@fedify/cli 2.4.0-dev.2247 → 2.4.0-dev.2253

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/deno.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import "temporal-polyfill";
2
2
  //#region deno.json
3
- var version = "2.4.0-dev.2247+4fe1b8c3";
3
+ var version = "2.4.0-dev.2253+f5212c33";
4
4
  //#endregion
5
5
  export { version };
@@ -1,40 +1,173 @@
1
1
  import "temporal-polyfill";
2
- import { FetchError, UrlError } from "@fedify/vocab-runtime";
3
- import "@fedify/vocab";
2
+ import { createPortableObjectVerifier, getPortableLookupProblem, isPortableReference } from "./portable.js";
3
+ import { FetchError, UrlError, formatIri } from "@fedify/vocab-runtime";
4
+ import { lookupObject } from "@fedify/vocab";
4
5
  //#region src/diagnostics.ts
5
- /** Records loader failures that lookupObject would otherwise swallow or wrap. */
6
- function createLookupDiagnostics(documentLoader, contextLoader) {
6
+ /**
7
+ * Records loader failures that lookupObject would otherwise swallow or wrap,
8
+ * and portable objects that the verifier rejects.
9
+ * @param documentLoader The document loader to record failures of.
10
+ * @param contextLoader The context loader to record failures of.
11
+ * @param verifierDocumentLoader The document loader for the documents that
12
+ * the [FEP-ef61] portable object verifier
13
+ * discovers. If omitted, the returned
14
+ * `verifyPortableObject` is `undefined`.
15
+ *
16
+ * [FEP-ef61]: https://w3id.org/fep/ef61
17
+ */
18
+ function createLookupDiagnostics(documentLoader, contextLoader, verifierDocumentLoader) {
7
19
  let objectFailure;
8
20
  let contextFailure;
21
+ let portableFailure;
22
+ let verificationFailures;
9
23
  const wrap = (loader, source, record) => async (url, options) => {
10
24
  try {
11
25
  const document = await loader(url, options);
12
26
  record(void 0);
27
+ if (verificationFailures != null) verificationFailures.length = 0;
13
28
  return document;
14
29
  } catch (error) {
15
- record({
30
+ const failure = {
16
31
  error,
17
32
  source
18
- });
33
+ };
34
+ record(failure);
35
+ verificationFailures?.push(failure);
19
36
  throw error;
20
37
  }
21
38
  };
39
+ const recordObjectFailure = (failure) => objectFailure = failure;
40
+ const wrappedContextLoader = wrap(contextLoader, "context", (e) => contextFailure = e);
41
+ const verifyPortableObject = verifierDocumentLoader == null ? void 0 : createPortableObjectVerifier(wrap(verifierDocumentLoader, "object", recordObjectFailure), wrappedContextLoader);
42
+ const verify = verifyPortableObject == null ? void 0 : async (document, options) => {
43
+ const id = getDocumentId(document);
44
+ const failures = [];
45
+ verificationFailures = failures;
46
+ try {
47
+ const result = await verifyPortableObject(document, options);
48
+ if (result.verified) portableFailure = void 0;
49
+ else if (result.reason.type === "collectionOwnerUnavailable" && failures.length > 0) portableFailure = failures[failures.length - 1];
50
+ else portableFailure = {
51
+ error: void 0,
52
+ source: "portable",
53
+ id,
54
+ reason: result.reason
55
+ };
56
+ return result;
57
+ } catch (error) {
58
+ portableFailure = {
59
+ error,
60
+ source: "portable",
61
+ id
62
+ };
63
+ throw error;
64
+ } finally {
65
+ verificationFailures = void 0;
66
+ }
67
+ };
22
68
  return {
23
- documentLoader: wrap(documentLoader, "object", (e) => objectFailure = e),
24
- contextLoader: wrap(contextLoader, "context", (e) => contextFailure = e),
69
+ documentLoader: wrap(documentLoader, "object", recordObjectFailure),
70
+ contextLoader: wrappedContextLoader,
71
+ verifyPortableObject: verify,
25
72
  clearFailures() {
26
73
  objectFailure = void 0;
27
74
  contextFailure = void 0;
75
+ portableFailure = void 0;
28
76
  },
29
77
  getObjectFailure: () => objectFailure,
30
- getContextFailure: () => contextFailure
78
+ getContextFailure: () => contextFailure,
79
+ getPortableFailure: () => portableFailure
31
80
  };
32
81
  }
82
+ function getDocumentId(document) {
83
+ if (document == null || typeof document !== "object") return void 0;
84
+ const record = document;
85
+ const id = record.id ?? record["@id"];
86
+ return typeof id === "string" ? formatIdentifier(id) : void 0;
87
+ }
88
+ /** Returns an object and a snapshot of the evidence for a failed lookup. */
89
+ async function lookupWithDiagnostics(identifier, options, lookup = lookupObject) {
90
+ const { verifierDocumentLoader, ...lookupOptions } = options;
91
+ if (verifierDocumentLoader != null) {
92
+ const problem = getPortableLookupProblem(identifier, options.gateways);
93
+ if (problem != null) return Object.freeze({
94
+ object: null,
95
+ failure: {
96
+ error: void 0,
97
+ source: "portable",
98
+ id: formatIdentifier(identifier),
99
+ problem
100
+ }
101
+ });
102
+ }
103
+ const diagnostics = createLookupDiagnostics(options.documentLoader, options.contextLoader, verifierDocumentLoader);
104
+ try {
105
+ const object = await lookup(identifier, {
106
+ ...lookupOptions,
107
+ documentLoader: diagnostics.documentLoader,
108
+ contextLoader: diagnostics.contextLoader,
109
+ ...diagnostics.verifyPortableObject == null ? {} : { verifyPortableObject: diagnostics.verifyPortableObject }
110
+ });
111
+ if (object != null) return Object.freeze({ object });
112
+ return Object.freeze({
113
+ object,
114
+ failure: diagnostics.getPortableFailure() ?? diagnostics.getObjectFailure() ?? diagnostics.getContextFailure() ?? (verifierDocumentLoader != null && isPortableReference(identifier) ? {
115
+ error: void 0,
116
+ source: "portable",
117
+ id: formatIdentifier(identifier)
118
+ } : void 0)
119
+ });
120
+ } catch (error) {
121
+ return Object.freeze({
122
+ object: null,
123
+ thrownError: error,
124
+ failure: diagnostics.getContextFailure() ?? diagnostics.getObjectFailure() ?? diagnostics.getPortableFailure() ?? {
125
+ error,
126
+ source: "other"
127
+ }
128
+ });
129
+ }
130
+ }
131
+ function formatIdentifier(identifier) {
132
+ try {
133
+ return formatIri(identifier);
134
+ } catch {
135
+ return typeof identifier === "string" ? identifier : identifier.href;
136
+ }
137
+ }
138
+ const PORTABLE_REJECTION_REASONS = {
139
+ notPortableObject: "it has neither a portable ID nor a compatible identifier",
140
+ unsupportedObjectType: "its type cannot be a portable object",
141
+ missingProof: "it has no integrity proof",
142
+ invalidGateways: "its gateways property is missing or invalid",
143
+ invalidProof: "its integrity proof is invalid",
144
+ unsupportedVerificationMethod: "its integrity proof has an unsupported verification method",
145
+ verificationMethodMismatch: "its integrity proof was not created by the DID in its ID",
146
+ unknownCollectionSource: "it is a collection without an integrity proof that was not served by a gateway",
147
+ unknownCollectionOwner: "it is a collection without an integrity proof whose owner is unknown",
148
+ conflictingCollectionOwners: "it is a collection without an integrity proof whose owner is ambiguous",
149
+ collectionPageMismatch: "it is a collection page that does not belong to its collection",
150
+ collectionOwnerUnavailable: "it is a collection without an integrity proof whose owner could not be retrieved",
151
+ collectionNotListedByOwner: "it is a collection without an integrity proof that its owner does not list",
152
+ untrustedGateway: "it is a collection without an integrity proof served by a gateway that its owner does not list"
153
+ };
154
+ function describePortableFailure(failure) {
155
+ const id = failure.id ?? "(unknown)";
156
+ if (failure.problem === "malformed") return `Invalid portable ID: ${id}.`;
157
+ if (failure.problem === "no-gateway") return `The portable ID ${id} has no @gateway location hints. Use the --gateway option to look it up.`;
158
+ if (failure.reason != null) return `Rejected the portable object ${id}, as ${PORTABLE_REJECTION_REASONS[failure.reason.type] ?? failure.reason.type}.`;
159
+ if (failure.error != null) return `Failed to verify the portable object ${id}: ${failure.error instanceof Error ? failure.error.message : String(failure.error)}`;
160
+ return `No gateway returned a valid portable object for ${id}.`;
161
+ }
33
162
  function escapeControlCharacters(message) {
34
163
  return message.replace(/[\x00-\x1f\x7f-\x9f]/g, (character) => `\\x${character.charCodeAt(0).toString(16).padStart(2, "0")}`);
35
164
  }
36
165
  /** Describes a failure without recommending signing unless HTTP supports it. */
37
166
  function describeLookupFailure(failure, authorizedFetch) {
167
+ if (failure?.source === "portable") return {
168
+ message: escapeControlCharacters(describePortableFailure(failure)),
169
+ suggestsAuthorizedFetch: false
170
+ };
38
171
  const error = failure?.error;
39
172
  const http = error instanceof FetchError ? /^(.+): HTTP (\d{3}): \1$/s.exec(error.message) : null;
40
173
  if (http != null) {
@@ -67,4 +200,4 @@ function describeLookupFailure(failure, authorizedFetch) {
67
200
  };
68
201
  }
69
202
  //#endregion
70
- export { createLookupDiagnostics, describeLookupFailure };
203
+ export { createLookupDiagnostics, describeLookupFailure, lookupWithDiagnostics };
@@ -1,6 +1,7 @@
1
1
  import "temporal-polyfill";
2
2
  import { configContext } from "../config.js";
3
3
  import { createTunnelOption } from "../options.js";
4
+ import { gatewayUrl } from "../portable.js";
4
5
  import { command, constant, group, merge, message, multiple, object, option, string } from "@optique/core";
5
6
  import { bindConfig } from "@optique/config";
6
7
  //#region src/inbox/command.ts
@@ -8,12 +9,15 @@ const DEFAULT_EPHEMERAL_INBOX_NAME = "Fedify Ephemeral Inbox";
8
9
  const DEFAULT_EPHEMERAL_INBOX_SUMMARY = "An ephemeral ActivityPub inbox for testing purposes.";
9
10
  const inboxOptions = merge(object("Inbox options", {
10
11
  command: constant("inbox"),
11
- follow: bindConfig(multiple(option("-f", "--follow", string({ metavar: "URI" }), { description: message`Follow the given actor. The argument can be either an actor URI or a handle. Can be specified multiple times.` })), {
12
+ follow: bindConfig(multiple(option("-f", "--follow", string({ metavar: "URI" }), { description: message`Follow the given actor. The argument can be either an actor URI (including an FEP-ef61 portable ID) or a handle. Can be specified multiple times.` })), {
12
13
  context: configContext,
13
14
  key: (config) => config.inbox?.follow ?? [],
14
15
  default: []
15
16
  }),
16
- acceptFollow: bindConfig(multiple(option("-a", "--accept-follow", string({ metavar: "URI" }), { description: message`Accept follow requests from the given actor. The argument can be either an actor URI or a handle, or a wildcard (${"*"}). Can be specified multiple times. If a wildcard is specified, all follow requests will be accepted.` })), {
17
+ gateways: multiple(option("--gateway", gatewayUrl(), { description: message`An FEP-ef61 gateway to look up the portable \
18
+ actors to follow from, instead of their ${"@gateway"} location hints. Can \
19
+ be specified multiple times.` })),
20
+ acceptFollow: bindConfig(multiple(option("-a", "--accept-follow", string({ metavar: "URI" }), { description: message`Accept follow requests from the given actor. The argument can be either an actor URI (including an FEP-ef61 portable ID) or a handle, or a wildcard (${"*"}). Can be specified multiple times. If a wildcard is specified, all follow requests will be accepted.` })), {
17
21
  context: configContext,
18
22
  key: (config) => config.inbox?.acceptFollow ?? [],
19
23
  default: []
package/dist/inbox.js CHANGED
@@ -1,16 +1,18 @@
1
1
  import { Temporal } from "temporal-polyfill";
2
+ import { getPortableLookupProblem } from "./portable.js";
2
3
  import { colors, matchesActor } from "./utils.js";
3
4
  import { configureLogging, recordingSink } from "./log.js";
4
5
  import { version } from "./deno.js";
5
- import { getDocumentLoader } from "./docloader.js";
6
+ import { getDocumentLoader as getDocumentLoader$1 } from "./docloader.js";
6
7
  import { ActivityEntryPage, ActivityListPage } from "./inbox/view.js";
7
8
  import { tableStyle } from "./table.js";
8
9
  import { spawnTemporaryServer } from "./tempserver.js";
9
10
  import process from "node:process";
11
+ import { formatIri } from "@fedify/vocab-runtime";
10
12
  import { MemoryKvStore, createFederation, generateCryptoKeyPair } from "@fedify/fedify";
11
13
  import { getLogger } from "@logtape/logtape";
12
14
  import ora from "ora";
13
- import { Accept, Activity, Application, Delete, Endpoints, Follow, Image, PUBLIC_COLLECTION, isActor, lookupObject } from "@fedify/vocab";
15
+ import { Accept, Activity, Application, Delete, Endpoints, Follow, Image, PUBLIC_COLLECTION, isActor } from "@fedify/vocab";
14
16
  import Table from "cli-table3";
15
17
  import { Hono } from "hono";
16
18
  import { jsx } from "hono/jsx/jsx-runtime";
@@ -32,7 +34,7 @@ async function runInbox(command) {
32
34
  for (const key of Object.keys(peers)) delete peers[key];
33
35
  for (const key of Object.keys(followers)) delete followers[key];
34
36
  if (command.debug) await configureLogging();
35
- const federationDocumentLoader = await getDocumentLoader();
37
+ const federationDocumentLoader = await getDocumentLoader$1();
36
38
  const authorizedFetchEnabled = command.authorizedFetch ?? false;
37
39
  const authorize = async (ctx) => {
38
40
  if (!authorizedFetchEnabled) return true;
@@ -97,13 +99,13 @@ async function runInbox(command) {
97
99
  const follower = await activity.getActor();
98
100
  if (!isActor(follower)) return;
99
101
  if (!await matchesActor(follower, acceptFollows) || activity.id == null) {
100
- logger.debug("Does not accept follow from {actor}.", { actor: follower.id?.href });
102
+ logger.debug("Does not accept follow from {actor}.", { actor: follower.id == null ? null : formatIri(follower.id) });
101
103
  return;
102
104
  }
103
- logger.debug("Accepting follow from {actor}.", { actor: follower.id?.href });
105
+ logger.debug("Accepting follow from {actor}.", { actor: follower.id == null ? null : formatIri(follower.id) });
104
106
  followers[activity.id.href] = follower;
105
107
  await ctx.sendActivity({ identifier }, follower, new Accept({
106
- id: new URL(`#accepts/${follower.id?.href}`, ctx.getActorUri("i")),
108
+ id: new URL(`#accepts/${follower.id == null ? "" : formatIri(follower.id)}`, ctx.getActorUri("i")),
107
109
  actor: ctx.getActorUri(identifier),
108
110
  object: activity.id
109
111
  }));
@@ -181,9 +183,19 @@ async function runInbox(command) {
181
183
  if (command.follow != null && command.follow.length > 0) {
182
184
  spinner.text = "Following actors...";
183
185
  const documentLoader = await fedCtx.getDocumentLoader({ identifier: "i" });
186
+ const gateways = command.gateways.length > 0 ? { gateways: command.gateways } : {};
184
187
  for (const uri of command.follow) {
185
188
  spinner.text = `Following ${colors.green(uri)}...`;
186
- const actor = await lookupObject(uri, { documentLoader });
189
+ const problem = getPortableLookupProblem(uri, command.gateways);
190
+ if (problem != null) {
191
+ spinner.fail(problem === "malformed" ? `Invalid portable ID: ${colors.red(uri)}` : `The portable ID ${colors.red(uri)} has no @gateway location hints. Use the --gateway option to follow it.`);
192
+ spinner.start();
193
+ continue;
194
+ }
195
+ const actor = await fedCtx.lookupObject(uri, {
196
+ documentLoader,
197
+ ...gateways
198
+ });
187
199
  if (!isActor(actor)) {
188
200
  spinner.fail(`Not an actor: ${colors.red(uri)}`);
189
201
  spinner.start();
@@ -191,7 +203,7 @@ async function runInbox(command) {
191
203
  }
192
204
  if (actor.id != null) peers[actor.id?.href] = actor;
193
205
  await fedCtx.sendActivity({ identifier: "i" }, actor, new Follow({
194
- id: new URL(`#follows/${actor.id?.href}`, fedCtx.getActorUri("i")),
206
+ id: new URL(`#follows/${actor.id == null ? "" : formatIri(actor.id)}`, fedCtx.getActorUri("i")),
195
207
  actor: fedCtx.getActorUri("i"),
196
208
  object: actor.id
197
209
  }));
@@ -1,6 +1,7 @@
1
1
  import "temporal-polyfill";
2
2
  import { configContext } from "../config.js";
3
3
  import { createTunnelServiceOption, userAgentOption } from "../options.js";
4
+ import { gatewayUrl } from "../portable.js";
4
5
  import { argument, choice, command, constant, flag, float, integer, map, merge, message, multiple, object, option, optionNames, optional, or, string, withDefault } from "@optique/core";
5
6
  import { path } from "@optique/run";
6
7
  import { bindConfig } from "@optique/config";
@@ -80,6 +81,10 @@ const lookupModeOption = withDefault(or(object("Recurse options", {
80
81
  suppressErrors: false
81
82
  });
82
83
  const lookupOptions = merge(object({ command: constant("lookup") }), lookupModeOption, authorizedFetchOption, merge("Network options", userAgentOption, object({
84
+ gateways: multiple(option("--gateway", gatewayUrl(), { description: message`An FEP-ef61 gateway to look up the given \
85
+ portable objects from, instead of their ${"@gateway"} location hints. Also \
86
+ used for linked portable objects without location hints. Can be specified \
87
+ multiple times.` })),
83
88
  allowPrivateAddress: allowPrivateAddressOption,
84
89
  timeout: optional(bindConfig(option("-T", "--timeout", float({
85
90
  min: 0,
package/dist/lookup.js CHANGED
@@ -1,16 +1,17 @@
1
1
  import "temporal-polyfill";
2
+ import { getIriKey, getPortableLookupProblem } from "./portable.js";
2
3
  import { FEDIBIRD_QUOTE_IRI, IN_REPLY_TO_IRI, MISSKEY_QUOTE_IRI, QUOTE_IRI, QUOTE_URL_IRI } from "./lookup/command.js";
3
4
  import { colorEnabled, colors, describeError, formatObject } from "./utils.js";
4
5
  import { configureLogging } from "./log.js";
5
6
  import { getContextLoader, getDocumentLoader as getDocumentLoader$1 } from "./docloader.js";
6
7
  import { spawnTemporaryServer } from "./tempserver.js";
7
- import { createLookupDiagnostics, describeLookupFailure } from "./diagnostics.js";
8
+ import { createLookupDiagnostics, describeLookupFailure, lookupWithDiagnostics } from "./diagnostics.js";
8
9
  import { renderImages } from "./imagerenderer.js";
9
10
  import process from "node:process";
10
11
  import { message, optionNames, text } from "@optique/core";
11
12
  import { printError } from "@optique/run";
12
13
  import { createWriteStream } from "node:fs";
13
- import { FetchError, UrlError, expandIPv6Address, isValidPublicIPv4Address, isValidPublicIPv6Address } from "@fedify/vocab-runtime";
14
+ import { FetchError, UrlError, expandIPv6Address, formatIri, isValidPublicIPv4Address, isValidPublicIPv6Address } from "@fedify/vocab-runtime";
14
15
  import { generateCryptoKeyPair, getAuthenticatedDocumentLoader, respondWithObject } from "@fedify/fedify";
15
16
  import { getLogger } from "@logtape/logtape";
16
17
  import ora from "ora";
@@ -44,10 +45,13 @@ const MAX_TIMEOUT_MS = 2147483647;
44
45
  */
45
46
  var RecursiveLookupError = class extends Error {
46
47
  target;
47
- constructor(target) {
48
+ /** Why the target could not be fetched, if known. */
49
+ failure;
50
+ constructor(target, failure) {
48
51
  super(`Failed to recursively fetch object: ${target}`);
49
52
  this.name = "RecursiveLookupError";
50
53
  this.target = target;
54
+ this.failure = failure;
51
55
  }
52
56
  };
53
57
  function writeToStream(stream, chunk) {
@@ -281,12 +285,12 @@ async function collectRecursiveObjects(initialObject, recurseProperty, recurseDe
281
285
  const visited = options.visited ?? /* @__PURE__ */ new Set();
282
286
  const results = [];
283
287
  let current = initialObject;
284
- if (current.id != null) visited.add(current.id.href);
288
+ if (current.id != null) visited.add(getVisitedKey(current.id));
285
289
  for (let depth = 0; depth < recurseDepth; depth++) {
286
290
  const targetId = getRecursiveTargetId(current, recurseProperty);
287
291
  if (targetId == null) break;
288
- const target = targetId.href;
289
- if (visited.has(target)) break;
292
+ const target = formatTarget(targetId);
293
+ if (visited.has(getVisitedKey(targetId))) break;
290
294
  let next;
291
295
  try {
292
296
  next = await lookup(target);
@@ -308,42 +312,42 @@ async function collectRecursiveObjects(initialObject, recurseProperty, recurseDe
308
312
  throw new RecursiveLookupError(target);
309
313
  }
310
314
  results.push(next);
311
- visited.add(target);
312
- if (next.id != null) visited.add(next.id.href);
315
+ visited.add(getVisitedKey(targetId));
316
+ if (next.id != null) visited.add(getVisitedKey(next.id));
313
317
  current = next;
314
318
  }
315
319
  return results;
316
320
  }
321
+ /**
322
+ * Gets the key for recognizing an already visited object, which identifies
323
+ * FEP-ef61 portable objects by their canonical IDs.
324
+ * @param iri The object's ID, or the raw string given by the user.
325
+ * @returns The key.
326
+ */
327
+ function getVisitedKey(iri) {
328
+ return getIriKey(iri) ?? (typeof iri === "string" ? iri : iri.href);
329
+ }
330
+ /**
331
+ * Formats a recursion target, printing FEP-ef61 portable IDs in their
332
+ * canonical form rather than their percent-encoded `URL` form.
333
+ */
334
+ function formatTarget(target) {
335
+ try {
336
+ return formatIri(target);
337
+ } catch {
338
+ return target.href;
339
+ }
340
+ }
317
341
  async function runLookup(command, deps = {}) {
318
342
  const effectiveDeps = {
319
343
  lookupObject,
320
344
  traverseCollection,
345
+ getDocumentLoader: getDocumentLoader$1,
321
346
  exit: (code) => process.exit(code),
322
347
  ...deps
323
348
  };
324
- const lookupDiagnosed = async (identifier, options) => {
325
- const diagnostics = createLookupDiagnostics(options.documentLoader, options.contextLoader);
326
- try {
327
- const object = await effectiveDeps.lookupObject(identifier, {
328
- ...options,
329
- ...diagnostics
330
- });
331
- return {
332
- object,
333
- failure: object == null ? diagnostics.getObjectFailure() ?? diagnostics.getContextFailure() : void 0,
334
- thrownError: void 0
335
- };
336
- } catch (error) {
337
- return {
338
- object: null,
339
- thrownError: error,
340
- failure: diagnostics.getContextFailure() ?? diagnostics.getObjectFailure() ?? {
341
- error,
342
- source: "other"
343
- }
344
- };
345
- }
346
- };
349
+ const lookupDiagnosed = (identifier, options) => lookupWithDiagnostics(identifier, options, effectiveDeps.lookupObject);
350
+ const gatewayOptions = command.gateways.length > 0 ? { gateways: command.gateways } : {};
347
351
  if (command.urls.length < 1) {
348
352
  printError(message`At least one URL or actor handle must be provided.`);
349
353
  effectiveDeps.exit(1);
@@ -354,12 +358,12 @@ async function runLookup(command, deps = {}) {
354
358
  discardStdin: false
355
359
  }).start();
356
360
  let server = void 0;
357
- const initialDocumentLoader = wrapDocumentLoaderWithTimeout(await getDocumentLoader$1({
361
+ const initialDocumentLoader = wrapDocumentLoaderWithTimeout(await effectiveDeps.getDocumentLoader({
358
362
  userAgent: command.userAgent,
359
363
  allowPrivateAddress: true,
360
364
  timeout: toDocumentLoaderTimeout(command.timeout)
361
365
  }), command.timeout);
362
- const documentLoader = wrapDocumentLoaderWithTimeout(await getDocumentLoader$1({
366
+ const documentLoader = wrapDocumentLoaderWithTimeout(await effectiveDeps.getDocumentLoader({
363
367
  userAgent: command.userAgent,
364
368
  allowPrivateAddress: command.allowPrivateAddress,
365
369
  timeout: toDocumentLoaderTimeout(command.timeout)
@@ -475,30 +479,25 @@ async function runLookup(command, deps = {}) {
475
479
  const visited = /* @__PURE__ */ new Set();
476
480
  const url = command.urls[urlIndex];
477
481
  if (urlIndex > 0) spinner.text = `Looking up object chain ${urlIndex + 1}/${command.urls.length}...`;
478
- let current = null;
479
- try {
480
- current = await effectiveDeps.lookupObject(url, {
481
- documentLoader: initialLookupDocumentLoader,
482
- contextLoader,
483
- userAgent: command.userAgent
484
- });
485
- } catch (error) {
486
- if (error instanceof TimeoutError) handleTimeoutError(spinner, command.timeout, url);
482
+ const result = await lookupDiagnosed(url, {
483
+ documentLoader: initialLookupDocumentLoader,
484
+ contextLoader,
485
+ verifierDocumentLoader: recursiveLookupDocumentLoader,
486
+ userAgent: command.userAgent,
487
+ ...gatewayOptions
488
+ });
489
+ const current = result.object;
490
+ if (current == null) {
491
+ if (result.thrownError instanceof TimeoutError || result.failure?.error instanceof TimeoutError) handleTimeoutError(spinner, command.timeout, url);
487
492
  else {
488
493
  spinner.fail(`Failed to fetch object: ${colors.red(url)}.`);
489
- printLookupFailureHint(authLoader, error);
494
+ printError(message`${text(describeLookupFailure(result.failure, authLoader != null).message)}`);
490
495
  }
491
496
  await finalizeAndExit(1);
492
497
  return;
493
498
  }
494
- if (current == null) {
495
- spinner.fail(`Failed to fetch object: ${colors.red(url)}.`);
496
- if (authLoader == null) printError(message`It may be a private object. Try with ${optionNames(["-a", "--authorized-fetch"])}.`);
497
- await finalizeAndExit(1);
498
- return;
499
- }
500
- visited.add(url);
501
- if (current.id != null) visited.add(current.id.href);
499
+ visited.add(getVisitedKey(url));
500
+ if (current.id != null) visited.add(getVisitedKey(current.id));
502
501
  if (!command.reverse) try {
503
502
  if (totalObjects > 0) await writeSeparator(command.separator, getOutputStream());
504
503
  await writeObjectToStream(current, command.output, command.format, contextLoader, getOutputStream());
@@ -511,11 +510,18 @@ async function runLookup(command, deps = {}) {
511
510
  }
512
511
  let chain = [];
513
512
  try {
514
- chain = await collectRecursiveObjects(current, command.recurse, recurseDepth, (target) => effectiveDeps.lookupObject(target, {
515
- documentLoader: recursiveLookupDocumentLoader,
516
- contextLoader: recursiveContextLoader,
517
- userAgent: command.userAgent
518
- }), {
513
+ chain = await collectRecursiveObjects(current, command.recurse, recurseDepth, async (target) => {
514
+ const result = await lookupDiagnosed(target, {
515
+ documentLoader: recursiveLookupDocumentLoader,
516
+ contextLoader: recursiveContextLoader,
517
+ verifierDocumentLoader: recursiveLookupDocumentLoader,
518
+ userAgent: command.userAgent,
519
+ ...getPortableLookupProblem(target) === "no-gateway" ? gatewayOptions : {}
520
+ });
521
+ if (result.object != null) return result.object;
522
+ if (result.thrownError != null && !(result.failure?.error instanceof TimeoutError)) throw result.thrownError;
523
+ throw new RecursiveLookupError(target, result.failure);
524
+ }, {
519
525
  suppressErrors: command.suppressErrors,
520
526
  visited
521
527
  });
@@ -532,9 +538,13 @@ async function runLookup(command, deps = {}) {
532
538
  }
533
539
  logger.error("Failed to recursively fetch an object in chain: {error}", { error });
534
540
  if (error instanceof TimeoutError) handleTimeoutError(spinner, command.timeout);
541
+ else if (error instanceof RecursiveLookupError && error.failure?.error instanceof TimeoutError) handleTimeoutError(spinner, command.timeout, error.target);
535
542
  else if (error instanceof RecursiveLookupError) {
536
543
  spinner.fail(`Failed to recursively fetch object: ${colors.red(error.target)}.`);
544
+ const privateContextUrl = error.failure?.source === "context" ? getPrivateContextUrl(error.failure.error) : null;
537
545
  if (!command.allowPrivateAddress && isPrivateAddressTarget(error.target)) printRecursivePrivateAddressHint();
546
+ else if (privateContextUrl != null) printRecursivePrivateContextHint(privateContextUrl);
547
+ else if (error.failure != null) printError(message`${text(describeLookupFailure(error.failure, authLoader != null).message)}`);
538
548
  else if (authLoader == null) printError(message`It may be a private object. Try with ${optionNames(["-a", "--authorized-fetch"])}.`);
539
549
  } else {
540
550
  spinner.fail("Failed to recursively fetch object.");
@@ -604,7 +614,9 @@ async function runLookup(command, deps = {}) {
604
614
  const result = await lookupDiagnosed(url, {
605
615
  documentLoader: initialAuthLoader ?? initialDocumentLoader,
606
616
  contextLoader,
607
- userAgent: command.userAgent
617
+ verifierDocumentLoader: authLoader ?? documentLoader,
618
+ userAgent: command.userAgent,
619
+ ...gatewayOptions
608
620
  });
609
621
  const collection = result.object;
610
622
  if (collection == null) {
@@ -622,13 +634,15 @@ async function runLookup(command, deps = {}) {
622
634
  return;
623
635
  }
624
636
  spinner.succeed(`Fetched collection: ${colors.green(url)}.`);
625
- const diagnostics = createLookupDiagnostics(authLoader ?? documentLoader, contextLoader);
637
+ const diagnostics = createLookupDiagnostics(authLoader ?? documentLoader, contextLoader, authLoader ?? documentLoader);
626
638
  try {
627
639
  if (command.reverse) {
628
640
  const { items: traversedItems, error: traversalError } = await collectAsyncItems(effectiveDeps.traverseCollection(collection, {
629
641
  documentLoader: diagnostics.documentLoader,
630
642
  contextLoader: diagnostics.contextLoader,
631
- suppressError: command.suppressErrors
643
+ verifyPortableObject: diagnostics.verifyPortableObject,
644
+ suppressError: command.suppressErrors,
645
+ ...gatewayOptions
632
646
  }));
633
647
  for (let index = traversedItems.length - 1; index >= 0; index--) {
634
648
  const item = traversedItems[index];
@@ -657,7 +671,9 @@ async function runLookup(command, deps = {}) {
657
671
  } else for await (const item of effectiveDeps.traverseCollection(collection, {
658
672
  documentLoader: diagnostics.documentLoader,
659
673
  contextLoader: diagnostics.contextLoader,
660
- suppressError: command.suppressErrors
674
+ verifyPortableObject: diagnostics.verifyPortableObject,
675
+ suppressError: command.suppressErrors,
676
+ ...gatewayOptions
661
677
  })) {
662
678
  try {
663
679
  if (totalItems > 0) await writeSeparator(command.separator, getOutputStream());
@@ -686,7 +702,7 @@ async function runLookup(command, deps = {}) {
686
702
  url,
687
703
  error
688
704
  });
689
- const failure = diagnostics.getContextFailure() ?? diagnostics.getObjectFailure() ?? {
705
+ const failure = diagnostics.getContextFailure() ?? diagnostics.getObjectFailure() ?? diagnostics.getPortableFailure() ?? {
690
706
  error,
691
707
  source: "other"
692
708
  };
@@ -711,7 +727,9 @@ async function runLookup(command, deps = {}) {
711
727
  const objects = await Promise.all(command.urls.map((url) => lookupDiagnosed(url, {
712
728
  documentLoader: initialAuthLoader ?? initialDocumentLoader,
713
729
  contextLoader,
714
- userAgent: command.userAgent
730
+ verifierDocumentLoader: authLoader ?? documentLoader,
731
+ userAgent: command.userAgent,
732
+ ...gatewayOptions
715
733
  })));
716
734
  spinner.stop();
717
735
  let success = true;
@@ -761,4 +779,4 @@ async function runLookup(command, deps = {}) {
761
779
  if (success && command.output) spinner.succeed(`Successfully wrote output to ${colors.green(command.output)}.`);
762
780
  }
763
781
  //#endregion
764
- export { RecursiveLookupError, TimeoutError, collectAsyncItems, collectRecursiveObjects, getLookupFailureHint, getPrivateUrlCandidate, getRecursiveTargetId, isDocumentLoaderTimeoutError, runLookup, shouldPrintLookupFailureHint, shouldSuggestSuppressErrorsForLookupFailure, toDocumentLoaderTimeout, toPresentationOrder, wrapDocumentLoaderWithTimeout, writeObjectToStream, writeSeparator };
782
+ export { RecursiveLookupError, TimeoutError, collectAsyncItems, collectRecursiveObjects, getLookupFailureHint, getPrivateUrlCandidate, getRecursiveTargetId, getVisitedKey, isDocumentLoaderTimeoutError, runLookup, shouldPrintLookupFailureHint, shouldSuggestSuppressErrorsForLookupFailure, toDocumentLoaderTimeout, toPresentationOrder, wrapDocumentLoaderWithTimeout, writeObjectToStream, writeSeparator };
@@ -0,0 +1,136 @@
1
+ import "temporal-polyfill";
2
+ import { message } from "@optique/core";
3
+ import { canonicalizePortableUri, formatIri, fromCompatibleEf61Id, getGatewayHints, isGatewayUrl, parseIri } from "@fedify/vocab-runtime";
4
+ import { verifyPortableObject } from "@fedify/fedify";
5
+ //#region src/portable.ts
6
+ const PORTABLE_IRI_PATTERN = /^ap(?:\+ef61)?:/i;
7
+ /**
8
+ * Checks whether the identifier is an [FEP-ef61] portable `ap:` or
9
+ * `ap+ef61:` ID. It does not check whether the ID is well-formed.
10
+ *
11
+ * [FEP-ef61]: https://w3id.org/fep/ef61
12
+ */
13
+ function isPortableIdentifier(identifier) {
14
+ return PORTABLE_IRI_PATTERN.test(typeof identifier === "string" ? identifier : identifier.protocol);
15
+ }
16
+ /**
17
+ * Checks whether the identifier is an [FEP-ef61] portable ID or looks like
18
+ * a compatible identifier, i.e., whether `lookupObject()` looks it up
19
+ * through gateways.
20
+ *
21
+ * [FEP-ef61]: https://w3id.org/fep/ef61
22
+ */
23
+ function isPortableReference(identifier) {
24
+ if (isPortableIdentifier(identifier)) return true;
25
+ try {
26
+ return fromCompatibleEf61Id(identifier) != null;
27
+ } catch (error) {
28
+ if (error instanceof TypeError) return true;
29
+ throw error;
30
+ }
31
+ }
32
+ /**
33
+ * Tells why a portable ID cannot be looked up before asking any server.
34
+ * @param identifier The identifier to look up.
35
+ * @param gateways The gateways given with `--gateway`, if any.
36
+ * @returns `"malformed"` if the identifier is a malformed portable ID,
37
+ * `"no-gateway"` if it is a portable ID without `@gateway` location
38
+ * hints and no gateways are given, or `null` otherwise.
39
+ */
40
+ function getPortableLookupProblem(identifier, gateways) {
41
+ if (!isPortableIdentifier(identifier)) return null;
42
+ let hints;
43
+ try {
44
+ parseIri(identifier);
45
+ hints = getGatewayHints(identifier);
46
+ } catch (error) {
47
+ if (error instanceof TypeError) return "malformed";
48
+ throw error;
49
+ }
50
+ if (hints.length > 0 || gateways != null && gateways.length > 0) return null;
51
+ return "no-gateway";
52
+ }
53
+ /**
54
+ * Computes a key to compare IRIs with. An [FEP-ef61] portable ID or
55
+ * compatible identifier is turned into its canonical form, which ignores
56
+ * the scheme spelling, the percent-encoding of the DID, the gateway of
57
+ * a compatible identifier, and the query (e.g., `@gateway` location hints).
58
+ * Other IRIs are compared by their normalized URLs.
59
+ *
60
+ * [FEP-ef61]: https://w3id.org/fep/ef61
61
+ * @param iri The IRI. Pass the raw string as given if available, since
62
+ * parsing it as a `URL` may change the object it identifies.
63
+ * @returns The key, or `null` if the IRI is malformed.
64
+ */
65
+ function getIriKey(iri) {
66
+ try {
67
+ const raw = typeof iri === "string" ? iri : formatIri(iri);
68
+ if (isPortableIdentifier(raw)) return canonicalizePortableUri(raw);
69
+ const portable = fromCompatibleEf61Id(iri);
70
+ if (portable != null) return canonicalizePortableUri(formatIri(portable));
71
+ return (typeof iri === "string" ? new URL(iri) : iri).href;
72
+ } catch (error) {
73
+ if (error instanceof TypeError) return null;
74
+ throw error;
75
+ }
76
+ }
77
+ /**
78
+ * Creates the [FEP-ef61] portable object verifier that the CLI uses.
79
+ *
80
+ * The verifier always fetches documents, such as the owner of an unsecured
81
+ * collection, with the given document loader rather than the one passed by
82
+ * the caller, since `lookupObject()` passes its own loader, which may allow
83
+ * private addresses for the objects explicitly given on the command line.
84
+ * @param documentLoader The document loader for the documents that the
85
+ * verifier discovers.
86
+ * @param contextLoader The context loader to use when the caller passes
87
+ * none.
88
+ * @returns The verifier.
89
+ */
90
+ function createPortableObjectVerifier(documentLoader, contextLoader) {
91
+ return (document, options) => verifyPortableObject(document, {
92
+ ...options,
93
+ documentLoader,
94
+ contextLoader: options.contextLoader ?? contextLoader
95
+ });
96
+ }
97
+ /**
98
+ * A value parser for the `--gateway` option, which accepts an [FEP-ef61]
99
+ * gateway, i.e., an HTTP(S) origin without credentials, path, query, or
100
+ * fragment.
101
+ *
102
+ * [FEP-ef61]: https://w3id.org/fep/ef61
103
+ */
104
+ function gatewayUrl() {
105
+ return {
106
+ mode: "sync",
107
+ metavar: "GATEWAY",
108
+ placeholder: new URL("https://gateway.invalid/"),
109
+ parse(input) {
110
+ let url;
111
+ try {
112
+ url = parseIri(input);
113
+ } catch {
114
+ return {
115
+ success: false,
116
+ error: message`Invalid URL: ${input}.`
117
+ };
118
+ }
119
+ if (!isGatewayUrl(url)) return {
120
+ success: false,
121
+ error: message`Invalid gateway: ${input}. A gateway must be \
122
+ an HTTP(S) origin without credentials, path, query, or fragment, \
123
+ e.g., ${"https://example.com"}.`
124
+ };
125
+ return {
126
+ success: true,
127
+ value: url
128
+ };
129
+ },
130
+ format(value) {
131
+ return value.href;
132
+ }
133
+ };
134
+ }
135
+ //#endregion
136
+ export { createPortableObjectVerifier, gatewayUrl, getIriKey, getPortableLookupProblem, isPortableIdentifier, isPortableReference };
@@ -30,12 +30,12 @@ const relayOptions = merge(object("Relay options", {
30
30
  key: (config) => config.relay?.name ?? "Fedify Relay",
31
31
  default: "Fedify Relay"
32
32
  }),
33
- acceptFollow: bindConfig(multiple(option("-a", "--accept-follow", string({ metavar: "URI" }), { description: message`Accept follow requests from the given actor. The argument can be either an actor URI or a handle, or a wildcard (${"*"}). Can be specified multiple times. If a wildcard is specified, all follow requests will be accepted.` })), {
33
+ acceptFollow: bindConfig(multiple(option("-a", "--accept-follow", string({ metavar: "URI" }), { description: message`Accept follow requests from the given actor. The argument can be either an actor URI (including an FEP-ef61 portable ID) or a handle, or a wildcard (${"*"}). Can be specified multiple times. If a wildcard is specified, all follow requests will be accepted.` })), {
34
34
  context: configContext,
35
35
  key: (config) => config.relay?.acceptFollow ?? [],
36
36
  default: []
37
37
  }),
38
- rejectFollow: bindConfig(multiple(option("-r", "--reject-follow", string({ metavar: "URI" }), { description: message`Reject follow requests from the given actor. The argument can be either an actor URI or a handle, or a wildcard (${"*"}). Can be specified multiple times. If a wildcard is specified, all follow requests will be rejected.` })), {
38
+ rejectFollow: bindConfig(multiple(option("-r", "--reject-follow", string({ metavar: "URI" }), { description: message`Reject follow requests from the given actor. The argument can be either an actor URI (including an FEP-ef61 portable ID) or a handle, or a wildcard (${"*"}). Can be specified multiple times. If a wildcard is specified, all follow requests will be rejected.` })), {
39
39
  context: configContext,
40
40
  key: (config) => config.relay?.rejectFollow ?? [],
41
41
  default: []
package/dist/utils.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import "temporal-polyfill";
2
+ import { getIriKey } from "./portable.js";
2
3
  import process from "node:process";
3
4
  import { message } from "@optique/core";
4
5
  import { print, printError } from "@optique/run";
@@ -20,19 +21,43 @@ function formatObject(obj, colors, json) {
20
21
  if (enableColors) return highlight(formatted, { language: "json" });
21
22
  return formatted;
22
23
  }
23
- async function matchesActor(actor, actorList) {
24
+ const URI_PATTERN = /^(?:https?|ap|ap\+ef61):/i;
25
+ const HANDLE_PATTERN = /^@?[^@/\s]+@[^@/\s]+$/;
26
+ /**
27
+ * Checks whether the actor matches any of the given actor URIs, handles, or
28
+ * the wildcard (`*`). [FEP-ef61] portable actors match their `ap:` and
29
+ * `ap+ef61:` IDs and compatible identifiers regardless of `@gateway` location
30
+ * hints and the gateway of a compatible identifier.
31
+ *
32
+ * [FEP-ef61]: https://w3id.org/fep/ef61
33
+ * @param actor The actor to check.
34
+ * @param actorList The actor URIs, handles, or wildcards.
35
+ * @param getHandle The function to get the actor's handle with. It is called
36
+ * at most once, and only if the list has a handle.
37
+ * @returns `true` if the actor matches.
38
+ * @throws If the list has a handle, the actor's handle cannot be determined,
39
+ * and the actor matches nothing else in the list.
40
+ */
41
+ async function matchesActor(actor, actorList, getHandle = getActorHandle) {
24
42
  const actorUri = actor.id;
25
- let actorHandle = void 0;
26
43
  if (actorUri == null) return false;
27
- for (let uri of actorList) {
28
- if (uri == "*") return true;
29
- if (uri.startsWith("http:") || uri.startsWith("https:")) {
30
- uri = new URL(uri).href;
31
- if (uri === actorUri.href) return true;
44
+ const actorKey = getIriKey(actorUri);
45
+ let actorHandle;
46
+ let handleError;
47
+ for (const entry of actorList) {
48
+ if (entry == "*") return true;
49
+ if (URI_PATTERN.test(entry)) {
50
+ const key = getIriKey(entry);
51
+ if (key != null && key === actorKey) return true;
52
+ continue;
32
53
  }
33
- if (actorHandle == null) actorHandle = await getActorHandle(actor);
34
- if (actorHandle === uri) return true;
54
+ if (!HANDLE_PATTERN.test(entry)) continue;
55
+ actorHandle ??= getHandle(actor).then((handle) => ({ handle }), (error) => ({ error }));
56
+ const result = await actorHandle;
57
+ if ("error" in result) handleError = result;
58
+ else if (result.handle === entry) return true;
35
59
  }
60
+ if (handleError != null) throw handleError.error;
36
61
  return false;
37
62
  }
38
63
  function describeError(error) {
@@ -1,38 +1,124 @@
1
1
  import "temporal-polyfill";
2
+ import { createPortableObjectVerifier, getIriKey, getPortableLookupProblem, isPortableReference } from "../portable.js";
2
3
  import { formatObject } from "../utils.js";
3
- import { NotFoundError, getErrorMessage } from "./error.js";
4
+ import { getContextLoader, getDocumentLoader as getDocumentLoader$1 } from "../docloader.js";
5
+ import { NotFoundError, PortableActorError, UnlinkedPortableActorError, getErrorMessage } from "./error.js";
4
6
  import { convertUrlIfHandle } from "./lib.js";
5
7
  import { print } from "@optique/run";
8
+ import { formatIri } from "@fedify/vocab-runtime";
6
9
  import ora from "ora";
10
+ import { isActor, lookupObject } from "@fedify/vocab";
7
11
  import { formatMessage, message } from "@optique/core/message";
8
12
  import { lookupWebFinger } from "@fedify/webfinger";
9
13
  //#region src/webfinger/action.ts
10
- async function runWebFinger({ command: _, resources, ...options }) {
11
- await Array.fromAsync(resources.map((resource) => ({
14
+ async function runWebFinger({ resources, userAgent, allowPrivateAddresses, maxRedirection, gateways }) {
15
+ await Array.fromAsync(resources, (resource) => lookupWithSpinner({
12
16
  resource,
13
- ...options
14
- })), spinnerWrapper(lookupSingleWebFinger));
17
+ userAgent,
18
+ allowPrivateAddresses,
19
+ maxRedirection,
20
+ gateways
21
+ }));
15
22
  }
16
- async function lookupSingleWebFinger({ resource, ...options }) {
17
- return await lookupWebFinger(convertUrlIfHandle(resource), options) ?? new NotFoundError(resource).throw();
23
+ function getLookupWebFingerOptions(options) {
24
+ return {
25
+ userAgent: options.userAgent,
26
+ allowPrivateAddress: options.allowPrivateAddresses,
27
+ maxRedirection: options.maxRedirection
28
+ };
29
+ }
30
+ async function lookupSingleWebFinger(options) {
31
+ return await lookupWebFinger(convertUrlIfHandle(options.resource), getLookupWebFingerOptions(options)) ?? new NotFoundError(options.resource).throw();
32
+ }
33
+ /**
34
+ * Looks up the WebFinger address of an [FEP-ef61] portable actor by its ID,
35
+ * i.e., an `ap:` or `ap+ef61:` URI or a compatible identifier.
36
+ *
37
+ * As a portable actor's ID does not belong to any server, its WebFinger
38
+ * address is `acct:` followed by its `preferredUsername`, `@`, and the host
39
+ * of the first gateway in its `gateways`. The actor is looked up and its
40
+ * proof is verified first.
41
+ *
42
+ * [FEP-ef61]: https://w3id.org/fep/ef61
43
+ * @param options The options.
44
+ * @returns The resource descriptor, which may not link back to the actor;
45
+ * see {@link linksToActor}.
46
+ * @throws {PortableActorError} If the actor cannot be looked up, or does
47
+ * not have a WebFinger address.
48
+ * @throws {NotFoundError} If the WebFinger address is not found.
49
+ */
50
+ async function lookupPortableWebFinger(options) {
51
+ const { resource, gateways } = options;
52
+ const problem = getPortableLookupProblem(resource, gateways);
53
+ if (problem === "malformed") throw new PortableActorError(resource, "it is not a valid portable ID");
54
+ else if (problem === "no-gateway") throw new PortableActorError(resource, "it has no @gateway location hints; use the --gateway option");
55
+ const documentLoader = await getDocumentLoader$1({
56
+ userAgent: options.userAgent,
57
+ allowPrivateAddress: options.allowPrivateAddresses
58
+ });
59
+ const contextLoader = await getContextLoader({
60
+ userAgent: options.userAgent,
61
+ allowPrivateAddress: options.allowPrivateAddresses
62
+ });
63
+ const actor = await lookupObject(resource, {
64
+ documentLoader,
65
+ contextLoader,
66
+ verifyPortableObject: createPortableObjectVerifier(documentLoader, contextLoader),
67
+ userAgent: options.userAgent,
68
+ ...gateways == null || gateways.length < 1 ? {} : { gateways }
69
+ });
70
+ if (actor == null) throw new PortableActorError(resource, "it could not be looked up or verified");
71
+ else if (!isActor(actor) || actor.id == null) throw new PortableActorError(resource, "it is not an actor");
72
+ const gateway = actor.gateway;
73
+ const username = actor.preferredUsername?.toString();
74
+ if (gateway == null || username == null) throw new PortableActorError(resource, "it has no preferredUsername or gateways");
75
+ const address = `acct:${username}@${gateway.host}`;
76
+ return {
77
+ descriptor: await lookupWebFinger(address, getLookupWebFingerOptions(options)) ?? new NotFoundError(address).throw(),
78
+ address,
79
+ actorId: actor.id
80
+ };
81
+ }
82
+ /**
83
+ * Checks if the first ActivityStreams `self` link of a WebFinger response
84
+ * identifies the given portable actor, either by its portable ID or by
85
+ * a compatible identifier.
86
+ * @param descriptor The WebFinger resource descriptor.
87
+ * @param actorId The portable actor's ID.
88
+ * @returns `true` if the descriptor links back to the actor.
89
+ */
90
+ function linksToActor(descriptor, actorId) {
91
+ const link = descriptor.links?.find((l) => l.rel === "self" && l.href != null && (l.type === "application/activity+json" || l.type?.match(/application\/ld\+json;\s*profile="https:\/\/www.w3.org\/ns\/activitystreams"/) != null));
92
+ if (link?.href == null) return false;
93
+ const key = getIriKey(link.href);
94
+ return key != null && isPortableReference(link.href) && key === getIriKey(actorId);
18
95
  }
19
- function spinnerWrapper(func) {
20
- return async (...args) => {
21
- const spinner = ora({
22
- text: `Looking up WebFinger for ${args[0]}`,
23
- discardStdin: false
24
- }).start();
25
- try {
26
- const result = await func(...args);
27
- spinner.succeed(formatMessage(message`WebFinger found for ${args[0].resource}:`));
96
+ async function lookupWithSpinner(options) {
97
+ const { resource } = options;
98
+ const spinner = ora({
99
+ text: `Looking up WebFinger for ${resource}`,
100
+ discardStdin: false
101
+ }).start();
102
+ try {
103
+ if (isPortableReference(resource)) {
104
+ const { descriptor, address, actorId } = await lookupPortableWebFinger(options);
105
+ if (linksToActor(descriptor, actorId)) spinner.succeed(formatMessage(message`WebFinger found for ${resource} (${address}):`));
106
+ else spinner.fail(formatMessage(getErrorMessage(resource, new UnlinkedPortableActorError(address, formatIri(actorId)))));
28
107
  print([{
29
108
  type: "text",
30
- text: formatObject(result)
109
+ text: formatObject(descriptor)
31
110
  }]);
32
- } catch (error) {
33
- spinner.fail(formatMessage(getErrorMessage(args[0].resource, error)));
111
+ return;
34
112
  }
35
- };
113
+ const result = await lookupSingleWebFinger(options);
114
+ spinner.succeed(formatMessage(message`WebFinger found for ${resource}:`));
115
+ print([{
116
+ type: "text",
117
+ text: formatObject(result)
118
+ }]);
119
+ } catch (error) {
120
+ spinner.fail(formatMessage(getErrorMessage(resource, error)));
121
+ }
36
122
  }
37
123
  //#endregion
38
- export { runWebFinger as default, lookupSingleWebFinger };
124
+ export { runWebFinger as default, linksToActor, lookupPortableWebFinger, lookupSingleWebFinger };
@@ -1,6 +1,7 @@
1
1
  import "temporal-polyfill";
2
2
  import { configContext } from "../config.js";
3
3
  import { userAgentOption } from "../options.js";
4
+ import { gatewayUrl } from "../portable.js";
4
5
  import { argument, command, constant, flag, group, integer, merge, message, multiple, object, option, string } from "@optique/core";
5
6
  import { bindConfig } from "@optique/config";
6
7
  //#region src/webfinger/command.ts
@@ -18,13 +19,18 @@ const webFingerOptions = merge("Network options", object({
18
19
  command: constant("webfinger"),
19
20
  resources: group("Arguments", multiple(argument(string({ metavar: "RESOURCE" }), { description: message`WebFinger resource(s) to look up.` }), { min: 1 })),
20
21
  allowPrivateAddresses,
21
- maxRedirection
22
+ maxRedirection,
23
+ gateways: multiple(option("--gateway", gatewayUrl(), { description: message`An FEP-ef61 gateway to look up the given \
24
+ portable actors from, instead of their ${"@gateway"} location hints. Can \
25
+ be specified multiple times.` }))
22
26
  }), userAgentOption);
23
27
  const webFingerMetadata = {
24
28
  brief: message`Look up WebFinger resources.`,
25
29
  description: message`Look up WebFinger resources.
26
30
 
27
- The argument can be multiple.`
31
+ The argument can be multiple. For an FEP-ef61 portable actor ID, the actor \
32
+ is looked up first, and the WebFinger address derived from its first gateway \
33
+ is looked up.`
28
34
  };
29
35
  command("webfinger", webFingerOptions, webFingerMetadata);
30
36
  //#endregion
@@ -8,7 +8,7 @@ import { message } from "@optique/core";
8
8
  * @param {unknown} error The error encountered.
9
9
  * @returns {string} A descriptive error message.
10
10
  */
11
- const getErrorMessage = (resource, error) => error instanceof InvalidHandleError ? message`Invalid handle format: ${error.handle}` : error instanceof NotFoundError ? message`Resource not found: ${error.resource}` : error instanceof Error ? message`Failed to look up WebFinger for ${resource}: ${error.message}` : message`Failed to look up WebFinger for ${resource}: ${String(error)}`;
11
+ const getErrorMessage = (resource, error) => error instanceof InvalidHandleError ? message`Invalid handle format: ${error.handle}` : error instanceof PortableActorError ? message`Failed to look up the portable actor ${error.resource}, as ${error.reason}.` : error instanceof UnlinkedPortableActorError ? message`${error.address} does not link back to the portable actor ${error.actorId}, so it is not verified to be the actor's WebFinger address:` : error instanceof NotFoundError ? message`Resource not found: ${error.resource}` : error instanceof Error ? message`Failed to look up WebFinger for ${resource}: ${error.message}` : message`Failed to look up WebFinger for ${resource}: ${String(error)}`;
12
12
  /**
13
13
  * Custom error class for invalid handle formats.
14
14
  * @param {string} handle The invalid handle that caused the error.
@@ -41,5 +41,43 @@ var NotFoundError = class extends Error {
41
41
  throw this;
42
42
  }
43
43
  };
44
+ /**
45
+ * Custom error class for [FEP-ef61] portable actors whose WebFinger address
46
+ * cannot be determined.
47
+ *
48
+ * [FEP-ef61]: https://w3id.org/fep/ef61
49
+ * @param resource The portable actor's ID.
50
+ * @param reason Why the WebFinger address cannot be determined.
51
+ * @extends {Error}
52
+ */
53
+ var PortableActorError = class extends Error {
54
+ resource;
55
+ reason;
56
+ constructor(resource, reason) {
57
+ super(`Failed to look up the portable actor ${resource}, as ${reason}.`);
58
+ this.resource = resource;
59
+ this.reason = reason;
60
+ this.name = "PortableActorError";
61
+ }
62
+ };
63
+ /**
64
+ * Custom error class for a WebFinger address that does not link back to
65
+ * the [FEP-ef61] portable actor that claims it.
66
+ *
67
+ * [FEP-ef61]: https://w3id.org/fep/ef61
68
+ * @param address The WebFinger address, e.g., `acct:alice@example.com`.
69
+ * @param actorId The portable actor's ID.
70
+ * @extends {Error}
71
+ */
72
+ var UnlinkedPortableActorError = class extends Error {
73
+ address;
74
+ actorId;
75
+ constructor(address, actorId) {
76
+ super(`${address} does not link back to the portable actor ${actorId}.`);
77
+ this.address = address;
78
+ this.actorId = actorId;
79
+ this.name = "UnlinkedPortableActorError";
80
+ }
81
+ };
44
82
  //#endregion
45
- export { InvalidHandleError, NotFoundError, getErrorMessage };
83
+ export { InvalidHandleError, NotFoundError, PortableActorError, UnlinkedPortableActorError, getErrorMessage };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fedify/cli",
3
- "version": "2.4.0-dev.2247+4fe1b8c3",
3
+ "version": "2.4.0-dev.2253+f5212c33",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "README.md",
@@ -88,14 +88,14 @@
88
88
  "temporal-polyfill": "^1.0.1",
89
89
  "valibot": "^1.4.0",
90
90
  "yaml": "^2.9.0",
91
- "@fedify/fedify": "2.4.0-dev.2247+4fe1b8c3",
92
- "@fedify/init": "2.4.0-dev.2247+4fe1b8c3",
93
- "@fedify/relay": "2.4.0-dev.2247+4fe1b8c3",
94
- "@fedify/vocab-runtime": "2.4.0-dev.2247+4fe1b8c3",
95
- "@fedify/vocab-tools": "2.4.0-dev.2247+4fe1b8c3",
96
- "@fedify/webfinger": "2.4.0-dev.2247+4fe1b8c3",
97
- "@fedify/vocab": "2.4.0-dev.2247+4fe1b8c3",
98
- "@fedify/sqlite": "2.4.0-dev.2247+4fe1b8c3"
91
+ "@fedify/relay": "2.4.0-dev.2253+f5212c33",
92
+ "@fedify/fedify": "2.4.0-dev.2253+f5212c33",
93
+ "@fedify/init": "2.4.0-dev.2253+f5212c33",
94
+ "@fedify/sqlite": "2.4.0-dev.2253+f5212c33",
95
+ "@fedify/vocab": "2.4.0-dev.2253+f5212c33",
96
+ "@fedify/vocab-tools": "2.4.0-dev.2253+f5212c33",
97
+ "@fedify/vocab-runtime": "2.4.0-dev.2253+f5212c33",
98
+ "@fedify/webfinger": "2.4.0-dev.2253+f5212c33"
99
99
  },
100
100
  "devDependencies": {
101
101
  "@types/bun": "^1.2.23",