@fedify/vocab 2.3.0-dev.1119 → 2.3.0-dev.1137

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fedify/vocab",
3
- "version": "2.3.0-dev.1119+6cc02662",
3
+ "version": "2.3.0-dev.1137+53a1f26d",
4
4
  "license": "MIT",
5
5
  "exports": {
6
6
  ".": "./src/mod.ts"
package/dist/mod.cjs CHANGED
@@ -33,7 +33,7 @@ let _fedify_vocab_runtime_temporal = require("@fedify/vocab-runtime/temporal");
33
33
  let es_toolkit = require("es-toolkit");
34
34
  //#region deno.json
35
35
  var name = "@fedify/vocab";
36
- var version = "2.3.0-dev.1119+6cc02662";
36
+ var version = "2.3.0-dev.1137+53a1f26d";
37
37
  //#endregion
38
38
  //#region src/type.ts
39
39
  function getTypeId(object) {
@@ -47552,6 +47552,40 @@ function toAcctUrl(handle) {
47552
47552
  }
47553
47553
  //#endregion
47554
47554
  //#region src/lookup.ts
47555
+ const objectLookupCounters = /* @__PURE__ */ new WeakMap();
47556
+ function getObjectLookupCounter(meterProvider) {
47557
+ let counter = objectLookupCounters.get(meterProvider);
47558
+ if (counter == null) {
47559
+ counter = meterProvider.getMeter(name, version).createCounter("activitypub.object.lookup", {
47560
+ description: "ActivityStreams object lookups via lookupObject(), classified by whether the resolved value is an Actor.",
47561
+ unit: "{lookup}"
47562
+ });
47563
+ objectLookupCounters.set(meterProvider, counter);
47564
+ }
47565
+ return counter;
47566
+ }
47567
+ function getLookupRemoteHost(identifier) {
47568
+ let url;
47569
+ if (identifier instanceof URL) url = identifier;
47570
+ else try {
47571
+ url = new URL(identifier);
47572
+ } catch {
47573
+ return extractHandleHost(identifier.startsWith("@") ? identifier.slice(1) : identifier);
47574
+ }
47575
+ if (url.hostname !== "") return url.hostname;
47576
+ if (url.protocol === "acct:") return extractHandleHost(url.pathname);
47577
+ }
47578
+ function extractHandleHost(handle) {
47579
+ const at = handle.lastIndexOf("@");
47580
+ if (at < 0 || at >= handle.length - 1) return void 0;
47581
+ const candidate = handle.slice(at + 1);
47582
+ if (/[/?#\s]/.test(candidate)) return void 0;
47583
+ try {
47584
+ return new URL(`https://${candidate}`).hostname || void 0;
47585
+ } catch {
47586
+ return;
47587
+ }
47588
+ }
47555
47589
  const logger = (0, _logtape_logtape.getLogger)([
47556
47590
  "fedify",
47557
47591
  "vocab",
@@ -47591,8 +47625,10 @@ const logger = (0, _logtape_logtape.getLogger)([
47591
47625
  */
47592
47626
  async function lookupObject(identifier, options = {}) {
47593
47627
  return await (options.tracerProvider ?? _opentelemetry_api.trace.getTracerProvider()).getTracer(name, version).startActiveSpan("activitypub.lookup_object", async (span) => {
47628
+ let kind = "other";
47594
47629
  try {
47595
47630
  const result = await lookupObjectInternal(identifier, options);
47631
+ if (result != null) kind = isActor(result) ? "actor" : "object";
47596
47632
  if (result == null) span.setStatus({ code: _opentelemetry_api.SpanStatusCode.ERROR });
47597
47633
  else {
47598
47634
  if (result.id != null) span.setAttribute("activitypub.object.id", result.id.href);
@@ -47612,6 +47648,12 @@ async function lookupObject(identifier, options = {}) {
47612
47648
  });
47613
47649
  throw error;
47614
47650
  } finally {
47651
+ if (options.meterProvider != null) {
47652
+ const attributes = { "activitypub.lookup.kind": kind };
47653
+ const host = getLookupRemoteHost(identifier);
47654
+ if (host != null) attributes["activitypub.remote.host"] = host;
47655
+ getObjectLookupCounter(options.meterProvider).add(1, attributes);
47656
+ }
47615
47657
  span.end();
47616
47658
  }
47617
47659
  });
package/dist/mod.d.cts CHANGED
@@ -1,6 +1,6 @@
1
1
  /// <reference lib="esnext.temporal" />
2
2
  import { Decimal, DocumentLoader, DocumentLoader as DocumentLoader$1, GetUserAgentOptions, GetUserAgentOptions as GetUserAgentOptions$1, LanguageString, LanguageString as LanguageString$1, RemoteDocument } from "@fedify/vocab-runtime";
3
- import { Span, TracerProvider } from "@opentelemetry/api";
3
+ import { MeterProvider, Span, TracerProvider } from "@opentelemetry/api";
4
4
 
5
5
  //#region src/vocab.d.ts
6
6
  /** Describes an object of any kind. The Object type serves as the base type for
@@ -18296,6 +18296,18 @@ declare function toAcctUrl(handle: string): URL | null;
18296
18296
  //#endregion
18297
18297
  //#region src/lookup.d.ts
18298
18298
  /**
18299
+ * The classification of an ActivityStreams lookup performed by
18300
+ * {@link lookupObject}, recorded as `activitypub.lookup.kind` on the
18301
+ * `activitypub.object.lookup` counter.
18302
+ *
18303
+ * - `actor`: the resolved object is an {@link import("./actor.ts").Actor}.
18304
+ * - `object`: the resolved object is a non-actor ActivityStreams object.
18305
+ * - `other`: the lookup did not resolve to an object (not found, network
18306
+ * failure, parse failure).
18307
+ * @since 2.3.0
18308
+ */
18309
+ type ObjectLookupKind = "actor" | "object" | "other";
18310
+ /**
18299
18311
  * Options for the {@link lookupObject} function.
18300
18312
  *
18301
18313
  * @since 0.2.0
@@ -18343,6 +18355,14 @@ interface LookupObjectOptions {
18343
18355
  */
18344
18356
  tracerProvider?: TracerProvider;
18345
18357
  /**
18358
+ * The OpenTelemetry meter provider used to record the
18359
+ * `activitypub.object.lookup` counter. If omitted, the counter is not
18360
+ * emitted at all (the helper is opt-in to avoid touching the global
18361
+ * meter provider for callers that do not use OpenTelemetry).
18362
+ * @since 2.3.0
18363
+ */
18364
+ meterProvider?: MeterProvider;
18365
+ /**
18346
18366
  * AbortSignal for cancelling the request.
18347
18367
  * @since 1.8.0
18348
18368
  */
@@ -18511,4 +18531,4 @@ declare function getTypeId(object: Object$1 | Link | null): URL | null;
18511
18531
  */
18512
18532
  declare function getTypeId(object: Object$1 | Link | null | undefined): URL | null | undefined;
18513
18533
  //#endregion
18514
- export { $EntityType, Accept, Activity, Actor, ActorTypeName, Add, Announce, AnnounceAuthorization, AnnounceRequest, Application, Arrive, Article, Audio, Block, ChatMessage, Collection, CollectionPage, Create, CryptographicKey, DataIntegrityProof, Delete, DidService, Dislike, Document, type DocumentLoader, Emoji, EmojiReact, Endpoints, Event, Export, FediverseHandle, Flag, Follow, GetActorHandleOptions, type GetUserAgentOptions, Group, Hashtag, Ignore, Image, Intent, InteractionPolicy, InteractionRule, IntransitiveActivity, Invite, Join, LanguageString, Leave, Like, LikeAuthorization, LikeRequest, Link, Listen, LookupObjectOptions, Measure, Mention, Move, Multikey, NormalizeActorHandleOptions, Note, Object$1 as Object, Offer, OrderedCollection, OrderedCollectionPage, Organization, PUBLIC_COLLECTION, Page, Person, Place, Profile, PropertyValue, Proposal, Question, QuoteAuthorization, QuoteRequest, Read, Recipient, Reject, Relationship, type RemoteDocument, Remove, ReplyAuthorization, ReplyRequest, Service, Source, TentativeAccept, TentativeReject, Tombstone, Travel, TraverseCollectionOptions, Undo, Update, Video, View, getActorClassByTypeName, getActorHandle, getActorTypeName, getEntityTypeById, getTypeId, isActor, isEntityType, isFediverseHandle, lookupObject, normalizeActorHandle, parseFediverseHandle, toAcctUrl, traverseCollection };
18534
+ export { $EntityType, Accept, Activity, Actor, ActorTypeName, Add, Announce, AnnounceAuthorization, AnnounceRequest, Application, Arrive, Article, Audio, Block, ChatMessage, Collection, CollectionPage, Create, CryptographicKey, DataIntegrityProof, Delete, DidService, Dislike, Document, type DocumentLoader, Emoji, EmojiReact, Endpoints, Event, Export, FediverseHandle, Flag, Follow, GetActorHandleOptions, type GetUserAgentOptions, Group, Hashtag, Ignore, Image, Intent, InteractionPolicy, InteractionRule, IntransitiveActivity, Invite, Join, LanguageString, Leave, Like, LikeAuthorization, LikeRequest, Link, Listen, LookupObjectOptions, Measure, Mention, Move, Multikey, NormalizeActorHandleOptions, Note, Object$1 as Object, ObjectLookupKind, Offer, OrderedCollection, OrderedCollectionPage, Organization, PUBLIC_COLLECTION, Page, Person, Place, Profile, PropertyValue, Proposal, Question, QuoteAuthorization, QuoteRequest, Read, Recipient, Reject, Relationship, type RemoteDocument, Remove, ReplyAuthorization, ReplyRequest, Service, Source, TentativeAccept, TentativeReject, Tombstone, Travel, TraverseCollectionOptions, Undo, Update, Video, View, getActorClassByTypeName, getActorHandle, getActorTypeName, getEntityTypeById, getTypeId, isActor, isEntityType, isFediverseHandle, lookupObject, normalizeActorHandle, parseFediverseHandle, toAcctUrl, traverseCollection };
package/dist/mod.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /// <reference lib="esnext.temporal" />
2
- import { Span, TracerProvider } from "@opentelemetry/api";
2
+ import { MeterProvider, Span, TracerProvider } from "@opentelemetry/api";
3
3
  import { Decimal, DocumentLoader, DocumentLoader as DocumentLoader$1, GetUserAgentOptions, GetUserAgentOptions as GetUserAgentOptions$1, LanguageString, LanguageString as LanguageString$1, RemoteDocument } from "@fedify/vocab-runtime";
4
4
 
5
5
  //#region src/vocab.d.ts
@@ -18296,6 +18296,18 @@ declare function toAcctUrl(handle: string): URL | null;
18296
18296
  //#endregion
18297
18297
  //#region src/lookup.d.ts
18298
18298
  /**
18299
+ * The classification of an ActivityStreams lookup performed by
18300
+ * {@link lookupObject}, recorded as `activitypub.lookup.kind` on the
18301
+ * `activitypub.object.lookup` counter.
18302
+ *
18303
+ * - `actor`: the resolved object is an {@link import("./actor.ts").Actor}.
18304
+ * - `object`: the resolved object is a non-actor ActivityStreams object.
18305
+ * - `other`: the lookup did not resolve to an object (not found, network
18306
+ * failure, parse failure).
18307
+ * @since 2.3.0
18308
+ */
18309
+ type ObjectLookupKind = "actor" | "object" | "other";
18310
+ /**
18299
18311
  * Options for the {@link lookupObject} function.
18300
18312
  *
18301
18313
  * @since 0.2.0
@@ -18343,6 +18355,14 @@ interface LookupObjectOptions {
18343
18355
  */
18344
18356
  tracerProvider?: TracerProvider;
18345
18357
  /**
18358
+ * The OpenTelemetry meter provider used to record the
18359
+ * `activitypub.object.lookup` counter. If omitted, the counter is not
18360
+ * emitted at all (the helper is opt-in to avoid touching the global
18361
+ * meter provider for callers that do not use OpenTelemetry).
18362
+ * @since 2.3.0
18363
+ */
18364
+ meterProvider?: MeterProvider;
18365
+ /**
18346
18366
  * AbortSignal for cancelling the request.
18347
18367
  * @since 1.8.0
18348
18368
  */
@@ -18511,4 +18531,4 @@ declare function getTypeId(object: Object$1 | Link | null): URL | null;
18511
18531
  */
18512
18532
  declare function getTypeId(object: Object$1 | Link | null | undefined): URL | null | undefined;
18513
18533
  //#endregion
18514
- export { $EntityType, Accept, Activity, Actor, ActorTypeName, Add, Announce, AnnounceAuthorization, AnnounceRequest, Application, Arrive, Article, Audio, Block, ChatMessage, Collection, CollectionPage, Create, CryptographicKey, DataIntegrityProof, Delete, DidService, Dislike, Document, type DocumentLoader, Emoji, EmojiReact, Endpoints, Event, Export, FediverseHandle, Flag, Follow, GetActorHandleOptions, type GetUserAgentOptions, Group, Hashtag, Ignore, Image, Intent, InteractionPolicy, InteractionRule, IntransitiveActivity, Invite, Join, LanguageString, Leave, Like, LikeAuthorization, LikeRequest, Link, Listen, LookupObjectOptions, Measure, Mention, Move, Multikey, NormalizeActorHandleOptions, Note, Object$1 as Object, Offer, OrderedCollection, OrderedCollectionPage, Organization, PUBLIC_COLLECTION, Page, Person, Place, Profile, PropertyValue, Proposal, Question, QuoteAuthorization, QuoteRequest, Read, Recipient, Reject, Relationship, type RemoteDocument, Remove, ReplyAuthorization, ReplyRequest, Service, Source, TentativeAccept, TentativeReject, Tombstone, Travel, TraverseCollectionOptions, Undo, Update, Video, View, getActorClassByTypeName, getActorHandle, getActorTypeName, getEntityTypeById, getTypeId, isActor, isEntityType, isFediverseHandle, lookupObject, normalizeActorHandle, parseFediverseHandle, toAcctUrl, traverseCollection };
18534
+ export { $EntityType, Accept, Activity, Actor, ActorTypeName, Add, Announce, AnnounceAuthorization, AnnounceRequest, Application, Arrive, Article, Audio, Block, ChatMessage, Collection, CollectionPage, Create, CryptographicKey, DataIntegrityProof, Delete, DidService, Dislike, Document, type DocumentLoader, Emoji, EmojiReact, Endpoints, Event, Export, FediverseHandle, Flag, Follow, GetActorHandleOptions, type GetUserAgentOptions, Group, Hashtag, Ignore, Image, Intent, InteractionPolicy, InteractionRule, IntransitiveActivity, Invite, Join, LanguageString, Leave, Like, LikeAuthorization, LikeRequest, Link, Listen, LookupObjectOptions, Measure, Mention, Move, Multikey, NormalizeActorHandleOptions, Note, Object$1 as Object, ObjectLookupKind, Offer, OrderedCollection, OrderedCollectionPage, Organization, PUBLIC_COLLECTION, Page, Person, Place, Profile, PropertyValue, Proposal, Question, QuoteAuthorization, QuoteRequest, Read, Recipient, Reject, Relationship, type RemoteDocument, Remove, ReplyAuthorization, ReplyRequest, Service, Source, TentativeAccept, TentativeReject, Tombstone, Travel, TraverseCollectionOptions, Undo, Update, Video, View, getActorClassByTypeName, getActorHandle, getActorTypeName, getEntityTypeById, getTypeId, isActor, isEntityType, isFediverseHandle, lookupObject, normalizeActorHandle, parseFediverseHandle, toAcctUrl, traverseCollection };
package/dist/mod.js CHANGED
@@ -9,7 +9,7 @@ import { isTemporalDuration, isTemporalInstant } from "@fedify/vocab-runtime/tem
9
9
  import { delay } from "es-toolkit";
10
10
  //#region deno.json
11
11
  var name = "@fedify/vocab";
12
- var version = "2.3.0-dev.1119+6cc02662";
12
+ var version = "2.3.0-dev.1137+53a1f26d";
13
13
  //#endregion
14
14
  //#region src/type.ts
15
15
  function getTypeId(object) {
@@ -47528,6 +47528,40 @@ function toAcctUrl(handle) {
47528
47528
  }
47529
47529
  //#endregion
47530
47530
  //#region src/lookup.ts
47531
+ const objectLookupCounters = /* @__PURE__ */ new WeakMap();
47532
+ function getObjectLookupCounter(meterProvider) {
47533
+ let counter = objectLookupCounters.get(meterProvider);
47534
+ if (counter == null) {
47535
+ counter = meterProvider.getMeter(name, version).createCounter("activitypub.object.lookup", {
47536
+ description: "ActivityStreams object lookups via lookupObject(), classified by whether the resolved value is an Actor.",
47537
+ unit: "{lookup}"
47538
+ });
47539
+ objectLookupCounters.set(meterProvider, counter);
47540
+ }
47541
+ return counter;
47542
+ }
47543
+ function getLookupRemoteHost(identifier) {
47544
+ let url;
47545
+ if (identifier instanceof URL) url = identifier;
47546
+ else try {
47547
+ url = new URL(identifier);
47548
+ } catch {
47549
+ return extractHandleHost(identifier.startsWith("@") ? identifier.slice(1) : identifier);
47550
+ }
47551
+ if (url.hostname !== "") return url.hostname;
47552
+ if (url.protocol === "acct:") return extractHandleHost(url.pathname);
47553
+ }
47554
+ function extractHandleHost(handle) {
47555
+ const at = handle.lastIndexOf("@");
47556
+ if (at < 0 || at >= handle.length - 1) return void 0;
47557
+ const candidate = handle.slice(at + 1);
47558
+ if (/[/?#\s]/.test(candidate)) return void 0;
47559
+ try {
47560
+ return new URL(`https://${candidate}`).hostname || void 0;
47561
+ } catch {
47562
+ return;
47563
+ }
47564
+ }
47531
47565
  const logger = getLogger([
47532
47566
  "fedify",
47533
47567
  "vocab",
@@ -47567,8 +47601,10 @@ const logger = getLogger([
47567
47601
  */
47568
47602
  async function lookupObject(identifier, options = {}) {
47569
47603
  return await (options.tracerProvider ?? trace.getTracerProvider()).getTracer(name, version).startActiveSpan("activitypub.lookup_object", async (span) => {
47604
+ let kind = "other";
47570
47605
  try {
47571
47606
  const result = await lookupObjectInternal(identifier, options);
47607
+ if (result != null) kind = isActor(result) ? "actor" : "object";
47572
47608
  if (result == null) span.setStatus({ code: SpanStatusCode.ERROR });
47573
47609
  else {
47574
47610
  if (result.id != null) span.setAttribute("activitypub.object.id", result.id.href);
@@ -47588,6 +47624,12 @@ async function lookupObject(identifier, options = {}) {
47588
47624
  });
47589
47625
  throw error;
47590
47626
  } finally {
47627
+ if (options.meterProvider != null) {
47628
+ const attributes = { "activitypub.lookup.kind": kind };
47629
+ const host = getLookupRemoteHost(identifier);
47630
+ if (host != null) attributes["activitypub.remote.host"] = host;
47631
+ getObjectLookupCounter(options.meterProvider).add(1, attributes);
47632
+ }
47591
47633
  span.end();
47592
47634
  }
47593
47635
  });
@@ -1,6 +1,10 @@
1
1
  import "@js-temporal/polyfill";
2
2
  globalThis.addEventListener = () => {};
3
- import { A as __commonJSMin, M as __toESM } from "./vocab-B0Z-tH4q.mjs";
3
+ import { A as __commonJSMin, M as __toESM, r as Application, u as Group, v as Organization, w as Service, y as Person } from "./vocab-B0Z-tH4q.mjs";
4
+ import { t as getTypeId } from "./type-Cf-vxmre.mjs";
5
+ import { lookupWebFinger } from "@fedify/webfinger";
6
+ import { SpanStatusCode, trace } from "@opentelemetry/api";
7
+ import { domainToASCII, domainToUnicode } from "node:url";
4
8
  //#endregion
5
9
  //#region ../../node_modules/.pnpm/regexparam@3.0.0/node_modules/regexparam/dist/index.mjs
6
10
  var import_glob_to_regexp = /* @__PURE__ */ __toESM((/* @__PURE__ */ __commonJSMin(((exports, module) => {
@@ -1202,6 +1206,138 @@ var esm_default = new class FetchMock {
1202
1206
  //#endregion
1203
1207
  //#region deno.json
1204
1208
  var name = "@fedify/vocab";
1205
- var version = "2.3.0-dev.1119+6cc02662";
1209
+ var version = "2.3.0-dev.1137+53a1f26d";
1206
1210
  //#endregion
1207
- export { version as n, esm_default as r, name as t };
1211
+ //#region src/actor.ts
1212
+ /**
1213
+ * Checks if the given object is an {@link Actor}.
1214
+ * @param object The object to check.
1215
+ * @returns `true` if the given object is an {@link Actor}.
1216
+ */
1217
+ function isActor(object) {
1218
+ return object instanceof Application || object instanceof Group || object instanceof Organization || object instanceof Person || object instanceof Service;
1219
+ }
1220
+ /**
1221
+ * Gets the type name of the given actor.
1222
+ * @param actor The actor to get the type name of.
1223
+ * @returns The type name of the given actor.
1224
+ */
1225
+ function getActorTypeName(actor) {
1226
+ if (actor instanceof Application) return "Application";
1227
+ else if (actor instanceof Group) return "Group";
1228
+ else if (actor instanceof Organization) return "Organization";
1229
+ else if (actor instanceof Person) return "Person";
1230
+ else if (actor instanceof Service) return "Service";
1231
+ throw new Error("Unknown actor type.");
1232
+ }
1233
+ /**
1234
+ * Gets the actor class by the given type name.
1235
+ * @param typeName The type name to get the actor class by.
1236
+ * @returns The actor class by the given type name.
1237
+ */
1238
+ function getActorClassByTypeName(typeName) {
1239
+ switch (typeName) {
1240
+ case "Application": return Application;
1241
+ case "Group": return Group;
1242
+ case "Organization": return Organization;
1243
+ case "Person": return Person;
1244
+ case "Service": return Service;
1245
+ }
1246
+ throw new Error("Unknown actor type name.");
1247
+ }
1248
+ /**
1249
+ * Gets the actor handle, of the form `@username@domain`, from the given actor
1250
+ * or an actor URI.
1251
+ *
1252
+ * @example
1253
+ * ``` typescript
1254
+ * // Get the handle of an actor object:
1255
+ * await getActorHandle(
1256
+ * new Person({ id: new URL("https://fosstodon.org/users/hongminhee") })
1257
+ * );
1258
+ *
1259
+ * // Get the handle of an actor URI:
1260
+ * await getActorHandle(new URL("https://fosstodon.org/users/hongminhee"));
1261
+ * ```
1262
+ *
1263
+ * @param actor The actor or actor URI to get the handle from.
1264
+ * @param options The extra options for getting the actor handle.
1265
+ * @returns The actor handle. It starts with `@` and is followed by the
1266
+ * username and domain, separated by `@` by default (it can be
1267
+ * customized with the options).
1268
+ * @throws {TypeError} If the actor does not have enough information to get the
1269
+ * handle.
1270
+ * @since 0.4.0
1271
+ */
1272
+ async function getActorHandle(actor, options = {}) {
1273
+ return await (options.tracerProvider ?? trace.getTracerProvider()).getTracer(name, version).startActiveSpan("activitypub.get_actor_handle", async (span) => {
1274
+ if (isActor(actor)) {
1275
+ if (actor.id != null) span.setAttribute("activitypub.actor.id", actor.id.href);
1276
+ span.setAttribute("activitypub.actor.type", getTypeId(actor).href);
1277
+ }
1278
+ try {
1279
+ return await getActorHandleInternal(actor, options);
1280
+ } catch (error) {
1281
+ span.setStatus({
1282
+ code: SpanStatusCode.ERROR,
1283
+ message: String(error)
1284
+ });
1285
+ throw error;
1286
+ } finally {
1287
+ span.end();
1288
+ }
1289
+ });
1290
+ }
1291
+ async function getActorHandleInternal(actor, options = {}) {
1292
+ const actorId = actor instanceof URL ? actor : actor.id;
1293
+ if (actorId != null) {
1294
+ const result = await lookupWebFinger(actorId, {
1295
+ userAgent: options.userAgent,
1296
+ tracerProvider: options.tracerProvider
1297
+ });
1298
+ if (result != null) {
1299
+ const aliases = [...result.aliases ?? []];
1300
+ if (result.subject != null) aliases.unshift(result.subject);
1301
+ for (const alias of aliases) {
1302
+ const match = alias.match(/^acct:([^@]+)@([^@]+)$/);
1303
+ if (match != null) {
1304
+ if (new URL(`https://${match[2]}/`).hostname !== actorId.hostname && !await verifyCrossOriginActorHandle(actorId.href, alias, options.userAgent, options.tracerProvider)) continue;
1305
+ return normalizeActorHandle(`@${match[1]}@${match[2]}`, options);
1306
+ }
1307
+ }
1308
+ }
1309
+ }
1310
+ if (!(actor instanceof URL) && actor.preferredUsername != null && actor.id != null) return normalizeActorHandle(`@${actor.preferredUsername}@${actor.id.host}`, options);
1311
+ throw new TypeError("Actor does not have enough information to get the handle.");
1312
+ }
1313
+ async function verifyCrossOriginActorHandle(actorId, alias, userAgent, tracerProvider) {
1314
+ const response = await lookupWebFinger(alias, {
1315
+ userAgent,
1316
+ tracerProvider
1317
+ });
1318
+ if (response == null) return false;
1319
+ for (const alias of response.aliases ?? []) if (new URL(alias).href === actorId) return true;
1320
+ return false;
1321
+ }
1322
+ /**
1323
+ * Normalizes the given actor handle.
1324
+ * @param handle The full handle of the actor to normalize.
1325
+ * @param options The options for normalizing the actor handle.
1326
+ * @returns The normalized actor handle.
1327
+ * @throws {TypeError} If the actor handle is invalid.
1328
+ * @since 0.9.0
1329
+ */
1330
+ function normalizeActorHandle(handle, options = {}) {
1331
+ handle = handle.replace(/^@/, "");
1332
+ const atPos = handle.indexOf("@");
1333
+ if (atPos < 1) throw new TypeError("Invalid actor handle.");
1334
+ let domain = handle.substring(atPos + 1);
1335
+ if (domain.length < 1 || domain.includes("@")) throw new TypeError("Invalid actor handle.");
1336
+ domain = domain.toLowerCase();
1337
+ domain = options.punycode ? domainToASCII(domain) : domainToUnicode(domain);
1338
+ domain = domain.toLowerCase();
1339
+ const user = handle.substring(0, atPos);
1340
+ return options.trimLeadingAt ? `${user}@${domain}` : `@${user}@${domain}`;
1341
+ }
1342
+ //#endregion
1343
+ export { normalizeActorHandle as a, esm_default as c, isActor as i, getActorHandle as n, name as o, getActorTypeName as r, version as s, getActorClassByTypeName as t };
@@ -1,13 +1,9 @@
1
1
  import "@js-temporal/polyfill";
2
2
  globalThis.addEventListener = () => {};
3
3
  import { j as __exportAll, r as Application, u as Group, v as Organization, w as Service, y as Person } from "./vocab-B0Z-tH4q.mjs";
4
- import { n as version, r as esm_default, t as name } from "./deno-C05d783V.mjs";
5
- import { t as getTypeId } from "./type-Cf-vxmre.mjs";
4
+ import { a as normalizeActorHandle, c as esm_default, i as isActor, n as getActorHandle, r as getActorTypeName, t as getActorClassByTypeName } from "./actor-COjhyCpy.mjs";
6
5
  import { test } from "@fedify/fixture";
7
6
  import { deepStrictEqual, ok, rejects, strictEqual, throws } from "node:assert/strict";
8
- import { lookupWebFinger } from "@fedify/webfinger";
9
- import { SpanStatusCode, trace } from "@opentelemetry/api";
10
- import { domainToASCII, domainToUnicode } from "node:url";
11
7
  //#region ../../node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/precondition/PreconditionFailure.js
12
8
  var PreconditionFailure = class PreconditionFailure extends Error {
13
9
  constructor(interruptExecution = false) {
@@ -5609,138 +5605,6 @@ function anything(constraints) {
5609
5605
  return anyArbitraryBuilder(toQualifiedObjectConstraints(constraints));
5610
5606
  }
5611
5607
  //#endregion
5612
- //#region src/actor.ts
5613
- /**
5614
- * Checks if the given object is an {@link Actor}.
5615
- * @param object The object to check.
5616
- * @returns `true` if the given object is an {@link Actor}.
5617
- */
5618
- function isActor(object) {
5619
- return object instanceof Application || object instanceof Group || object instanceof Organization || object instanceof Person || object instanceof Service;
5620
- }
5621
- /**
5622
- * Gets the type name of the given actor.
5623
- * @param actor The actor to get the type name of.
5624
- * @returns The type name of the given actor.
5625
- */
5626
- function getActorTypeName(actor) {
5627
- if (actor instanceof Application) return "Application";
5628
- else if (actor instanceof Group) return "Group";
5629
- else if (actor instanceof Organization) return "Organization";
5630
- else if (actor instanceof Person) return "Person";
5631
- else if (actor instanceof Service) return "Service";
5632
- throw new Error("Unknown actor type.");
5633
- }
5634
- /**
5635
- * Gets the actor class by the given type name.
5636
- * @param typeName The type name to get the actor class by.
5637
- * @returns The actor class by the given type name.
5638
- */
5639
- function getActorClassByTypeName(typeName) {
5640
- switch (typeName) {
5641
- case "Application": return Application;
5642
- case "Group": return Group;
5643
- case "Organization": return Organization;
5644
- case "Person": return Person;
5645
- case "Service": return Service;
5646
- }
5647
- throw new Error("Unknown actor type name.");
5648
- }
5649
- /**
5650
- * Gets the actor handle, of the form `@username@domain`, from the given actor
5651
- * or an actor URI.
5652
- *
5653
- * @example
5654
- * ``` typescript
5655
- * // Get the handle of an actor object:
5656
- * await getActorHandle(
5657
- * new Person({ id: new URL("https://fosstodon.org/users/hongminhee") })
5658
- * );
5659
- *
5660
- * // Get the handle of an actor URI:
5661
- * await getActorHandle(new URL("https://fosstodon.org/users/hongminhee"));
5662
- * ```
5663
- *
5664
- * @param actor The actor or actor URI to get the handle from.
5665
- * @param options The extra options for getting the actor handle.
5666
- * @returns The actor handle. It starts with `@` and is followed by the
5667
- * username and domain, separated by `@` by default (it can be
5668
- * customized with the options).
5669
- * @throws {TypeError} If the actor does not have enough information to get the
5670
- * handle.
5671
- * @since 0.4.0
5672
- */
5673
- async function getActorHandle(actor, options = {}) {
5674
- return await (options.tracerProvider ?? trace.getTracerProvider()).getTracer(name, version).startActiveSpan("activitypub.get_actor_handle", async (span) => {
5675
- if (isActor(actor)) {
5676
- if (actor.id != null) span.setAttribute("activitypub.actor.id", actor.id.href);
5677
- span.setAttribute("activitypub.actor.type", getTypeId(actor).href);
5678
- }
5679
- try {
5680
- return await getActorHandleInternal(actor, options);
5681
- } catch (error) {
5682
- span.setStatus({
5683
- code: SpanStatusCode.ERROR,
5684
- message: String(error)
5685
- });
5686
- throw error;
5687
- } finally {
5688
- span.end();
5689
- }
5690
- });
5691
- }
5692
- async function getActorHandleInternal(actor, options = {}) {
5693
- const actorId = actor instanceof URL ? actor : actor.id;
5694
- if (actorId != null) {
5695
- const result = await lookupWebFinger(actorId, {
5696
- userAgent: options.userAgent,
5697
- tracerProvider: options.tracerProvider
5698
- });
5699
- if (result != null) {
5700
- const aliases = [...result.aliases ?? []];
5701
- if (result.subject != null) aliases.unshift(result.subject);
5702
- for (const alias of aliases) {
5703
- const match = alias.match(/^acct:([^@]+)@([^@]+)$/);
5704
- if (match != null) {
5705
- if (new URL(`https://${match[2]}/`).hostname !== actorId.hostname && !await verifyCrossOriginActorHandle(actorId.href, alias, options.userAgent, options.tracerProvider)) continue;
5706
- return normalizeActorHandle(`@${match[1]}@${match[2]}`, options);
5707
- }
5708
- }
5709
- }
5710
- }
5711
- if (!(actor instanceof URL) && actor.preferredUsername != null && actor.id != null) return normalizeActorHandle(`@${actor.preferredUsername}@${actor.id.host}`, options);
5712
- throw new TypeError("Actor does not have enough information to get the handle.");
5713
- }
5714
- async function verifyCrossOriginActorHandle(actorId, alias, userAgent, tracerProvider) {
5715
- const response = await lookupWebFinger(alias, {
5716
- userAgent,
5717
- tracerProvider
5718
- });
5719
- if (response == null) return false;
5720
- for (const alias of response.aliases ?? []) if (new URL(alias).href === actorId) return true;
5721
- return false;
5722
- }
5723
- /**
5724
- * Normalizes the given actor handle.
5725
- * @param handle The full handle of the actor to normalize.
5726
- * @param options The options for normalizing the actor handle.
5727
- * @returns The normalized actor handle.
5728
- * @throws {TypeError} If the actor handle is invalid.
5729
- * @since 0.9.0
5730
- */
5731
- function normalizeActorHandle(handle, options = {}) {
5732
- handle = handle.replace(/^@/, "");
5733
- const atPos = handle.indexOf("@");
5734
- if (atPos < 1) throw new TypeError("Invalid actor handle.");
5735
- let domain = handle.substring(atPos + 1);
5736
- if (domain.length < 1 || domain.includes("@")) throw new TypeError("Invalid actor handle.");
5737
- domain = domain.toLowerCase();
5738
- domain = options.punycode ? domainToASCII(domain) : domainToUnicode(domain);
5739
- domain = domain.toLowerCase();
5740
- const user = handle.substring(0, atPos);
5741
- return options.trimLeadingAt ? `${user}@${domain}` : `@${user}@${domain}`;
5742
- }
5743
- //#endregion
5744
5608
  //#region src/actor.test.ts
5745
5609
  function actorClass() {
5746
5610
  return constantFrom(Application, Group, Organization, Person, Service);
@@ -1,10 +1,10 @@
1
1
  import { Temporal } from "@js-temporal/polyfill";
2
2
  globalThis.addEventListener = () => {};
3
3
  import { g as Object$1, h as Note, i as Collection, y as Person } from "./vocab-B0Z-tH4q.mjs";
4
- import { n as version, r as esm_default, t as name } from "./deno-C05d783V.mjs";
4
+ import { c as esm_default, i as isActor, o as name, s as version } from "./actor-COjhyCpy.mjs";
5
5
  import { t as getTypeId } from "./type-Cf-vxmre.mjs";
6
6
  import { t as assertInstanceOf } from "./utils-CE8Dk5hm.mjs";
7
- import { createTestTracerProvider, mockDocumentLoader, test } from "@fedify/fixture";
7
+ import { createTestMeterProvider, createTestTracerProvider, mockDocumentLoader, test } from "@fedify/fixture";
8
8
  import { deepStrictEqual, equal, ok, rejects } from "node:assert/strict";
9
9
  import { lookupWebFinger } from "@fedify/webfinger";
10
10
  import { SpanStatusCode, trace } from "@opentelemetry/api";
@@ -67,6 +67,40 @@ function toAcctUrl(handle) {
67
67
  }
68
68
  //#endregion
69
69
  //#region src/lookup.ts
70
+ const objectLookupCounters = /* @__PURE__ */ new WeakMap();
71
+ function getObjectLookupCounter(meterProvider) {
72
+ let counter = objectLookupCounters.get(meterProvider);
73
+ if (counter == null) {
74
+ counter = meterProvider.getMeter(name, version).createCounter("activitypub.object.lookup", {
75
+ description: "ActivityStreams object lookups via lookupObject(), classified by whether the resolved value is an Actor.",
76
+ unit: "{lookup}"
77
+ });
78
+ objectLookupCounters.set(meterProvider, counter);
79
+ }
80
+ return counter;
81
+ }
82
+ function getLookupRemoteHost(identifier) {
83
+ let url;
84
+ if (identifier instanceof URL) url = identifier;
85
+ else try {
86
+ url = new URL(identifier);
87
+ } catch {
88
+ return extractHandleHost(identifier.startsWith("@") ? identifier.slice(1) : identifier);
89
+ }
90
+ if (url.hostname !== "") return url.hostname;
91
+ if (url.protocol === "acct:") return extractHandleHost(url.pathname);
92
+ }
93
+ function extractHandleHost(handle) {
94
+ const at = handle.lastIndexOf("@");
95
+ if (at < 0 || at >= handle.length - 1) return void 0;
96
+ const candidate = handle.slice(at + 1);
97
+ if (/[/?#\s]/.test(candidate)) return void 0;
98
+ try {
99
+ return new URL(`https://${candidate}`).hostname || void 0;
100
+ } catch {
101
+ return;
102
+ }
103
+ }
70
104
  const logger = getLogger([
71
105
  "fedify",
72
106
  "vocab",
@@ -106,8 +140,10 @@ const logger = getLogger([
106
140
  */
107
141
  async function lookupObject(identifier, options = {}) {
108
142
  return await (options.tracerProvider ?? trace.getTracerProvider()).getTracer(name, version).startActiveSpan("activitypub.lookup_object", async (span) => {
143
+ let kind = "other";
109
144
  try {
110
145
  const result = await lookupObjectInternal(identifier, options);
146
+ if (result != null) kind = isActor(result) ? "actor" : "object";
111
147
  if (result == null) span.setStatus({ code: SpanStatusCode.ERROR });
112
148
  else {
113
149
  if (result.id != null) span.setAttribute("activitypub.object.id", result.id.href);
@@ -127,6 +163,12 @@ async function lookupObject(identifier, options = {}) {
127
163
  });
128
164
  throw error;
129
165
  } finally {
166
+ if (options.meterProvider != null) {
167
+ const attributes = { "activitypub.lookup.kind": kind };
168
+ const host = getLookupRemoteHost(identifier);
169
+ if (host != null) attributes["activitypub.remote.host"] = host;
170
+ getObjectLookupCounter(options.meterProvider).add(1, attributes);
171
+ }
130
172
  span.end();
131
173
  }
132
174
  });
@@ -668,5 +710,110 @@ test("lookupObject() records OpenTelemetry span events", async () => {
668
710
  ok(typeof event.attributes["activitypub.object.json"] === "string");
669
711
  deepStrictEqual(JSON.parse(event.attributes["activitypub.object.json"]).id, "https://example.com/object");
670
712
  });
713
+ test("lookupObject() records activitypub.object.lookup counter", {
714
+ sanitizeResources: false,
715
+ sanitizeOps: false
716
+ }, async (t) => {
717
+ esm_default.spyGlobal();
718
+ try {
719
+ esm_default.removeRoutes();
720
+ esm_default.get("begin:https://example.com/.well-known/webfinger", {
721
+ subject: "acct:johndoe@example.com",
722
+ links: [{
723
+ rel: "self",
724
+ href: "https://example.com/person",
725
+ type: "application/activity+json"
726
+ }]
727
+ });
728
+ await t.step("records kind=actor for an actor result", async () => {
729
+ const [meterProvider, recorder] = createTestMeterProvider();
730
+ assertInstanceOf(await lookupObject("@johndoe@example.com", {
731
+ documentLoader: mockDocumentLoader,
732
+ contextLoader: mockDocumentLoader,
733
+ meterProvider
734
+ }), Person);
735
+ const counters = recorder.getMeasurements("activitypub.object.lookup");
736
+ deepStrictEqual(counters.length, 1);
737
+ deepStrictEqual(counters[0].type, "counter");
738
+ deepStrictEqual(counters[0].value, 1);
739
+ deepStrictEqual(counters[0].attributes["activitypub.lookup.kind"], "actor");
740
+ deepStrictEqual(counters[0].attributes["activitypub.remote.host"], "example.com");
741
+ deepStrictEqual(recorder.getMeasurements("activitypub.document.fetch").length, 0);
742
+ });
743
+ await t.step("records kind=object for a non-actor object", async () => {
744
+ const [meterProvider, recorder] = createTestMeterProvider();
745
+ assertInstanceOf(await lookupObject("https://example.com/object", {
746
+ documentLoader: mockDocumentLoader,
747
+ contextLoader: mockDocumentLoader,
748
+ meterProvider
749
+ }), Object$1);
750
+ const counters = recorder.getMeasurements("activitypub.object.lookup");
751
+ deepStrictEqual(counters.length, 1);
752
+ deepStrictEqual(counters[0].attributes["activitypub.lookup.kind"], "object");
753
+ deepStrictEqual(counters[0].attributes["activitypub.remote.host"], "example.com");
754
+ });
755
+ await t.step("records kind=other on null result", async () => {
756
+ esm_default.removeRoutes();
757
+ esm_default.get("begin:https://example.com/.well-known/webfinger", {
758
+ subject: "acct:janedoe@example.com",
759
+ links: [{
760
+ rel: "self",
761
+ href: "https://example.com/404",
762
+ type: "application/activity+json"
763
+ }]
764
+ });
765
+ const [meterProvider, recorder] = createTestMeterProvider();
766
+ deepStrictEqual(await lookupObject("janedoe@example.com", {
767
+ documentLoader: mockDocumentLoader,
768
+ contextLoader: mockDocumentLoader,
769
+ meterProvider
770
+ }), null);
771
+ const counters = recorder.getMeasurements("activitypub.object.lookup");
772
+ deepStrictEqual(counters.length, 1);
773
+ deepStrictEqual(counters[0].attributes["activitypub.lookup.kind"], "other");
774
+ deepStrictEqual(counters[0].attributes["activitypub.remote.host"], "example.com");
775
+ });
776
+ await t.step("omits counter when no meterProvider is provided", async () => {
777
+ const [_unused, recorder] = createTestMeterProvider();
778
+ await lookupObject("https://example.com/object", {
779
+ documentLoader: mockDocumentLoader,
780
+ contextLoader: mockDocumentLoader
781
+ });
782
+ deepStrictEqual(recorder.getMeasurements("activitypub.object.lookup").length, 0);
783
+ });
784
+ await t.step("extracts host from a URL acct: instance", async () => {
785
+ esm_default.removeRoutes();
786
+ esm_default.get("begin:https://example.com/.well-known/webfinger", {
787
+ subject: "acct:johndoe@example.com",
788
+ links: [{
789
+ rel: "self",
790
+ href: "https://example.com/person",
791
+ type: "application/activity+json"
792
+ }]
793
+ });
794
+ const [meterProvider, recorder] = createTestMeterProvider();
795
+ await lookupObject(new URL("acct:johndoe@example.com"), {
796
+ documentLoader: mockDocumentLoader,
797
+ contextLoader: mockDocumentLoader,
798
+ meterProvider
799
+ });
800
+ deepStrictEqual(recorder.getMeasurement("activitypub.object.lookup")?.attributes["activitypub.remote.host"], "example.com");
801
+ });
802
+ await t.step("omits host when the identifier carries path/query characters", async () => {
803
+ const [meterProvider, recorder] = createTestMeterProvider();
804
+ await lookupObject("johndoe@example.com/leak", {
805
+ documentLoader: mockDocumentLoader,
806
+ contextLoader: mockDocumentLoader,
807
+ meterProvider
808
+ });
809
+ const counter = recorder.getMeasurement("activitypub.object.lookup");
810
+ ok(counter != null);
811
+ deepStrictEqual("activitypub.remote.host" in counter.attributes, false, "high-cardinality handle suffixes must not be recorded as remote.host");
812
+ });
813
+ } finally {
814
+ esm_default.removeRoutes();
815
+ esm_default.hardReset();
816
+ }
817
+ });
671
818
  //#endregion
672
819
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fedify/vocab",
3
- "version": "2.3.0-dev.1119+6cc02662",
3
+ "version": "2.3.0-dev.1137+53a1f26d",
4
4
  "homepage": "https://fedify.dev/",
5
5
  "repository": {
6
6
  "type": "git",
@@ -46,9 +46,9 @@
46
46
  "es-toolkit": "1.46.1",
47
47
  "jsonld": "^9.0.0",
48
48
  "pkijs": "^3.3.3",
49
- "@fedify/webfinger": "2.3.0-dev.1119+6cc02662",
50
- "@fedify/vocab-tools": "2.3.0-dev.1119+6cc02662",
51
- "@fedify/vocab-runtime": "2.3.0-dev.1119+6cc02662"
49
+ "@fedify/webfinger": "2.3.0-dev.1137+53a1f26d",
50
+ "@fedify/vocab-runtime": "2.3.0-dev.1137+53a1f26d",
51
+ "@fedify/vocab-tools": "2.3.0-dev.1137+53a1f26d"
52
52
  },
53
53
  "devDependencies": {
54
54
  "@types/node": "^22.17.0",
@@ -1,4 +1,5 @@
1
1
  import {
2
+ createTestMeterProvider,
2
3
  createTestTracerProvider,
3
4
  mockDocumentLoader,
4
5
  test,
@@ -693,4 +694,175 @@ test("lookupObject() records OpenTelemetry span events", async () => {
693
694
  deepStrictEqual(recordedObject.id, "https://example.com/object");
694
695
  });
695
696
 
697
+ test("lookupObject() records activitypub.object.lookup counter", {
698
+ sanitizeResources: false,
699
+ sanitizeOps: false,
700
+ }, async (t) => {
701
+ fetchMock.spyGlobal();
702
+ // Cleanup must run even if a test step throws so the global `fetch`
703
+ // spy does not leak into subsequent tests.
704
+ try {
705
+ fetchMock.removeRoutes();
706
+ fetchMock.get(
707
+ "begin:https://example.com/.well-known/webfinger",
708
+ {
709
+ subject: "acct:johndoe@example.com",
710
+ links: [
711
+ {
712
+ rel: "self",
713
+ href: "https://example.com/person",
714
+ type: "application/activity+json",
715
+ },
716
+ ],
717
+ },
718
+ );
719
+
720
+ await t.step("records kind=actor for an actor result", async () => {
721
+ const [meterProvider, recorder] = createTestMeterProvider();
722
+ const person = await lookupObject("@johndoe@example.com", {
723
+ documentLoader: mockDocumentLoader,
724
+ contextLoader: mockDocumentLoader,
725
+ meterProvider,
726
+ });
727
+ assertInstanceOf(person, Person);
728
+ const counters = recorder.getMeasurements("activitypub.object.lookup");
729
+ deepStrictEqual(counters.length, 1);
730
+ deepStrictEqual(counters[0].type, "counter");
731
+ deepStrictEqual(counters[0].value, 1);
732
+ deepStrictEqual(
733
+ counters[0].attributes["activitypub.lookup.kind"],
734
+ "actor",
735
+ );
736
+ deepStrictEqual(
737
+ counters[0].attributes["activitypub.remote.host"],
738
+ "example.com",
739
+ );
740
+ // No `activitypub.document.fetch` measurement should be recorded by
741
+ // lookupObject itself; that lives on the document-loader path.
742
+ deepStrictEqual(
743
+ recorder.getMeasurements("activitypub.document.fetch").length,
744
+ 0,
745
+ );
746
+ });
747
+
748
+ await t.step("records kind=object for a non-actor object", async () => {
749
+ const [meterProvider, recorder] = createTestMeterProvider();
750
+ const object = await lookupObject("https://example.com/object", {
751
+ documentLoader: mockDocumentLoader,
752
+ contextLoader: mockDocumentLoader,
753
+ meterProvider,
754
+ });
755
+ assertInstanceOf(object, Object);
756
+ const counters = recorder.getMeasurements("activitypub.object.lookup");
757
+ deepStrictEqual(counters.length, 1);
758
+ deepStrictEqual(
759
+ counters[0].attributes["activitypub.lookup.kind"],
760
+ "object",
761
+ );
762
+ deepStrictEqual(
763
+ counters[0].attributes["activitypub.remote.host"],
764
+ "example.com",
765
+ );
766
+ });
767
+
768
+ await t.step("records kind=other on null result", async () => {
769
+ fetchMock.removeRoutes();
770
+ fetchMock.get("begin:https://example.com/.well-known/webfinger", {
771
+ subject: "acct:janedoe@example.com",
772
+ links: [
773
+ {
774
+ rel: "self",
775
+ href: "https://example.com/404",
776
+ type: "application/activity+json",
777
+ },
778
+ ],
779
+ });
780
+ const [meterProvider, recorder] = createTestMeterProvider();
781
+ const result = await lookupObject("janedoe@example.com", {
782
+ documentLoader: mockDocumentLoader,
783
+ contextLoader: mockDocumentLoader,
784
+ meterProvider,
785
+ });
786
+ deepStrictEqual(result, null);
787
+ const counters = recorder.getMeasurements("activitypub.object.lookup");
788
+ deepStrictEqual(counters.length, 1);
789
+ deepStrictEqual(
790
+ counters[0].attributes["activitypub.lookup.kind"],
791
+ "other",
792
+ );
793
+ deepStrictEqual(
794
+ counters[0].attributes["activitypub.remote.host"],
795
+ "example.com",
796
+ );
797
+ });
798
+
799
+ await t.step(
800
+ "omits counter when no meterProvider is provided",
801
+ async () => {
802
+ const [_unused, recorder] = createTestMeterProvider();
803
+ await lookupObject("https://example.com/object", {
804
+ documentLoader: mockDocumentLoader,
805
+ contextLoader: mockDocumentLoader,
806
+ });
807
+ deepStrictEqual(
808
+ recorder.getMeasurements("activitypub.object.lookup").length,
809
+ 0,
810
+ );
811
+ },
812
+ );
813
+
814
+ await t.step(
815
+ "extracts host from a URL acct: instance",
816
+ async () => {
817
+ fetchMock.removeRoutes();
818
+ fetchMock.get("begin:https://example.com/.well-known/webfinger", {
819
+ subject: "acct:johndoe@example.com",
820
+ links: [
821
+ {
822
+ rel: "self",
823
+ href: "https://example.com/person",
824
+ type: "application/activity+json",
825
+ },
826
+ ],
827
+ });
828
+ const [meterProvider, recorder] = createTestMeterProvider();
829
+ await lookupObject(new URL("acct:johndoe@example.com"), {
830
+ documentLoader: mockDocumentLoader,
831
+ contextLoader: mockDocumentLoader,
832
+ meterProvider,
833
+ });
834
+ const counter = recorder.getMeasurement("activitypub.object.lookup");
835
+ deepStrictEqual(
836
+ counter?.attributes["activitypub.remote.host"],
837
+ "example.com",
838
+ );
839
+ },
840
+ );
841
+
842
+ await t.step(
843
+ "omits host when the identifier carries path/query characters",
844
+ async () => {
845
+ const [meterProvider, recorder] = createTestMeterProvider();
846
+ // The bare-handle parser must refuse to put a path-bearing string
847
+ // like "example.com/leak" into the metric attribute.
848
+ await lookupObject("johndoe@example.com/leak", {
849
+ documentLoader: mockDocumentLoader,
850
+ contextLoader: mockDocumentLoader,
851
+ meterProvider,
852
+ });
853
+ const counter = recorder.getMeasurement("activitypub.object.lookup");
854
+ ok(counter != null);
855
+ deepStrictEqual(
856
+ "activitypub.remote.host" in counter.attributes,
857
+ false,
858
+ "high-cardinality handle suffixes must not be recorded as remote.host",
859
+ );
860
+ },
861
+ );
862
+ } finally {
863
+ fetchMock.removeRoutes();
864
+ fetchMock.hardReset();
865
+ }
866
+ });
867
+
696
868
  // cSpell: ignore gildong
package/src/lookup.ts CHANGED
@@ -6,13 +6,95 @@ import {
6
6
  } from "@fedify/vocab-runtime";
7
7
  import { lookupWebFinger } from "@fedify/webfinger";
8
8
  import { getLogger } from "@logtape/logtape";
9
- import { SpanStatusCode, trace, type TracerProvider } from "@opentelemetry/api";
9
+ import {
10
+ type Attributes,
11
+ type Counter,
12
+ type MeterProvider,
13
+ SpanStatusCode,
14
+ trace,
15
+ type TracerProvider,
16
+ } from "@opentelemetry/api";
10
17
  import { delay } from "es-toolkit";
11
18
  import metadata from "../deno.json" with { type: "json" };
19
+ import { isActor } from "./actor.ts";
12
20
  import { toAcctUrl } from "./handle.ts";
13
21
  import { getTypeId } from "./type.ts";
14
22
  import { type Collection, type Link, Object } from "./vocab.ts";
15
23
 
24
+ /**
25
+ * The classification of an ActivityStreams lookup performed by
26
+ * {@link lookupObject}, recorded as `activitypub.lookup.kind` on the
27
+ * `activitypub.object.lookup` counter.
28
+ *
29
+ * - `actor`: the resolved object is an {@link import("./actor.ts").Actor}.
30
+ * - `object`: the resolved object is a non-actor ActivityStreams object.
31
+ * - `other`: the lookup did not resolve to an object (not found, network
32
+ * failure, parse failure).
33
+ * @since 2.3.0
34
+ */
35
+ export type ObjectLookupKind = "actor" | "object" | "other";
36
+
37
+ const objectLookupCounters = new WeakMap<MeterProvider, Counter>();
38
+
39
+ function getObjectLookupCounter(meterProvider: MeterProvider): Counter {
40
+ let counter = objectLookupCounters.get(meterProvider);
41
+ if (counter == null) {
42
+ counter = meterProvider
43
+ .getMeter(metadata.name, metadata.version)
44
+ .createCounter("activitypub.object.lookup", {
45
+ description:
46
+ "ActivityStreams object lookups via lookupObject(), classified " +
47
+ "by whether the resolved value is an Actor.",
48
+ unit: "{lookup}",
49
+ });
50
+ objectLookupCounters.set(meterProvider, counter);
51
+ }
52
+ return counter;
53
+ }
54
+
55
+ function getLookupRemoteHost(identifier: string | URL): string | undefined {
56
+ let url: URL | undefined;
57
+ if (identifier instanceof URL) {
58
+ url = identifier;
59
+ } else {
60
+ try {
61
+ url = new URL(identifier);
62
+ } catch {
63
+ // Not a URL — try to interpret as a bare fediverse handle below.
64
+ const stripped = identifier.startsWith("@")
65
+ ? identifier.slice(1)
66
+ : identifier;
67
+ return extractHandleHost(stripped);
68
+ }
69
+ }
70
+ if (url.hostname !== "") return url.hostname;
71
+ // `acct:` URIs are opaque (no `//host` form), so the URL hostname is
72
+ // empty. The user and authority live in `url.pathname` as
73
+ // `user@host`; reuse the same handle-extraction logic, which both
74
+ // takes only the substring after the last `@` and refuses to record
75
+ // anything that looks like a path / query / fragment rather than a
76
+ // bare hostname.
77
+ if (url.protocol === "acct:") return extractHandleHost(url.pathname);
78
+ return undefined;
79
+ }
80
+
81
+ function extractHandleHost(handle: string): string | undefined {
82
+ const at = handle.lastIndexOf("@");
83
+ if (at < 0 || at >= handle.length - 1) return undefined;
84
+ const candidate = handle.slice(at + 1);
85
+ // Reject anything that is not just an authority — paths, queries,
86
+ // fragments, and spaces would all leak high-cardinality metadata into
87
+ // the metric attribute, so we drop the host entirely in those cases.
88
+ if (/[/?#\s]/.test(candidate)) return undefined;
89
+ // Round-trip through `URL` so the parser validates the authority and
90
+ // strips any port/userinfo before we record it.
91
+ try {
92
+ return new URL(`https://${candidate}`).hostname || undefined;
93
+ } catch {
94
+ return undefined;
95
+ }
96
+ }
97
+
16
98
  const logger = getLogger(["fedify", "vocab", "lookup"]);
17
99
 
18
100
  /**
@@ -67,6 +149,15 @@ export interface LookupObjectOptions {
67
149
  */
68
150
  tracerProvider?: TracerProvider;
69
151
 
152
+ /**
153
+ * The OpenTelemetry meter provider used to record the
154
+ * `activitypub.object.lookup` counter. If omitted, the counter is not
155
+ * emitted at all (the helper is opt-in to avoid touching the global
156
+ * meter provider for callers that do not use OpenTelemetry).
157
+ * @since 2.3.0
158
+ */
159
+ meterProvider?: MeterProvider;
160
+
70
161
  /**
71
162
  * AbortSignal for cancelling the request.
72
163
  * @since 1.8.0
@@ -118,8 +209,16 @@ export async function lookupObject(
118
209
  return await tracer.startActiveSpan(
119
210
  "activitypub.lookup_object",
120
211
  async (span) => {
212
+ let kind: ObjectLookupKind = "other";
121
213
  try {
122
214
  const result = await lookupObjectInternal(identifier, options);
215
+ // Classify the result as soon as `lookupObjectInternal` returns,
216
+ // so that any subsequent throw (for example from
217
+ // `result.toJsonLd(options)` while building the span event) does
218
+ // not roll `kind` back to `"other"` in the `finally` block.
219
+ if (result != null) {
220
+ kind = isActor(result) ? "actor" : "object";
221
+ }
123
222
  if (result == null) span.setStatus({ code: SpanStatusCode.ERROR });
124
223
  else {
125
224
  if (result.id != null) {
@@ -150,6 +249,14 @@ export async function lookupObject(
150
249
  });
151
250
  throw error;
152
251
  } finally {
252
+ if (options.meterProvider != null) {
253
+ const attributes: Attributes = {
254
+ "activitypub.lookup.kind": kind,
255
+ };
256
+ const host = getLookupRemoteHost(identifier);
257
+ if (host != null) attributes["activitypub.remote.host"] = host;
258
+ getObjectLookupCounter(options.meterProvider).add(1, attributes);
259
+ }
153
260
  span.end();
154
261
  }
155
262
  },