@fedify/vocab-runtime 2.4.0-pr.936.41 → 2.4.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/deno.json +4 -2
- package/dist/{tests/docloader-Ck8bcBir.mjs → contexts-BK1CqBR5.cjs} +164 -275
- package/dist/{tests/docloader-S_gQZ2tt.cjs → contexts-DPuJ4UYL.js} +160 -294
- package/dist/{docloader-C_dir7Xb.d.ts → docloader-CYwqh5Df.d.cts} +66 -2
- package/dist/{docloader-D2DTRiyA.d.cts → docloader-CYwqh5Df.d.ts} +66 -2
- package/dist/internal/jsonld-cache.cjs +70 -1
- package/dist/internal/jsonld-cache.d.cts +35 -2
- package/dist/internal/jsonld-cache.d.ts +35 -2
- package/dist/internal/jsonld-cache.js +67 -2
- package/dist/internal/portable-dereference.cjs +721 -0
- package/dist/internal/portable-dereference.d.cts +349 -0
- package/dist/internal/portable-dereference.d.ts +349 -0
- package/dist/internal/portable-dereference.js +702 -0
- package/dist/internal/signed-representation.cjs +378 -0
- package/dist/internal/signed-representation.d.cts +144 -0
- package/dist/internal/signed-representation.d.ts +144 -0
- package/dist/internal/signed-representation.js +371 -0
- package/dist/jsonld.cjs +2 -2
- package/dist/mod.cjs +711 -4527
- package/dist/mod.d.cts +400 -8
- package/dist/mod.d.ts +400 -9
- package/dist/mod.js +692 -4522
- package/dist/portable-DqtfLy_1.d.cts +160 -0
- package/dist/portable-DtWsu2yU.d.ts +160 -0
- package/dist/tests/body-CYiu9CO-.mjs +130 -0
- package/dist/tests/body-CkEwROsn.cjs +153 -0
- package/dist/tests/body.test.cjs +89 -0
- package/dist/tests/body.test.d.cts +1 -0
- package/dist/tests/body.test.d.mts +1 -0
- package/dist/tests/body.test.mjs +90 -0
- package/dist/tests/contexts-CIKsin4e.mjs +4512 -0
- package/dist/tests/contexts-DizzBjz4.cjs +4523 -0
- package/dist/tests/decimal.test.cjs +8 -7
- package/dist/tests/decimal.test.mjs +8 -6
- package/dist/tests/digest-3FeH2Y-Q.cjs +176 -0
- package/dist/tests/digest-COC7xDiQ.mjs +141 -0
- package/dist/tests/digest.test.cjs +102 -0
- package/dist/tests/digest.test.d.cts +1 -0
- package/dist/tests/digest.test.d.mts +1 -0
- package/dist/tests/digest.test.mjs +103 -0
- package/dist/tests/docloader-C3YFl6y4.cjs +396 -0
- package/dist/tests/docloader-CvmS4sVq.mjs +373 -0
- package/dist/tests/docloader.test.cjs +692 -39
- package/dist/tests/docloader.test.mjs +686 -34
- package/dist/tests/internal/multicodec.test.cjs +2 -3
- package/dist/tests/internal/multicodec.test.mjs +2 -2
- package/dist/tests/internal/portable-dereference.test.cjs +172 -0
- package/dist/tests/internal/portable-dereference.test.d.cts +1 -0
- package/dist/tests/internal/portable-dereference.test.d.mts +1 -0
- package/dist/tests/internal/portable-dereference.test.mjs +173 -0
- package/dist/tests/jsonld-cache-BPQmOZWD.mjs +342 -0
- package/dist/tests/jsonld-cache-C07AyNOY.cjs +397 -0
- package/dist/tests/jsonld-cache.test.cjs +101 -298
- package/dist/tests/jsonld-cache.test.mjs +94 -289
- package/dist/tests/{key-_wXwomh_.cjs → key-C-AYkdJJ.cjs} +11 -4
- package/dist/tests/{key-CDGDH_vC.mjs → key-C2Db_TAJ.mjs} +11 -3
- package/dist/tests/key.test.cjs +6 -5
- package/dist/tests/key.test.mjs +6 -4
- package/dist/tests/langstr.test.cjs +4 -4
- package/dist/tests/langstr.test.mjs +2 -2
- package/dist/tests/link.test.cjs +2 -3
- package/dist/tests/link.test.mjs +2 -2
- package/dist/tests/multibase/multibase.test.cjs +8 -9
- package/dist/tests/multibase/multibase.test.mjs +6 -6
- package/dist/tests/{multibase-Bz_UUDtL.cjs → multibase-B5Mea7Ip.cjs} +19 -2
- package/dist/tests/{multibase-B4bvakyA.mjs → multibase-BPnF_L4e.mjs} +12 -1
- package/dist/tests/portable-dereference-BNXtgg5U.cjs +278 -0
- package/dist/tests/portable-dereference-DRE5bz-l.mjs +249 -0
- package/dist/tests/portable-media-BLKllU56.cjs +153 -0
- package/dist/tests/portable-media-DRSGBORB.mjs +148 -0
- package/dist/tests/portable-media.test.cjs +222 -0
- package/dist/tests/portable-media.test.d.cts +1 -0
- package/dist/tests/portable-media.test.d.mts +1 -0
- package/dist/tests/portable-media.test.mjs +223 -0
- package/dist/tests/portable-workers.test.cjs +36 -0
- package/dist/tests/portable-workers.test.d.cts +2 -0
- package/dist/tests/portable-workers.test.d.mts +2 -0
- package/dist/tests/portable-workers.test.mjs +35 -0
- package/dist/tests/{request-uk51rkhO.cjs → request-8vPWtV1-.cjs} +10 -4
- package/dist/tests/{request-C8CaGwtt.mjs → request-DRaOaTqD.mjs} +8 -2
- package/dist/tests/request.test.cjs +7 -4
- package/dist/tests/request.test.mjs +5 -2
- package/dist/tests/signed-representation.test.cjs +588 -0
- package/dist/tests/signed-representation.test.d.cts +1 -0
- package/dist/tests/signed-representation.test.d.mts +1 -0
- package/dist/tests/signed-representation.test.mjs +589 -0
- package/dist/tests/temporal.test.cjs +1 -2
- package/dist/tests/temporal.test.mjs +1 -1
- package/dist/tests/url-BNakuZ8k.cjs +998 -0
- package/dist/tests/url-DMxmp7ZG.mjs +859 -0
- package/dist/tests/url.test.cjs +489 -6
- package/dist/tests/url.test.mjs +489 -5
- package/dist/url-DrGTR8yv.cjs +993 -0
- package/dist/url-Dzyp-NsC.js +860 -0
- package/package.json +27 -4
- package/scripts/test-bun.mjs +17 -0
- package/src/body.test.ts +125 -0
- package/src/body.ts +152 -0
- package/src/contexts/cid-v1.json +114 -0
- package/src/contexts/fep-22cd.json +21 -0
- package/src/contexts/miscellany.json +17 -0
- package/src/contexts.ts +39 -1
- package/src/digest.test.ts +220 -0
- package/src/digest.ts +229 -0
- package/src/docloader.test.ts +918 -27
- package/src/docloader.ts +320 -105
- package/src/internal/jsonld-cache.ts +97 -1
- package/src/internal/portable-dereference.test.ts +254 -0
- package/src/internal/portable-dereference.ts +1000 -0
- package/src/internal/signed-representation.ts +565 -0
- package/src/jsonld-cache.test.ts +89 -0
- package/src/key.test.ts +9 -0
- package/src/key.ts +11 -1
- package/src/mod.ts +28 -0
- package/src/multibase/multibase.test.ts +5 -5
- package/src/portable-media.test.ts +293 -0
- package/src/portable-media.ts +264 -0
- package/src/portable-workers.test.ts +81 -0
- package/src/portable.ts +180 -0
- package/src/preprocessor.ts +7 -0
- package/src/request.test.ts +10 -1
- package/src/request.ts +9 -1
- package/src/signed-representation.test.ts +338 -0
- package/src/url.test.ts +823 -3
- package/src/url.ts +700 -23
- package/tsdown.config.ts +2 -0
- package/dist/tests/url-CsOV_B_P.cjs +0 -482
- package/dist/tests/url-Du7RQQgP.mjs +0 -392
- package/dist/url-DD4F0ULf.cjs +0 -483
- package/dist/url-DGVbSVVi.js +0 -393
- /package/dist/{chunk-M78iaK0I.cjs → rolldown-runtime-B7lfambq.cjs} +0 -0
- /package/dist/tests/{langstr-CbAxaeEZ.cjs → langstr-C4Fl80ae.cjs} +0 -0
- /package/dist/tests/{langstr-Di5AvKpB.mjs → langstr-CQ26J_L7.mjs} +0 -0
- /package/dist/tests/{link-NUUWCdnK.mjs → link-Cevmc87v.mjs} +0 -0
- /package/dist/tests/{link-FguCydMA.cjs → link-DlKm8bEr.cjs} +0 -0
- /package/dist/tests/{multicodec-CxGVGa91.cjs → multicodec-CLRPeW4N.cjs} +0 -0
- /package/dist/tests/{multicodec-CyFp54fI.mjs → multicodec-CRIj_05H.mjs} +0 -0
- /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
|
+
}
|
package/src/jsonld-cache.test.ts
CHANGED
|
@@ -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"),
|