@fedify/vocab-runtime 2.4.0-pr.936.41 → 2.5.0-dev.2271

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.
Files changed (138) hide show
  1. package/deno.json +4 -2
  2. package/dist/{tests/docloader-Ck8bcBir.mjs → contexts-BK1CqBR5.cjs} +164 -275
  3. package/dist/{tests/docloader-S_gQZ2tt.cjs → contexts-DPuJ4UYL.js} +160 -294
  4. package/dist/{docloader-C_dir7Xb.d.ts → docloader-CYwqh5Df.d.cts} +66 -2
  5. package/dist/{docloader-D2DTRiyA.d.cts → docloader-CYwqh5Df.d.ts} +66 -2
  6. package/dist/internal/jsonld-cache.cjs +70 -1
  7. package/dist/internal/jsonld-cache.d.cts +35 -2
  8. package/dist/internal/jsonld-cache.d.ts +35 -2
  9. package/dist/internal/jsonld-cache.js +67 -2
  10. package/dist/internal/portable-dereference.cjs +721 -0
  11. package/dist/internal/portable-dereference.d.cts +349 -0
  12. package/dist/internal/portable-dereference.d.ts +349 -0
  13. package/dist/internal/portable-dereference.js +702 -0
  14. package/dist/internal/signed-representation.cjs +378 -0
  15. package/dist/internal/signed-representation.d.cts +144 -0
  16. package/dist/internal/signed-representation.d.ts +144 -0
  17. package/dist/internal/signed-representation.js +371 -0
  18. package/dist/jsonld.cjs +2 -2
  19. package/dist/mod.cjs +711 -4527
  20. package/dist/mod.d.cts +400 -8
  21. package/dist/mod.d.ts +400 -9
  22. package/dist/mod.js +692 -4522
  23. package/dist/portable-DqtfLy_1.d.cts +160 -0
  24. package/dist/portable-DtWsu2yU.d.ts +160 -0
  25. package/dist/tests/body-CVR1bABr.mjs +130 -0
  26. package/dist/tests/body-T_yQWql3.cjs +153 -0
  27. package/dist/tests/body.test.cjs +89 -0
  28. package/dist/tests/body.test.d.cts +1 -0
  29. package/dist/tests/body.test.d.mts +1 -0
  30. package/dist/tests/body.test.mjs +90 -0
  31. package/dist/tests/contexts-CIKsin4e.mjs +4512 -0
  32. package/dist/tests/contexts-DizzBjz4.cjs +4523 -0
  33. package/dist/tests/decimal.test.cjs +8 -7
  34. package/dist/tests/decimal.test.mjs +8 -6
  35. package/dist/tests/digest-3FeH2Y-Q.cjs +176 -0
  36. package/dist/tests/digest-COC7xDiQ.mjs +141 -0
  37. package/dist/tests/digest.test.cjs +102 -0
  38. package/dist/tests/digest.test.d.cts +1 -0
  39. package/dist/tests/digest.test.d.mts +1 -0
  40. package/dist/tests/digest.test.mjs +103 -0
  41. package/dist/tests/docloader-C6yApEts.mjs +373 -0
  42. package/dist/tests/docloader-CJK2w3l-.cjs +396 -0
  43. package/dist/tests/docloader.test.cjs +692 -39
  44. package/dist/tests/docloader.test.mjs +686 -34
  45. package/dist/tests/internal/multicodec.test.cjs +2 -3
  46. package/dist/tests/internal/multicodec.test.mjs +2 -2
  47. package/dist/tests/internal/portable-dereference.test.cjs +172 -0
  48. package/dist/tests/internal/portable-dereference.test.d.cts +1 -0
  49. package/dist/tests/internal/portable-dereference.test.d.mts +1 -0
  50. package/dist/tests/internal/portable-dereference.test.mjs +173 -0
  51. package/dist/tests/jsonld-cache-BPQmOZWD.mjs +342 -0
  52. package/dist/tests/jsonld-cache-C07AyNOY.cjs +397 -0
  53. package/dist/tests/jsonld-cache.test.cjs +101 -298
  54. package/dist/tests/jsonld-cache.test.mjs +94 -289
  55. package/dist/tests/{key-_wXwomh_.cjs → key-C-AYkdJJ.cjs} +11 -4
  56. package/dist/tests/{key-CDGDH_vC.mjs → key-C2Db_TAJ.mjs} +11 -3
  57. package/dist/tests/key.test.cjs +6 -5
  58. package/dist/tests/key.test.mjs +6 -4
  59. package/dist/tests/langstr.test.cjs +4 -4
  60. package/dist/tests/langstr.test.mjs +2 -2
  61. package/dist/tests/link.test.cjs +2 -3
  62. package/dist/tests/link.test.mjs +2 -2
  63. package/dist/tests/multibase/multibase.test.cjs +8 -9
  64. package/dist/tests/multibase/multibase.test.mjs +6 -6
  65. package/dist/tests/{multibase-Bz_UUDtL.cjs → multibase-B5Mea7Ip.cjs} +19 -2
  66. package/dist/tests/{multibase-B4bvakyA.mjs → multibase-BPnF_L4e.mjs} +12 -1
  67. package/dist/tests/portable-dereference-BNXtgg5U.cjs +278 -0
  68. package/dist/tests/portable-dereference-DRE5bz-l.mjs +249 -0
  69. package/dist/tests/portable-media-C4LEXJk4.mjs +148 -0
  70. package/dist/tests/portable-media-D05JJIJg.cjs +153 -0
  71. package/dist/tests/portable-media.test.cjs +222 -0
  72. package/dist/tests/portable-media.test.d.cts +1 -0
  73. package/dist/tests/portable-media.test.d.mts +1 -0
  74. package/dist/tests/portable-media.test.mjs +223 -0
  75. package/dist/tests/portable-workers.test.cjs +36 -0
  76. package/dist/tests/portable-workers.test.d.cts +2 -0
  77. package/dist/tests/portable-workers.test.d.mts +2 -0
  78. package/dist/tests/portable-workers.test.mjs +35 -0
  79. package/dist/tests/{request-uk51rkhO.cjs → request-C-VZ9TLj.cjs} +10 -4
  80. package/dist/tests/{request-C8CaGwtt.mjs → request-Cf-A3DU-.mjs} +8 -2
  81. package/dist/tests/request.test.cjs +7 -4
  82. package/dist/tests/request.test.mjs +5 -2
  83. package/dist/tests/signed-representation.test.cjs +588 -0
  84. package/dist/tests/signed-representation.test.d.cts +1 -0
  85. package/dist/tests/signed-representation.test.d.mts +1 -0
  86. package/dist/tests/signed-representation.test.mjs +589 -0
  87. package/dist/tests/temporal.test.cjs +1 -2
  88. package/dist/tests/temporal.test.mjs +1 -1
  89. package/dist/tests/url-BNakuZ8k.cjs +998 -0
  90. package/dist/tests/url-DMxmp7ZG.mjs +859 -0
  91. package/dist/tests/url.test.cjs +489 -6
  92. package/dist/tests/url.test.mjs +489 -5
  93. package/dist/url-DrGTR8yv.cjs +993 -0
  94. package/dist/url-Dzyp-NsC.js +860 -0
  95. package/package.json +27 -4
  96. package/scripts/test-bun.mjs +17 -0
  97. package/src/body.test.ts +125 -0
  98. package/src/body.ts +152 -0
  99. package/src/contexts/cid-v1.json +114 -0
  100. package/src/contexts/fep-22cd.json +21 -0
  101. package/src/contexts/miscellany.json +17 -0
  102. package/src/contexts.ts +39 -1
  103. package/src/digest.test.ts +220 -0
  104. package/src/digest.ts +229 -0
  105. package/src/docloader.test.ts +918 -27
  106. package/src/docloader.ts +320 -105
  107. package/src/internal/jsonld-cache.ts +97 -1
  108. package/src/internal/portable-dereference.test.ts +254 -0
  109. package/src/internal/portable-dereference.ts +1000 -0
  110. package/src/internal/signed-representation.ts +565 -0
  111. package/src/jsonld-cache.test.ts +89 -0
  112. package/src/key.test.ts +9 -0
  113. package/src/key.ts +11 -1
  114. package/src/mod.ts +28 -0
  115. package/src/multibase/multibase.test.ts +5 -5
  116. package/src/portable-media.test.ts +293 -0
  117. package/src/portable-media.ts +264 -0
  118. package/src/portable-workers.test.ts +81 -0
  119. package/src/portable.ts +180 -0
  120. package/src/preprocessor.ts +7 -0
  121. package/src/request.test.ts +10 -1
  122. package/src/request.ts +9 -1
  123. package/src/signed-representation.test.ts +338 -0
  124. package/src/url.test.ts +823 -3
  125. package/src/url.ts +700 -23
  126. package/tsdown.config.ts +2 -0
  127. package/dist/tests/url-CsOV_B_P.cjs +0 -482
  128. package/dist/tests/url-Du7RQQgP.mjs +0 -392
  129. package/dist/url-DD4F0ULf.cjs +0 -483
  130. package/dist/url-DGVbSVVi.js +0 -393
  131. /package/dist/{chunk-M78iaK0I.cjs → rolldown-runtime-B7lfambq.cjs} +0 -0
  132. /package/dist/tests/{langstr-CbAxaeEZ.cjs → langstr-C4Fl80ae.cjs} +0 -0
  133. /package/dist/tests/{langstr-Di5AvKpB.mjs → langstr-CQ26J_L7.mjs} +0 -0
  134. /package/dist/tests/{link-NUUWCdnK.mjs → link-Cevmc87v.mjs} +0 -0
  135. /package/dist/tests/{link-FguCydMA.cjs → link-DlKm8bEr.cjs} +0 -0
  136. /package/dist/tests/{multicodec-CxGVGa91.cjs → multicodec-CLRPeW4N.cjs} +0 -0
  137. /package/dist/tests/{multicodec-CyFp54fI.mjs → multicodec-CRIj_05H.mjs} +0 -0
  138. /package/dist/tests/{chunk-C2EiDwsr.cjs → rolldown-runtime-emK7D4bc.cjs} +0 -0
@@ -0,0 +1,565 @@
1
+ import preloadedContexts from "../contexts.ts";
2
+
3
+ /**
4
+ * A JSON map. Retained signed representations are always plain JSON maps.
5
+ *
6
+ * @internal Technically exported for generated vocabulary classes, but not
7
+ * part of the public API contract. This is not considered public API for
8
+ * Semantic Versioning decisions.
9
+ */
10
+ export type SignedRepresentationJsonMap = Record<string, unknown>;
11
+
12
+ const RETAINED_SIGNED_REPRESENTATION = Symbol.for(
13
+ "@fedify/vocab-runtime.signedRepresentation.v1",
14
+ );
15
+
16
+ const SIGNED_VALUE_SCOPE = Symbol.for(
17
+ "@fedify/vocab-runtime.signedValueScope.v1",
18
+ );
19
+
20
+ /**
21
+ * The IRI namespace of the placeholder node references that stand in for a
22
+ * retained signed representation until the owning `toJsonLd()` frame puts the
23
+ * representation back.
24
+ */
25
+ const MARKER_NAMESPACE = "urn:x-fedify-signed-value:";
26
+
27
+ /** Bounds applied when validating a retained signed representation. */
28
+ const MAX_VALIDATION_DEPTH = 64;
29
+ const MAX_VALIDATION_NODES = 100_000;
30
+
31
+ /**
32
+ * The depth to which placeholders are put back. It has to exceed
33
+ * {@link MAX_VALIDATION_DEPTH}, because a restored document is itself nested
34
+ * inside the document that embeds it. Running out of depth is an error
35
+ * rather than a stopping point: a placeholder left in an unvisited subtree
36
+ * would reach the wire in place of the secured child.
37
+ */
38
+ const MAX_RESTORATION_DEPTH = 256;
39
+
40
+ /**
41
+ * JSON-LD keywords that may appear at the top level of a context definition
42
+ * without endangering placeholder recovery. `@vocab` and `@base` are checked
43
+ * separately because they can rewrite the placeholder IRI.
44
+ */
45
+ const HARMLESS_CONTEXT_KEYWORDS: ReadonlySet<string> = new Set([
46
+ "@protected",
47
+ "@version",
48
+ "@propagate",
49
+ ]);
50
+
51
+ /**
52
+ * Keyword aliases that are safe to leave in place because the placeholder
53
+ * recovery walker understands them.
54
+ */
55
+ const ALLOWED_KEYWORD_ALIASES: ReadonlyMap<string, string> = new Map([
56
+ ["id", "@id"],
57
+ ["type", "@type"],
58
+ ]);
59
+
60
+ function isJsonMap(value: unknown): value is SignedRepresentationJsonMap {
61
+ if (value == null || typeof value !== "object" || Array.isArray(value)) {
62
+ return false;
63
+ }
64
+ const prototype = Object.getPrototypeOf(value);
65
+ return prototype === Object.prototype || prototype === null;
66
+ }
67
+
68
+ /**
69
+ * Checks that a value is a plain JSON tree that is safe to embed verbatim in
70
+ * a serialized document: plain object and array prototypes only, string keys
71
+ * only, own enumerable data properties only, finite numbers, and no
72
+ * `undefined`.
73
+ *
74
+ * @internal Technically exported for generated vocabulary classes, but not
75
+ * part of the public API contract. This is not considered public API for
76
+ * Semantic Versioning decisions.
77
+ */
78
+ export function isPlainJsonTree(value: unknown): boolean {
79
+ let nodes = 0;
80
+ return check(value, 0);
81
+
82
+ function check(current: unknown, depth: number): boolean {
83
+ if (depth > MAX_VALIDATION_DEPTH) return false;
84
+ if (++nodes > MAX_VALIDATION_NODES) return false;
85
+ if (current === null) return true;
86
+ switch (typeof current) {
87
+ case "string":
88
+ case "boolean":
89
+ return true;
90
+ case "number":
91
+ return Number.isFinite(current);
92
+ case "object":
93
+ break;
94
+ default:
95
+ return false;
96
+ }
97
+ if (Array.isArray(current)) {
98
+ if (Object.getPrototypeOf(current) !== Array.prototype) return false;
99
+ if (Object.getOwnPropertySymbols(current).length > 0) return false;
100
+ for (let i = 0; i < current.length; i++) {
101
+ const descriptor = Object.getOwnPropertyDescriptor(current, i);
102
+ if (descriptor == null || !("value" in descriptor)) return false;
103
+ if (!check(descriptor.value, depth + 1)) return false;
104
+ }
105
+ return true;
106
+ }
107
+ if (!isJsonMap(current)) return false;
108
+ if (Object.getOwnPropertySymbols(current).length > 0) return false;
109
+ for (const key of Object.getOwnPropertyNames(current)) {
110
+ const descriptor = Object.getOwnPropertyDescriptor(current, key);
111
+ if (descriptor == null || !("value" in descriptor)) return false;
112
+ if (!descriptor.enumerable) return false;
113
+ if (!check(descriptor.value, depth + 1)) return false;
114
+ }
115
+ return true;
116
+ }
117
+ }
118
+
119
+ function deepFreeze<T>(value: T): T {
120
+ if (value == null || typeof value !== "object") return value;
121
+ if (Object.isFrozen(value)) return value;
122
+ Object.freeze(value);
123
+ for (const child of Object.values(value as Record<string, unknown>)) {
124
+ deepFreeze(child);
125
+ }
126
+ return value;
127
+ }
128
+
129
+ /**
130
+ * Attaches the secured JSON document that a signer captured for `target`.
131
+ *
132
+ * The document is the exact JSON value that the object's own Object Integrity
133
+ * Proof covers. Nested serialization embeds it verbatim instead of
134
+ * reconstructing the object under the parent's JSON-LD context. The value is
135
+ * deep-frozen, so it is independent of anything done to `target` afterwards.
136
+ *
137
+ * `clone()` never carries the attachment, because a clone is a fresh instance.
138
+ *
139
+ * @internal Technically exported for `@fedify/fedify` and generated
140
+ * vocabulary classes, but not part of the public API contract. This is not
141
+ * considered public API for Semantic Versioning decisions.
142
+ */
143
+ export function retainSignedRepresentation(
144
+ target: object,
145
+ securedDocument: SignedRepresentationJsonMap,
146
+ ): void {
147
+ if (!isJsonMap(securedDocument) || !isPlainJsonTree(securedDocument)) {
148
+ throw new TypeError(
149
+ "The secured document must be a plain JSON map within the validation " +
150
+ `bounds (at most ${MAX_VALIDATION_DEPTH} levels deep and ` +
151
+ `${MAX_VALIDATION_NODES} nodes).`,
152
+ );
153
+ }
154
+ Object.defineProperty(target, RETAINED_SIGNED_REPRESENTATION, {
155
+ value: deepFreeze(securedDocument),
156
+ enumerable: false,
157
+ writable: false,
158
+ configurable: true,
159
+ });
160
+ }
161
+
162
+ /**
163
+ * Returns the secured JSON document retained for `target`, if any.
164
+ *
165
+ * @internal Technically exported for generated vocabulary classes, but not
166
+ * part of the public API contract. This is not considered public API for
167
+ * Semantic Versioning decisions.
168
+ */
169
+ export function getRetainedSignedRepresentation(
170
+ target: unknown,
171
+ ): SignedRepresentationJsonMap | undefined {
172
+ if (target == null || typeof target !== "object") return undefined;
173
+ const retained = (target as Record<symbol, unknown>)[
174
+ RETAINED_SIGNED_REPRESENTATION
175
+ ];
176
+ return isJsonMap(retained) ? retained : undefined;
177
+ }
178
+
179
+ function resolveContextEntry(entry: unknown): unknown {
180
+ if (typeof entry !== "string") return entry;
181
+ if (!Object.hasOwn(preloadedContexts, entry)) return undefined;
182
+ const document = preloadedContexts[entry];
183
+ if (!isJsonMap(document)) return undefined;
184
+ return document["@context"];
185
+ }
186
+
187
+ /**
188
+ * Checks whether a JSON-LD context leaves placeholder node references
189
+ * recoverable after compaction.
190
+ *
191
+ * Placeholder recovery replaces a bare IRI string, `{"@id": …}`, or
192
+ * `{"id": …}`. A context that aliases `@id` under a different term, declares
193
+ * `@nest`, uses an `@id` or `@type` container, rebases or re-vocabularies the
194
+ * placeholder namespace, or defines a prefix that shortens the placeholder
195
+ * IRI could hide the placeholder from that walker or make the walker rewrite
196
+ * the wrong node. A context that cannot be resolved offline cannot be vetted
197
+ * at all. Any of those makes the context unsafe, and retention is then not
198
+ * used.
199
+ *
200
+ * @internal Technically exported for generated vocabulary classes, but not
201
+ * part of the public API contract. This is not considered public API for
202
+ * Semantic Versioning decisions.
203
+ */
204
+ export function isMarkerSafeContext(context: unknown): boolean {
205
+ return check(context, 0);
206
+
207
+ function check(current: unknown, depth: number): boolean {
208
+ if (depth > 8) return false;
209
+ if (Array.isArray(current)) {
210
+ return current.every((entry) => check(entry, depth + 1));
211
+ }
212
+ if (typeof current === "string") {
213
+ const resolved = resolveContextEntry(current);
214
+ if (resolved === undefined) return false;
215
+ return check(resolved, depth + 1);
216
+ }
217
+ if (current == null) return true;
218
+ if (!isJsonMap(current)) return false;
219
+ for (const [term, definition] of Object.entries(current)) {
220
+ if (term === "@vocab" || term === "@base") {
221
+ // A `@vocab` or `@base` that covers the placeholder namespace lets
222
+ // compaction emit a shortened or relative form of the marker.
223
+ if (typeof definition !== "string") return false;
224
+ if (sharesMarkerNamespace(definition)) return false;
225
+ continue;
226
+ }
227
+ if (term.startsWith("@")) {
228
+ if (!HARMLESS_CONTEXT_KEYWORDS.has(term)) return false;
229
+ continue;
230
+ }
231
+ // A term named `urn` turns `urn:x-fedify-signed-value:…` into
232
+ // something a JSON-LD processor reads as a compact IRI, which it
233
+ // rejects outright in safe mode.
234
+ if (MARKER_NAMESPACE.startsWith(`${term}:`)) return false;
235
+ if (!checkTerm(term, definition, depth)) return false;
236
+ }
237
+ return true;
238
+ }
239
+
240
+ function checkTerm(
241
+ term: string,
242
+ definition: unknown,
243
+ depth: number,
244
+ ): boolean {
245
+ if (definition == null) return true;
246
+ if (typeof definition === "string") {
247
+ return checkTermTarget(term, definition);
248
+ }
249
+ if (!isJsonMap(definition)) return false;
250
+ const target = definition["@id"];
251
+ if (target != null) {
252
+ if (typeof target !== "string") return false;
253
+ if (!checkTermTarget(term, target)) return false;
254
+ }
255
+ if ("@nest" in definition) return false;
256
+ if ("@reverse" in definition) return false;
257
+ const container = definition["@container"];
258
+ const containers = Array.isArray(container)
259
+ ? container
260
+ : container == null
261
+ ? []
262
+ : [container];
263
+ for (const entry of containers) {
264
+ if (entry === "@id" || entry === "@type") return false;
265
+ }
266
+ if ("@context" in definition) {
267
+ if (!check(definition["@context"], depth + 1)) return false;
268
+ }
269
+ return true;
270
+ }
271
+
272
+ function checkTermTarget(term: string, target: string): boolean {
273
+ if (target.startsWith("@")) {
274
+ return ALLOWED_KEYWORD_ALIASES.get(term) === target;
275
+ }
276
+ // A target with no scheme separator is not an IRI: it resolves through
277
+ // another term or through `@vocab`, so it can alias `@id` indirectly
278
+ // (`{ i: "id" }` where `id` is ActivityStreams' own `@id` alias).
279
+ // Resolving those chains is not worth it here; reject them and let the
280
+ // document take the ordinary-serialization fallback.
281
+ if (!target.includes(":")) return false;
282
+ // A prefix definition that covers the placeholder namespace lets
283
+ // compaction emit the marker as a compact IRI.
284
+ return !sharesMarkerNamespace(target);
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Reports whether an IRI prefix could shorten, relativize or otherwise hide a
290
+ * placeholder IRI. Both directions matter: a prefix shorter than the
291
+ * namespace covers every marker, and a longer one covers the markers whose
292
+ * random suffix happens to start with it.
293
+ */
294
+ function sharesMarkerNamespace(prefix: string): boolean {
295
+ return MARKER_NAMESPACE.startsWith(prefix) ||
296
+ prefix.startsWith(MARKER_NAMESPACE);
297
+ }
298
+
299
+ /**
300
+ * The per-`toJsonLd()`-call state that tracks retained children.
301
+ *
302
+ * @internal Technically exported for generated vocabulary classes, but not
303
+ * part of the public API contract. This is not considered public API for
304
+ * Semantic Versioning decisions.
305
+ */
306
+ export interface SignedValueScope {
307
+ /** Whether retained representations may be embedded at all. */
308
+ readonly enabled: boolean;
309
+ /** Marker IRI for each retained instance seen in this call. */
310
+ readonly markers: Map<object, string>;
311
+ /** Retained document for each marker IRI. */
312
+ readonly documents: Map<string, SignedRepresentationJsonMap>;
313
+ }
314
+
315
+ /**
316
+ * The result of entering a signed-value scope.
317
+ *
318
+ * @internal Technically exported for generated vocabulary classes, but not
319
+ * part of the public API contract. This is not considered public API for
320
+ * Semantic Versioning decisions.
321
+ */
322
+ export interface SignedValueScopeEntry {
323
+ readonly scope: SignedValueScope;
324
+ /**
325
+ * Whether this frame created the scope, and therefore has to put the
326
+ * retained documents back before returning.
327
+ */
328
+ readonly owner: boolean;
329
+ }
330
+
331
+ /**
332
+ * Joins the signed-value scope of the enclosing `toJsonLd()` frame, or starts
333
+ * one when this frame is the outermost.
334
+ *
335
+ * `options` must already be the internal copy that the generated encoder
336
+ * makes, never the object a caller passed in: the scope is attached to it as
337
+ * an enumerable symbol-keyed property so that every `{...options}` spread in
338
+ * the generated code propagates it to nested and inherited frames.
339
+ *
340
+ * @internal Technically exported for generated vocabulary classes, but not
341
+ * part of the public API contract. This is not considered public API for
342
+ * Semantic Versioning decisions.
343
+ */
344
+ export function enterSignedValueScope(
345
+ options: {
346
+ format?: "compact" | "expand";
347
+ context?:
348
+ | string
349
+ | Record<string, string>
350
+ | (string | Record<string, string>)[];
351
+ },
352
+ ): SignedValueScopeEntry {
353
+ const existing = (options as Record<symbol, unknown>)[SIGNED_VALUE_SCOPE];
354
+ if (existing != null) {
355
+ return { scope: existing as SignedValueScope, owner: false };
356
+ }
357
+ const scope: SignedValueScope = {
358
+ // A document that the caller asked to have expanded has no compact
359
+ // representation to preserve, so retention does not apply to it.
360
+ enabled: options.format !== "expand" &&
361
+ (options.context == null || isMarkerSafeContext(options.context)),
362
+ markers: new Map(),
363
+ documents: new Map(),
364
+ };
365
+ Object.defineProperty(options, SIGNED_VALUE_SCOPE, {
366
+ value: scope,
367
+ enumerable: true,
368
+ writable: false,
369
+ configurable: true,
370
+ });
371
+ return { scope, owner: true };
372
+ }
373
+
374
+ /**
375
+ * Emits a placeholder node reference for a value that carries a retained
376
+ * signed representation, or `undefined` when it does not carry one.
377
+ *
378
+ * Both encoder paths use placeholders, never the retained document itself:
379
+ * an ancestor frame may still expand or compact the value, which would
380
+ * destroy an embedded document but leaves a node reference recoverable.
381
+ *
382
+ * @internal Technically exported for generated vocabulary classes, but not
383
+ * part of the public API contract. This is not considered public API for
384
+ * Semantic Versioning decisions.
385
+ */
386
+ export function retainedSignedValueRef(
387
+ value: unknown,
388
+ scope: SignedValueScope,
389
+ ): { "@id": string } | undefined {
390
+ if (!scope.enabled) return undefined;
391
+ if (value == null || typeof value !== "object") return undefined;
392
+ const retained = getRetainedSignedRepresentation(value);
393
+ if (retained == null) return undefined;
394
+ let marker = scope.markers.get(value);
395
+ if (marker == null) {
396
+ // Validated once per instance per call; `retainSignedRepresentation()`
397
+ // already enforces this, so a failure here means the carrier was written
398
+ // by something other than a signer.
399
+ if (!isPlainJsonTree(retained)) return undefined;
400
+ marker = `${MARKER_NAMESPACE}${crypto.randomUUID()}`;
401
+ scope.markers.set(value, marker);
402
+ scope.documents.set(marker, retained);
403
+ }
404
+ return { "@id": marker };
405
+ }
406
+
407
+ function markerOf(
408
+ value: unknown,
409
+ scope: SignedValueScope,
410
+ ): string | undefined {
411
+ return typeof value === "string" && scope.documents.has(value)
412
+ ? value
413
+ : undefined;
414
+ }
415
+
416
+ /**
417
+ * Puts every retained signed representation back in place of its placeholder
418
+ * node reference.
419
+ *
420
+ * Only the frame that owns the scope calls this, and only after compaction
421
+ * and context embedding are complete.
422
+ *
423
+ * @throws {TypeError} If a retained document was not put back anywhere, or if
424
+ * any trace of a placeholder survives. Both mean the serialized
425
+ * document does not contain the secured child it was told to embed,
426
+ * and emitting it would produce a signed child that silently fails
427
+ * verification.
428
+ *
429
+ * @internal Technically exported for generated vocabulary classes, but not
430
+ * part of the public API contract. This is not considered public API for
431
+ * Semantic Versioning decisions.
432
+ */
433
+ export function resolveSignedValues(
434
+ document: unknown,
435
+ scope: SignedValueScope,
436
+ ): unknown {
437
+ if (scope.documents.size < 1) return document;
438
+ const restored = new Set<string>();
439
+ const resolved = restore(document, 0);
440
+ // Emitted references are not counted, because the generated encoders can
441
+ // legitimately emit one more or one fewer than the output holds: a subtype
442
+ // that redefines an inherited property re-encodes it after deleting the
443
+ // inherited key, and a functional property with redundant properties writes
444
+ // one encoded value under several keys. What must hold is that every
445
+ // retained document reached the output somewhere, and that no trace of a
446
+ // placeholder is left behind.
447
+ for (const marker of scope.documents.keys()) {
448
+ if (restored.has(marker)) continue;
449
+ throw new TypeError(
450
+ `Failed to preserve a signed child representation: the placeholder ` +
451
+ `${marker} is not in the serialized document.`,
452
+ );
453
+ }
454
+ // A shortened or relativized marker no longer matches the walker's
455
+ // reference shapes, so look for the random suffix anywhere in the output,
456
+ // including in map keys.
457
+ const trace = findMarkerTrace(resolved, scope);
458
+ if (trace != null) {
459
+ throw new TypeError(
460
+ `Failed to preserve a signed child representation: a trace of the ` +
461
+ `placeholder ${trace} survived serialization.`,
462
+ );
463
+ }
464
+ return resolved;
465
+
466
+ function restore(value: unknown, depth: number): unknown {
467
+ if (depth > MAX_RESTORATION_DEPTH) {
468
+ throw new TypeError(
469
+ "Failed to preserve a signed child representation: the serialized " +
470
+ `document is nested deeper than ${MAX_RESTORATION_DEPTH} levels, ` +
471
+ "so a placeholder could be left unresolved.",
472
+ );
473
+ }
474
+ const direct = markerOf(value, scope);
475
+ if (direct != null) return take(direct);
476
+ if (Array.isArray(value)) {
477
+ let changed = false;
478
+ const mapped = value.map((item) => {
479
+ const next = restore(item, depth + 1);
480
+ if (next !== item) changed = true;
481
+ return next;
482
+ });
483
+ return changed ? mapped : value;
484
+ }
485
+ if (!isJsonMap(value)) return value;
486
+ const keys = Object.keys(value);
487
+ if (keys.length === 1 && (keys[0] === "@id" || keys[0] === "id")) {
488
+ const marker = markerOf(value[keys[0]], scope);
489
+ if (marker != null) return take(marker);
490
+ }
491
+ let changed = false;
492
+ // Write into a null-prototype object so that a key called `__proto__`
493
+ // becomes a regular own property instead of poisoning the chain.
494
+ const mapped: Record<string, unknown> = Object.create(null);
495
+ for (const key of keys) {
496
+ // `@context` holds term definitions, never a node reference.
497
+ const next = key === "@context"
498
+ ? value[key]
499
+ : restore(value[key], depth + 1);
500
+ if (next !== value[key]) changed = true;
501
+ mapped[key] = next;
502
+ }
503
+ if (!changed) return value;
504
+ // Restore the ordinary prototype once every key is an own property, so
505
+ // that the result compares equal to a plain object literal.
506
+ return Object.setPrototypeOf(mapped, Object.prototype);
507
+ }
508
+
509
+ function take(marker: string): SignedRepresentationJsonMap {
510
+ restored.add(marker);
511
+ // A fresh copy per insertion site, so that two sites never alias one
512
+ // object and a caller mutating the returned document cannot reach the
513
+ // frozen snapshot. The restored document is not traversed again.
514
+ return structuredClone(
515
+ scope.documents.get(marker),
516
+ ) as SignedRepresentationJsonMap;
517
+ }
518
+ }
519
+
520
+ /**
521
+ * Finds any surviving trace of a placeholder IRI in a resolved document,
522
+ * matching the random suffix rather than the whole IRI so that a shortened,
523
+ * relativized or otherwise rewritten marker is caught too.
524
+ */
525
+ function findMarkerTrace(
526
+ document: unknown,
527
+ scope: SignedValueScope,
528
+ ): string | undefined {
529
+ const suffixes = new Map<string, string>();
530
+ for (const marker of scope.documents.keys()) {
531
+ suffixes.set(marker.slice(MARKER_NAMESPACE.length), marker);
532
+ }
533
+ return search(document, 0);
534
+
535
+ function matched(value: string): string | undefined {
536
+ for (const [suffix, marker] of suffixes) {
537
+ if (value.includes(suffix)) return marker;
538
+ }
539
+ return undefined;
540
+ }
541
+
542
+ function search(value: unknown, depth: number): string | undefined {
543
+ if (depth > MAX_RESTORATION_DEPTH) {
544
+ throw new TypeError(
545
+ "Failed to preserve a signed child representation: the serialized " +
546
+ `document is nested deeper than ${MAX_RESTORATION_DEPTH} levels, ` +
547
+ "so a surviving placeholder could go unnoticed.",
548
+ );
549
+ }
550
+ if (typeof value === "string") return matched(value);
551
+ if (Array.isArray(value)) {
552
+ for (const item of value) {
553
+ const found = search(item, depth + 1);
554
+ if (found != null) return found;
555
+ }
556
+ return undefined;
557
+ }
558
+ if (value == null || typeof value !== "object") return undefined;
559
+ for (const [key, child] of Object.entries(value)) {
560
+ const found = matched(key) ?? search(child, depth + 1);
561
+ if (found != null) return found;
562
+ }
563
+ return undefined;
564
+ }
565
+ }
@@ -3,12 +3,101 @@ import { test } from "node:test";
3
3
  import {
4
4
  compactJsonLdCache,
5
5
  getJsonLdContext,
6
+ getPortableActorGateways,
6
7
  isTrustedIriOrigin,
7
8
  normalizeJsonLdIris,
8
9
  } from "./internal/jsonld-cache.ts";
9
10
  import jsonld from "./jsonld.ts";
10
11
  import { parseIri } from "./url.ts";
11
12
 
13
+ test("getPortableActorGateways() preserves unmapped portable actor gateways", () => {
14
+ const gateways = [
15
+ { "@value": "https://second.example" },
16
+ { "@value": "https://first.example" },
17
+ { "@value": "not a valid gateway" },
18
+ ];
19
+ const ids = [
20
+ "ap://did:key:z6Mkabc/actor",
21
+ "ap+ef61://did:key:z6Mkabc/actor",
22
+ "https://gateway.example/.well-known/apgateway/did:key:z6Mkabc/actor",
23
+ ];
24
+ for (const id of ids) {
25
+ const node = {
26
+ "@id": id,
27
+ "http://www.w3.org/ns/ldp#inbox": [{ "@id": `${id}/inbox` }],
28
+ "https://www.w3.org/ns/activitystreams#outbox": [
29
+ { "@id": `${id}/outbox` },
30
+ ],
31
+ "_:gateways": gateways,
32
+ };
33
+ const original = structuredClone(node);
34
+ strictEqual(getPortableActorGateways(node), gateways, id);
35
+ strictEqual(getPortableActorGateways(node, false), undefined, id);
36
+ deepStrictEqual(node, original);
37
+ }
38
+ });
39
+
40
+ test("getPortableActorGateways() gives mapped gateways precedence", () => {
41
+ const canonical = "https://w3id.org/fep/ef61/gateways";
42
+ const gateways = [{ "@list": [{ "@id": "https://gateway.example" }] }];
43
+ const node: Record<string, unknown> = {
44
+ "@id": "ap://did:key:z6Mkabc/actor",
45
+ "http://www.w3.org/ns/ldp#inbox": [],
46
+ "https://www.w3.org/ns/activitystreams#outbox": [],
47
+ "_:gateways": [{ "@value": "https://other.example" }],
48
+ [canonical]: gateways,
49
+ };
50
+ strictEqual(getPortableActorGateways(node), gateways);
51
+ strictEqual(getPortableActorGateways(node, false), gateways);
52
+ for (const value of [undefined, null, {}, "https://gateway.example", []]) {
53
+ node[canonical] = value;
54
+ strictEqual(
55
+ getPortableActorGateways(node),
56
+ Array.isArray(value) ? value : undefined,
57
+ );
58
+ }
59
+ });
60
+
61
+ test("getPortableActorGateways() limits unmapped terms to portable actors", () => {
62
+ const node: Record<string, unknown> = {
63
+ "@id": "ap://did:key:z6Mkabc/actor",
64
+ "http://www.w3.org/ns/ldp#inbox": [],
65
+ "https://www.w3.org/ns/activitystreams#outbox": [],
66
+ "_:gateways": [{ "@value": "https://gateway.example" }],
67
+ };
68
+ for (
69
+ const id of [
70
+ undefined,
71
+ 1,
72
+ "not an IRI",
73
+ "https://ordinary.example/actor",
74
+ "https://gateway.example/.well-known/apgateway/not-a-did/actor",
75
+ "https://user@gateway.example/.well-known/apgateway/did:key:z6Mkabc/actor",
76
+ ]
77
+ ) {
78
+ strictEqual(getPortableActorGateways({ ...node, "@id": id }), undefined);
79
+ }
80
+ for (
81
+ const property of [
82
+ "http://www.w3.org/ns/ldp#inbox",
83
+ "https://www.w3.org/ns/activitystreams#outbox",
84
+ ]
85
+ ) {
86
+ const nonActor = { ...node };
87
+ delete nonActor[property];
88
+ strictEqual(getPortableActorGateways(nonActor), undefined);
89
+ }
90
+ strictEqual(
91
+ getPortableActorGateways({ ...node, "_:gateways": null }),
92
+ undefined,
93
+ );
94
+ const empty: unknown[] = [];
95
+ strictEqual(
96
+ getPortableActorGateways({ ...node, "_:gateways": empty }),
97
+ empty,
98
+ );
99
+ });
100
+
12
101
  test("isTrustedIriOrigin() trusts same portable IRI origins", () => {
13
102
  ok(isTrustedIriOrigin(
14
103
  {},
package/src/key.test.ts CHANGED
@@ -218,6 +218,15 @@ test("exportDidKey() and importDidKey()", async () => {
218
218
  () => exportDidKey(rsaKey),
219
219
  TypeError,
220
220
  );
221
+ const { privateKey } = await crypto.subtle.generateKey(
222
+ "Ed25519",
223
+ true,
224
+ ["sign", "verify"],
225
+ ) as CryptoKeyPair;
226
+ await rejects(
227
+ () => exportDidKey(privateKey),
228
+ new TypeError("The key must be a public key."),
229
+ );
221
230
  await rejects(
222
231
  () => importDidKey(`did:key:${rsaMultibase}`),
223
232
  new TypeError("Unsupported did:key type: 0x1205"),