@fedify/vocab 2.4.0-dev.2063 → 2.4.0-dev.2071

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.
@@ -7,6 +7,7 @@ import {
7
7
  type DocumentLoader,
8
8
  FetchError,
9
9
  getDocumentLoader,
10
+ parseIri,
10
11
  type PortableObjectVerification,
11
12
  type PortableObjectVerifier,
12
13
  type PortableObjectVerifierOptions,
@@ -749,12 +750,19 @@ test("getObject() shares context documents within a dereference", async (t) => {
749
750
  const loaded: string[] = [];
750
751
  const contextLoader: DocumentLoader = async (url) => {
751
752
  loaded.push(url);
753
+ if (url === contextUrl) {
754
+ return {
755
+ contextUrl: null,
756
+ documentUrl: url,
757
+ document: { "@context": {} },
758
+ };
759
+ }
752
760
  return await mockDocumentLoader(url);
753
761
  };
754
762
  const object = await (await createActivity()).getObject({
755
763
  documentLoader: createLoader({
756
764
  [`https://gw1.example${gatewayPath}`]: {
757
- ...note(),
765
+ ...document,
758
766
  attributedTo: "https://example.com/person",
759
767
  },
760
768
  }),
@@ -763,20 +771,20 @@ test("getObject() shares context documents within a dereference", async (t) => {
763
771
  verifyPortableObject: createVerifier(),
764
772
  });
765
773
  assertInstanceOf(object, Note);
766
- deepStrictEqual(loaded, []);
767
- // Later dereferences on the returned object use the caller's context
768
- // loader again, including for the baseline contexts:
774
+ deepStrictEqual(loaded, [contextUrl]);
775
+ // Later dereferences on the returned object do not reuse the snapshot,
776
+ // so they load the custom context through the caller's loader again:
769
777
  const person = await object.getAttribution({
770
778
  documentLoader: createLoader({
771
779
  "https://example.com/person": {
772
- "@context": "https://www.w3.org/ns/activitystreams",
780
+ "@context": ["https://www.w3.org/ns/activitystreams", contextUrl],
773
781
  id: "https://example.com/person",
774
782
  type: "Person",
775
783
  },
776
784
  }),
777
785
  });
778
786
  deepStrictEqual(person?.id, new URL("https://example.com/person"));
779
- ok((loaded as string[]).includes("https://www.w3.org/ns/activitystreams"));
787
+ deepStrictEqual(loaded, [contextUrl, contextUrl]);
780
788
  });
781
789
 
782
790
  await t.step("failed context loads are retried", async () => {
@@ -1113,14 +1121,308 @@ test("accessors do not trust objects embedded in unsecured collections", async (
1113
1121
  deepStrictEqual(items3, []);
1114
1122
  });
1115
1123
 
1116
- test("accessors reject compatible identifiers from portable objects", async () => {
1117
- const compatibleId = gatewayUrl("https://evil.example", "/actor/outbox");
1118
- const collection = (id: string) => ({
1124
+ function createRedirectingLoader(
1125
+ responses: Record<string, { documentUrl: string; document: unknown }>,
1126
+ ): DocumentLoader & { readonly fetched: string[] } {
1127
+ const fetched: string[] = [];
1128
+ // deno-lint-ignore require-await
1129
+ const loader = async (url: string): Promise<RemoteDocument> => {
1130
+ fetched.push(url);
1131
+ const response = responses[url];
1132
+ if (response == null) {
1133
+ throw new FetchError(
1134
+ url,
1135
+ "HTTP 404",
1136
+ new globalThis.Response(null, { status: 404 }),
1137
+ );
1138
+ }
1139
+ return { contextUrl: null, ...response };
1140
+ };
1141
+ return Object.assign(loader, { fetched });
1142
+ }
1143
+
1144
+ function createHttpActivity(object: unknown): Promise<Create> {
1145
+ return Create.fromJsonLd({
1119
1146
  "@context": "https://www.w3.org/ns/activitystreams",
1120
- id,
1121
- type: "OrderedCollection",
1122
- totalItems: 0,
1147
+ type: "Create",
1148
+ id: "https://example.com/activities/1",
1149
+ object,
1150
+ }, { contextLoader: mockDocumentLoader });
1151
+ }
1152
+
1153
+ const compatibleNoteId = gatewayUrl("https://gw.example", "/objects/1");
1154
+
1155
+ test("accessors dereference compatible identifiers through their gateways", async (t) => {
1156
+ await t.step("through the named gateway", async () => {
1157
+ const documentLoader = createLoader({ [compatibleNoteId]: note() });
1158
+ const verifier = createRecordingVerifier();
1159
+ const activity = await createHttpActivity(compatibleNoteId);
1160
+ const object = await activity.getObject({
1161
+ documentLoader,
1162
+ contextLoader: mockDocumentLoader,
1163
+ verifyPortableObject: verifier,
1164
+ });
1165
+ assertInstanceOf(object, Note);
1166
+ deepStrictEqual(object.id, parseIri(objectId));
1167
+ deepStrictEqual(documentLoader.fetched, [compatibleNoteId]);
1168
+ deepStrictEqual(verifier.calls.length, 1);
1169
+ const { options } = verifier.calls[0];
1170
+ deepStrictEqual(options.documentUrl, new URL(compatibleNoteId));
1171
+ deepStrictEqual(options.gateways, undefined);
1172
+ deepStrictEqual(options.gatewayHints, [new URL("https://gw.example")]);
1173
+ ok(options.referrer?.object === activity);
1174
+ // The verified object is cached:
1175
+ ok(
1176
+ await activity.getObject({ documentLoader: createLoader({}) }) === object,
1177
+ );
1178
+ });
1179
+
1180
+ await t.step("then through explicit gateways", async () => {
1181
+ const documentLoader = createLoader({
1182
+ [gatewayUrl("https://gw2.example", "/objects/1")]: note(),
1183
+ });
1184
+ const verifier = createRecordingVerifier();
1185
+ const object = await (await createHttpActivity(compatibleNoteId))
1186
+ .getObject({
1187
+ documentLoader,
1188
+ contextLoader: mockDocumentLoader,
1189
+ gateways: ["https://gw2.example", "https://gw.example"],
1190
+ verifyPortableObject: verifier,
1191
+ });
1192
+ assertInstanceOf(object, Note);
1193
+ deepStrictEqual(documentLoader.fetched, [
1194
+ compatibleNoteId,
1195
+ gatewayUrl("https://gw2.example", "/objects/1"),
1196
+ ]);
1197
+ deepStrictEqual(verifier.calls[0].options.gateways, [
1198
+ new URL("https://gw2.example"),
1199
+ new URL("https://gw.example"),
1200
+ ]);
1201
+ deepStrictEqual(verifier.calls[0].options.gatewayHints, undefined);
1202
+
1203
+ // An empty list still asks the gateway that the identifier names:
1204
+ const documentLoader2 = createLoader({ [compatibleNoteId]: note() });
1205
+ const object2 = await (await createHttpActivity(compatibleNoteId))
1206
+ .getObject({
1207
+ documentLoader: documentLoader2,
1208
+ contextLoader: mockDocumentLoader,
1209
+ gateways: [],
1210
+ verifyPortableObject: createVerifier(),
1211
+ });
1212
+ assertInstanceOf(object2, Note);
1213
+ deepStrictEqual(documentLoader2.fetched, [compatibleNoteId]);
1214
+ });
1215
+
1216
+ await t.step("in plural accessors", async () => {
1217
+ const documentLoader = createLoader({ [compatibleNoteId]: note() });
1218
+ const objects = await Array.fromAsync(
1219
+ (await createHttpActivity([compatibleNoteId])).getObjects({
1220
+ documentLoader,
1221
+ contextLoader: mockDocumentLoader,
1222
+ verifyPortableObject: createVerifier(),
1223
+ }),
1224
+ );
1225
+ deepStrictEqual(objects.map((o) => o.id), [parseIri(objectId)]);
1226
+ deepStrictEqual(documentLoader.fetched, [compatibleNoteId]);
1227
+ });
1228
+ });
1229
+
1230
+ test("accessors reject forged objects at compatible identifiers", async () => {
1231
+ const forgedId = gatewayUrl("https://evil.example", "/objects/1");
1232
+ // The unsigned object from the issue, identified by the compatible
1233
+ // identifier itself:
1234
+ const forged = {
1235
+ "@context": "https://www.w3.org/ns/activitystreams",
1236
+ id: forgedId,
1237
+ type: "Note",
1238
+ attributedTo: gatewayUrl("https://evil.example", "/actor"),
1239
+ content: "Forged",
1240
+ };
1241
+ for (const crossOrigin of [undefined, "ignore", "trust"] as const) {
1242
+ // Its @id is not a portable ID, so it fails the identity check:
1243
+ const verifier = createRecordingVerifier();
1244
+ deepStrictEqual(
1245
+ await (await createHttpActivity(forgedId)).getObject({
1246
+ documentLoader: createLoader({ [forgedId]: forged }),
1247
+ contextLoader: mockDocumentLoader,
1248
+ verifyPortableObject: verifier,
1249
+ crossOrigin,
1250
+ }),
1251
+ null,
1252
+ );
1253
+ deepStrictEqual(verifier.calls, []);
1254
+ // With a portable ID, it fails the proof policy:
1255
+ deepStrictEqual(
1256
+ await (await createHttpActivity(forgedId)).getObject({
1257
+ documentLoader: createLoader({ [forgedId]: note() }),
1258
+ contextLoader: mockDocumentLoader,
1259
+ verifyPortableObject: createVerifier(false),
1260
+ crossOrigin,
1261
+ }),
1262
+ null,
1263
+ );
1264
+ }
1265
+ await rejects(
1266
+ async () =>
1267
+ await (await createHttpActivity(forgedId)).getObject({
1268
+ documentLoader: createLoader({ [forgedId]: note() }),
1269
+ contextLoader: mockDocumentLoader,
1270
+ verifyPortableObject: createVerifier(false),
1271
+ crossOrigin: "throw",
1272
+ }),
1273
+ /No gateway returned a valid portable object/,
1274
+ );
1275
+ });
1276
+
1277
+ test("accessors reject malformed compatible identifiers", async () => {
1278
+ const malformed = [
1279
+ // Location hints are not allowed in compatible identifiers:
1280
+ `${compatibleNoteId}?@gateway=https%3A%2F%2Fgw2.example`,
1281
+ // Nor are credentials:
1282
+ compatibleNoteId.replace("https://", "https://user:pass@"),
1283
+ ];
1284
+ for (const url of malformed) {
1285
+ const documentLoader = createLoader({ [url]: note() });
1286
+ const verifier = createRecordingVerifier();
1287
+ const activity = await createHttpActivity(url);
1288
+ deepStrictEqual(
1289
+ await activity.getObject({
1290
+ documentLoader,
1291
+ contextLoader: mockDocumentLoader,
1292
+ verifyPortableObject: verifier,
1293
+ }),
1294
+ null,
1295
+ );
1296
+ deepStrictEqual(
1297
+ await activity.getObject({
1298
+ documentLoader,
1299
+ contextLoader: mockDocumentLoader,
1300
+ verifyPortableObject: verifier,
1301
+ crossOrigin: "throw",
1302
+ suppressError: true,
1303
+ }),
1304
+ null,
1305
+ );
1306
+ await rejects(
1307
+ async () =>
1308
+ await activity.getObject({
1309
+ documentLoader,
1310
+ contextLoader: mockDocumentLoader,
1311
+ verifyPortableObject: verifier,
1312
+ crossOrigin: "throw",
1313
+ }),
1314
+ /malformed FEP-ef61 compatible identifier/,
1315
+ );
1316
+ deepStrictEqual(documentLoader.fetched, []);
1317
+ deepStrictEqual(verifier.calls, []);
1318
+ }
1319
+ });
1320
+
1321
+ test("accessors keep fetching compatible identifiers as HTTP(S) URLs without verifyPortableObject", async (t) => {
1322
+ const document = { ...note(), id: compatibleNoteId };
1323
+
1324
+ await t.step("singular accessors", async () => {
1325
+ const documentLoader = createLoader({ [compatibleNoteId]: document });
1326
+ const activity = await createHttpActivity(compatibleNoteId);
1327
+ const object = await activity.getObject({
1328
+ documentLoader,
1329
+ contextLoader: mockDocumentLoader,
1330
+ });
1331
+ assertInstanceOf(object, Note);
1332
+ deepStrictEqual(object.id, new URL(compatibleNoteId));
1333
+ // It is not cached, so the portable object policy applies later:
1334
+ deepStrictEqual(
1335
+ await activity.getObject({
1336
+ documentLoader,
1337
+ contextLoader: mockDocumentLoader,
1338
+ verifyPortableObject: createVerifier(),
1339
+ }),
1340
+ null,
1341
+ );
1342
+ deepStrictEqual(documentLoader.fetched, [
1343
+ compatibleNoteId,
1344
+ compatibleNoteId,
1345
+ ]);
1346
+ });
1347
+
1348
+ await t.step("plural accessors", async () => {
1349
+ const documentLoader = createLoader({ [compatibleNoteId]: document });
1350
+ const activity = await createHttpActivity([compatibleNoteId]);
1351
+ const objects = await Array.fromAsync(activity.getObjects({
1352
+ documentLoader,
1353
+ contextLoader: mockDocumentLoader,
1354
+ }));
1355
+ deepStrictEqual(objects.map((o) => o.id), [new URL(compatibleNoteId)]);
1356
+ deepStrictEqual(
1357
+ await Array.fromAsync(activity.getObjects({
1358
+ documentLoader,
1359
+ contextLoader: mockDocumentLoader,
1360
+ verifyPortableObject: createVerifier(),
1361
+ })),
1362
+ [],
1363
+ );
1364
+ });
1365
+
1366
+ await t.step("redirects to ordinary objects", async () => {
1367
+ const plainUrl = "https://gw.example/notes/1";
1368
+ const documentLoader = createRedirectingLoader({
1369
+ [compatibleNoteId]: {
1370
+ documentUrl: plainUrl,
1371
+ document: { ...note(), id: plainUrl },
1372
+ },
1373
+ });
1374
+ for (const activity of [await createHttpActivity(compatibleNoteId)]) {
1375
+ const object = await activity.getObject({
1376
+ documentLoader,
1377
+ contextLoader: mockDocumentLoader,
1378
+ });
1379
+ deepStrictEqual(object?.id, new URL(plainUrl));
1380
+ deepStrictEqual(
1381
+ await activity.getObject({
1382
+ documentLoader,
1383
+ contextLoader: mockDocumentLoader,
1384
+ verifyPortableObject: createVerifier(),
1385
+ }),
1386
+ null,
1387
+ );
1388
+ }
1389
+ const activity = await createHttpActivity([compatibleNoteId]);
1390
+ deepStrictEqual(
1391
+ (await Array.fromAsync(activity.getObjects({
1392
+ documentLoader,
1393
+ contextLoader: mockDocumentLoader,
1394
+ }))).length,
1395
+ 1,
1396
+ );
1397
+ deepStrictEqual(
1398
+ await Array.fromAsync(activity.getObjects({
1399
+ documentLoader,
1400
+ contextLoader: mockDocumentLoader,
1401
+ verifyPortableObject: createVerifier(),
1402
+ })),
1403
+ [],
1404
+ );
1123
1405
  });
1406
+
1407
+ await t.step("embedded objects", async () => {
1408
+ const activity = await createHttpActivity({
1409
+ type: "Note",
1410
+ id: gatewayUrl("https://example.com", "/objects/1"),
1411
+ content: "Embedded",
1412
+ });
1413
+ const object = await activity.getObject({
1414
+ documentLoader: createLoader({}),
1415
+ contextLoader: mockDocumentLoader,
1416
+ });
1417
+ deepStrictEqual(
1418
+ object?.id,
1419
+ new URL(gatewayUrl("https://example.com", "/objects/1")),
1420
+ );
1421
+ });
1422
+ });
1423
+
1424
+ test("accessors dereference compatible identifiers from portable objects", async () => {
1425
+ const compatibleOutbox = gatewayUrl("https://gw.example", "/actor/outbox");
1124
1426
  const person = (outbox: unknown) =>
1125
1427
  Person.fromJsonLd({
1126
1428
  "@context": "https://www.w3.org/ns/activitystreams",
@@ -1129,62 +1431,379 @@ test("accessors reject compatible identifiers from portable objects", async () =
1129
1431
  inbox: `ap://${did}/actor/inbox`,
1130
1432
  outbox,
1131
1433
  }, { contextLoader: mockDocumentLoader });
1132
- const redirectingLoader: DocumentLoader = (url) =>
1133
- Promise.resolve({
1134
- contextUrl: null,
1135
- documentUrl: url === "https://example.com/outbox" ? compatibleId : url,
1136
- document: collection(
1137
- url === "https://example.com/outbox" ? compatibleId : url,
1138
- ),
1139
- });
1434
+ const documentLoader = createRedirectingLoader({
1435
+ [compatibleOutbox]: {
1436
+ documentUrl: compatibleOutbox,
1437
+ document: portableCollection("/actor/outbox"),
1438
+ },
1439
+ "https://example.com/outbox": {
1440
+ documentUrl: compatibleOutbox,
1441
+ document: portableCollection("/actor/outbox"),
1442
+ },
1443
+ });
1140
1444
  for (const crossOrigin of [undefined, "trust"] as const) {
1141
- const options = {
1142
- documentLoader: redirectingLoader,
1445
+ for (
1446
+ const outbox of [
1447
+ // A compatible identifier as the reference:
1448
+ compatibleOutbox,
1449
+ // A redirect to a compatible identifier:
1450
+ "https://example.com/outbox",
1451
+ // An embedded object with a compatible identifier:
1452
+ { ...portableCollection("/actor/outbox"), id: compatibleOutbox },
1453
+ ]
1454
+ ) {
1455
+ const verifier = createRecordingVerifier();
1456
+ const collection = await (await person(outbox)).getOutbox({
1457
+ documentLoader,
1458
+ contextLoader: mockDocumentLoader,
1459
+ verifyPortableObject: verifier,
1460
+ crossOrigin,
1461
+ });
1462
+ assertInstanceOf(collection, OrderedCollection);
1463
+ deepStrictEqual(collection.id, parseIri(`ap://${did}/actor/outbox`));
1464
+ deepStrictEqual(verifier.calls.length, 1);
1465
+ deepStrictEqual(
1466
+ verifier.calls[0].options.documentUrl,
1467
+ new URL(compatibleOutbox),
1468
+ );
1469
+ deepStrictEqual(verifier.calls[0].options.gatewayHints, [
1470
+ new URL("https://gw.example"),
1471
+ ]);
1472
+ }
1473
+ }
1474
+ // Without verifyPortableObject, they are handled like portable IRIs:
1475
+ await rejects(
1476
+ async () =>
1477
+ await (await person(compatibleOutbox)).getOutbox({
1478
+ documentLoader,
1479
+ contextLoader: mockDocumentLoader,
1480
+ }),
1481
+ TypeError,
1482
+ );
1483
+ deepStrictEqual(
1484
+ await (await person(compatibleOutbox)).getOutbox({
1485
+ documentLoader,
1143
1486
  contextLoader: mockDocumentLoader,
1144
- verifyPortableObject: createRecordingVerifier(),
1145
- crossOrigin,
1146
- };
1147
- // A compatible identifier as the reference:
1487
+ suppressError: true,
1488
+ }),
1489
+ null,
1490
+ );
1491
+ });
1492
+
1493
+ test("accessors verify fetched documents that stand for portable objects", async (t) => {
1494
+ const plainUrl = "https://example.com/notes/1";
1495
+
1496
+ await t.step("a redirect to a compatible identifier", async () => {
1497
+ const documentLoader = createRedirectingLoader({
1498
+ [plainUrl]: { documentUrl: compatibleNoteId, document: note() },
1499
+ });
1500
+ const verifier = createRecordingVerifier();
1501
+ const object = await (await createHttpActivity(plainUrl)).getObject({
1502
+ documentLoader,
1503
+ contextLoader: mockDocumentLoader,
1504
+ verifyPortableObject: verifier,
1505
+ });
1506
+ assertInstanceOf(object, Note);
1507
+ deepStrictEqual(object.id, parseIri(objectId));
1508
+ // Verified in place, without another request:
1509
+ deepStrictEqual(documentLoader.fetched, [plainUrl]);
1510
+ deepStrictEqual(verifier.calls.length, 1);
1511
+ deepStrictEqual(
1512
+ verifier.calls[0].options.documentUrl,
1513
+ new URL(compatibleNoteId),
1514
+ );
1515
+ deepStrictEqual(verifier.calls[0].options.gatewayHints, [
1516
+ new URL("https://gw.example"),
1517
+ ]);
1518
+ // It is rejected if the verifier rejects it:
1148
1519
  deepStrictEqual(
1149
- await (await person(compatibleId)).getOutbox(options),
1520
+ await (await createHttpActivity(plainUrl)).getObject({
1521
+ documentLoader,
1522
+ contextLoader: mockDocumentLoader,
1523
+ verifyPortableObject: createVerifier(false),
1524
+ }),
1150
1525
  null,
1151
1526
  );
1152
- // A redirect to a compatible identifier:
1527
+ await rejects(
1528
+ async () =>
1529
+ await (await createHttpActivity(plainUrl)).getObject({
1530
+ documentLoader,
1531
+ contextLoader: mockDocumentLoader,
1532
+ verifyPortableObject: createVerifier(false),
1533
+ crossOrigin: "throw",
1534
+ }),
1535
+ /No gateway returned a valid portable object/,
1536
+ );
1537
+ });
1538
+
1539
+ await t.step("a document that claims another object", async () => {
1540
+ for (
1541
+ const id of [
1542
+ `ap://${did}/objects/2`,
1543
+ compatibleNoteId,
1544
+ "https://gw.example/notes/1",
1545
+ ]
1546
+ ) {
1547
+ const verifier = createRecordingVerifier();
1548
+ deepStrictEqual(
1549
+ await (await createHttpActivity(plainUrl)).getObject({
1550
+ documentLoader: createRedirectingLoader({
1551
+ [plainUrl]: { documentUrl: compatibleNoteId, document: note(id) },
1552
+ }),
1553
+ contextLoader: mockDocumentLoader,
1554
+ verifyPortableObject: verifier,
1555
+ }),
1556
+ null,
1557
+ );
1558
+ deepStrictEqual(verifier.calls, []);
1559
+ }
1560
+ });
1561
+
1562
+ await t.step("a portable @id", async () => {
1563
+ const verifier = createRecordingVerifier();
1564
+ const object = await (await createHttpActivity(plainUrl)).getObject({
1565
+ documentLoader: createLoader({ [plainUrl]: note() }),
1566
+ contextLoader: mockDocumentLoader,
1567
+ verifyPortableObject: verifier,
1568
+ // Does not skip the portable object policy:
1569
+ crossOrigin: "trust",
1570
+ });
1571
+ assertInstanceOf(object, Note);
1572
+ deepStrictEqual(verifier.calls.length, 1);
1573
+ deepStrictEqual(verifier.calls[0].options.documentUrl, new URL(plainUrl));
1153
1574
  deepStrictEqual(
1154
- await (await person("https://example.com/outbox")).getOutbox(options),
1575
+ await (await createHttpActivity(plainUrl)).getObject({
1576
+ documentLoader: createLoader({ [plainUrl]: note() }),
1577
+ contextLoader: mockDocumentLoader,
1578
+ verifyPortableObject: createVerifier(false),
1579
+ crossOrigin: "trust",
1580
+ }),
1155
1581
  null,
1156
1582
  );
1157
- // An embedded object with a compatible identifier:
1583
+ });
1584
+
1585
+ await t.step("a compatible @id", async () => {
1586
+ const verifier = createRecordingVerifier();
1587
+ const url = "https://gw.example/notes/1";
1158
1588
  deepStrictEqual(
1159
- await (await person(collection(compatibleId))).getOutbox(options),
1589
+ await (await createHttpActivity(url)).getObject({
1590
+ documentLoader: createLoader({ [url]: note(compatibleNoteId) }),
1591
+ contextLoader: mockDocumentLoader,
1592
+ verifyPortableObject: verifier,
1593
+ }),
1160
1594
  null,
1161
1595
  );
1162
- }
1163
- // A throwing crossOrigin option throws:
1164
- await rejects(
1165
- async () =>
1166
- await (await person(compatibleId)).getOutbox({
1167
- documentLoader: redirectingLoader,
1596
+ deepStrictEqual(verifier.calls, []);
1597
+ });
1598
+
1599
+ await t.step("a portable final URL", async () => {
1600
+ for (
1601
+ const [id, expected] of [
1602
+ [objectId, true],
1603
+ [`ap://${did}/objects/2`, false],
1604
+ ] as const
1605
+ ) {
1606
+ const object = await (await createHttpActivity(plainUrl)).getObject({
1607
+ documentLoader: createRedirectingLoader({
1608
+ [plainUrl]: {
1609
+ documentUrl: `ap://${did}/objects/1`,
1610
+ document: note(id),
1611
+ },
1612
+ }),
1168
1613
  contextLoader: mockDocumentLoader,
1169
- crossOrigin: "throw",
1614
+ verifyPortableObject: createVerifier(),
1615
+ });
1616
+ deepStrictEqual(object instanceof Note, expected);
1617
+ }
1618
+ // A final URL that URL parsing would turn into another ID is refused:
1619
+ deepStrictEqual(
1620
+ await (await createHttpActivity(plainUrl)).getObject({
1621
+ documentLoader: createRedirectingLoader({
1622
+ [plainUrl]: {
1623
+ documentUrl: `ap://${did}/objects/x/../1`,
1624
+ document: note(),
1625
+ },
1626
+ }),
1627
+ contextLoader: mockDocumentLoader,
1628
+ verifyPortableObject: createVerifier(),
1629
+ }),
1630
+ null,
1631
+ );
1632
+ });
1633
+
1634
+ await t.step("a malformed compatible final URL", async () => {
1635
+ const malformed = `${compatibleNoteId}?@gateway=https%3A%2F%2Fgw2.example`;
1636
+ const documentLoader = createRedirectingLoader({
1637
+ [plainUrl]: { documentUrl: malformed, document: note() },
1638
+ });
1639
+ deepStrictEqual(
1640
+ await (await createHttpActivity(plainUrl)).getObject({
1641
+ documentLoader,
1642
+ contextLoader: mockDocumentLoader,
1643
+ verifyPortableObject: createVerifier(),
1644
+ }),
1645
+ null,
1646
+ );
1647
+ await rejects(
1648
+ async () =>
1649
+ await (await createHttpActivity(plainUrl)).getObject({
1650
+ documentLoader,
1651
+ contextLoader: mockDocumentLoader,
1652
+ verifyPortableObject: createVerifier(),
1653
+ crossOrigin: "throw",
1654
+ }),
1655
+ /malformed FEP-ef61 compatible identifier/,
1656
+ );
1657
+ });
1658
+
1659
+ await t.step("context documents are shared", async () => {
1660
+ const contextUrl = "https://example.com/portable-context";
1661
+ let version = 0;
1662
+ const contextLoader: DocumentLoader = async (url) => {
1663
+ if (url === contextUrl) {
1664
+ version++;
1665
+ return {
1666
+ contextUrl: null,
1667
+ documentUrl: url,
1668
+ document: { "@context": { version: `urn:version:${version}` } },
1669
+ };
1670
+ }
1671
+ return await mockDocumentLoader(url);
1672
+ };
1673
+ const contexts: unknown[] = [];
1674
+ const object = await (await createHttpActivity(plainUrl)).getObject({
1675
+ documentLoader: createRedirectingLoader({
1676
+ [plainUrl]: {
1677
+ documentUrl: compatibleNoteId,
1678
+ document: {
1679
+ ...note(),
1680
+ "@context": ["https://www.w3.org/ns/activitystreams", contextUrl],
1681
+ },
1682
+ },
1683
+ }),
1684
+ contextLoader,
1685
+ verifyPortableObject: async (_, { contextLoader }) => {
1686
+ contexts.push((await contextLoader!(contextUrl)).document);
1687
+ return { verified: true };
1688
+ },
1689
+ });
1690
+ assertInstanceOf(object, Note);
1691
+ deepStrictEqual(version, 1);
1692
+ deepStrictEqual(contexts, [{ "@context": { version: "urn:version:1" } }]);
1693
+ });
1694
+
1695
+ await t.step("without verifyPortableObject", async () => {
1696
+ // Fetched as before, i.e., refused as a cross-origin object:
1697
+ deepStrictEqual(
1698
+ await (await createHttpActivity(plainUrl)).getObject({
1699
+ documentLoader: createLoader({ [plainUrl]: note() }),
1700
+ contextLoader: mockDocumentLoader,
1701
+ }),
1702
+ null,
1703
+ );
1704
+ // A redirect to a compatible identifier of the same origin:
1705
+ const object = await (await createHttpActivity(plainUrl)).getObject({
1706
+ documentLoader: createRedirectingLoader({
1707
+ [plainUrl]: {
1708
+ documentUrl: gatewayUrl("https://example.com", "/objects/1"),
1709
+ document: note(gatewayUrl("https://example.com", "/objects/1")),
1710
+ },
1170
1711
  }),
1171
- /portable object/,
1172
- );
1173
- // Compatible identifiers from ordinary HTTP(S) objects are not affected:
1174
- const httpPerson = await Person.fromJsonLd({
1175
- "@context": "https://www.w3.org/ns/activitystreams",
1176
- id: "https://evil.example/actor",
1177
- type: "Person",
1178
- inbox: "https://evil.example/actor/inbox",
1179
- outbox: compatibleId,
1180
- }, { contextLoader: mockDocumentLoader });
1181
- assertInstanceOf(
1182
- await httpPerson.getOutbox({
1183
- documentLoader: redirectingLoader,
1184
1712
  contextLoader: mockDocumentLoader,
1185
- }),
1186
- OrderedCollection,
1187
- );
1713
+ });
1714
+ assertInstanceOf(object, Note);
1715
+ });
1716
+ });
1717
+
1718
+ test("accessors verify embedded objects that stand for portable objects", async () => {
1719
+ const forgedId = gatewayUrl("https://example.com", "/objects/1");
1720
+ const forged = { type: "Note", id: forgedId, content: "Forged" };
1721
+ for (const crossOrigin of [undefined, "trust"] as const) {
1722
+ // A compatible identifier of the parent's origin:
1723
+ const documentLoader = createLoader({ [forgedId]: note() });
1724
+ const verifier = createRecordingVerifier(() => ({ verified: false }));
1725
+ deepStrictEqual(
1726
+ await (await createHttpActivity(forged)).getObject({
1727
+ documentLoader,
1728
+ contextLoader: mockDocumentLoader,
1729
+ verifyPortableObject: verifier,
1730
+ crossOrigin,
1731
+ }),
1732
+ null,
1733
+ );
1734
+ deepStrictEqual(documentLoader.fetched, [forgedId]);
1735
+ deepStrictEqual(verifier.calls.length, 1);
1736
+ deepStrictEqual(
1737
+ await Array.fromAsync(
1738
+ (await createHttpActivity([forged])).getObjects({
1739
+ documentLoader,
1740
+ contextLoader: mockDocumentLoader,
1741
+ verifyPortableObject: verifier,
1742
+ crossOrigin,
1743
+ }),
1744
+ ),
1745
+ [],
1746
+ );
1747
+
1748
+ // A portable IRI embedded in a non-portable object:
1749
+ const documentLoader2 = createLoader({
1750
+ [gatewayUrl("https://gw.example", "/objects/1")]: note(),
1751
+ });
1752
+ const verifier2 = createRecordingVerifier();
1753
+ const object = await (await createHttpActivity({
1754
+ type: "Note",
1755
+ id: objectId,
1756
+ content: "Forged",
1757
+ })).getObject({
1758
+ documentLoader: documentLoader2,
1759
+ contextLoader: mockDocumentLoader,
1760
+ gateways: ["https://gw.example"],
1761
+ verifyPortableObject: verifier2,
1762
+ crossOrigin,
1763
+ });
1764
+ assertInstanceOf(object, Note);
1765
+ deepStrictEqual(object.content, "Portable note");
1766
+ deepStrictEqual(verifier2.calls.length, 1);
1767
+ }
1768
+
1769
+ // A portable object with another DID embedded in a portable object:
1770
+ const otherDid = "did:key:z6Mkother";
1771
+ const otherId = `ap://${otherDid}/objects/1`;
1772
+ const documentLoader = createLoader({
1773
+ [`https://gw.example/.well-known/apgateway/${otherDid}/objects/1`]: note(
1774
+ otherId,
1775
+ ),
1776
+ });
1777
+ const verifier = createRecordingVerifier();
1778
+ const crossDid = await (await createActivity({
1779
+ type: "Note",
1780
+ id: otherId,
1781
+ content: "Forged",
1782
+ } as unknown as string)).getObject({
1783
+ documentLoader,
1784
+ contextLoader: mockDocumentLoader,
1785
+ gateways: ["https://gw.example"],
1786
+ verifyPortableObject: verifier,
1787
+ crossOrigin: "trust",
1788
+ });
1789
+ assertInstanceOf(crossDid, Note);
1790
+ deepStrictEqual(crossDid.content, "Portable note");
1791
+ deepStrictEqual(verifier.calls.length, 1);
1792
+
1793
+ // A portable object embedded in a portable object with the same DID is
1794
+ // still trusted as embedded:
1795
+ const activity = await createActivity({
1796
+ type: "Note",
1797
+ id: objectId,
1798
+ content: "Embedded",
1799
+ } as unknown as string);
1800
+ const object = await activity.getObject({
1801
+ documentLoader: createLoader({}),
1802
+ contextLoader: mockDocumentLoader,
1803
+ verifyPortableObject: createVerifier(false),
1804
+ });
1805
+ assertInstanceOf(object, Note);
1806
+ deepStrictEqual(object.content, "Embedded");
1188
1807
  });
1189
1808
 
1190
1809
  test("singular accessors drop anonymous objects embedded in unsecured collections", async () => {