diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 0db56a180..dab02440c 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -67,6 +67,7 @@ # by Jiwon Kwon ("@fedify/relay", "@fedify/backfill"). /packages/relay/ @sij411 @dahlia /packages/backfill/ @sij411 @dahlia +/packages/interaction-controls/ @dahlia # Testing utilities and fixtures, owned by Hong Minhee with # ChanHaeng Lee contributing. diff --git a/AGENTS.md b/AGENTS.md index 35c48b786..e36c6b6f5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,6 +104,8 @@ for the complete package list. - *packages/init/*: Project initializer (`@fedify/init`) for Fedify. Separated from `@fedify/cli` to enable standalone use. + - *packages/interaction-controls/*: Interaction control helpers + (`@fedify/interaction-controls`) for Fedify. - *packages/nuxt/*: Nuxt integration (`@fedify/nuxt`) for Fedify. - *packages/create/*: Standalone CLI (`@fedify/create`) for creating new Fedify projects via `npm init @fedify`. diff --git a/CHANGES.md b/CHANGES.md index 092d389fe..9e0e506da 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -142,6 +142,19 @@ To be released. [#850]: https://github.com/fedify-dev/fedify/pull/850 [#914]: https://github.com/fedify-dev/fedify/pull/914 +### @fedify/interaction-controls + + - Added the new `@fedify/interaction-controls` package for implementing + [GoToSocial interaction controls], [FEP-044f], and [FEP-7aa9]. It provides + immutable TypeScript APIs for creating and verifying interaction requests + and authorizations, evaluating `InteractionPolicy`, recognizing bare + interactions, and formatting stable storage keys for like, reply, announce, + quote, and feature interactions. [[#811]] + +[GoToSocial interaction controls]: https://docs.gotosocial.org/en/v0.21.1/federation/interaction_controls/ +[FEP-044f]: https://w3id.org/fep/044f +[#811]: https://github.com/fedify-dev/fedify/issues/811 + ### @fedify/vocab-runtime - Added `getFe34Origin()` and `haveSameFe34Origin()` for comparing ordinary @@ -1079,7 +1092,6 @@ Released on April 28, 2026. - Added `Measure` class for representing quantities with units of measure, with `unit` and `numericalValue` properties. -[FEP-044f]: https://w3id.org/fep/044f [#452]: https://github.com/fedify-dev/fedify/issues/452 [#578]: https://github.com/fedify-dev/fedify/issues/578 [#645]: https://github.com/fedify-dev/fedify/issues/645 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d4ccf3978..c16c6d928 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -504,6 +504,8 @@ The repository is organized as a monorepo with the following packages: - *packages/init/*: Project initializer (@fedify/init) for Fedify. Separated from @fedify/cli to enable standalone use and `npm init @fedify`. + - *packages/interaction-controls/*: Interaction control helpers + (@fedify/interaction-controls) for Fedify. - *packages/koa/*: Koa integration (@fedify/koa) for Fedify. - *packages/lint/*: Linting utilities (@fedify/lint) for Fedify. - *packages/mysql/*: MySQL/MariaDB drivers (@fedify/mysql) for Fedify. diff --git a/deno.json b/deno.json index c72225a36..a844cf9f1 100644 --- a/deno.json +++ b/deno.json @@ -11,6 +11,7 @@ "./packages/fastify", "./packages/fedify", "./packages/fixture", + "./packages/interaction-controls", "./packages/fresh", "./packages/h3", "./packages/init", diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 57c16d7b3..91f67cd7c 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -146,6 +146,7 @@ const MANUAL = { { text: "Conversation backfill", link: "/manual/backfill.md" }, { text: "Object dispatcher", link: "/manual/object.md" }, { text: "Access control", link: "/manual/access-control.md" }, + { text: "Interaction controls", link: "/manual/interaction-controls.md" }, { text: "WebFinger", link: "/manual/webfinger.md" }, { text: "NodeInfo", link: "/manual/nodeinfo.md" }, { text: "URI Template", link: "/manual/uri-template.md" }, diff --git a/docs/manual/interaction-controls.md b/docs/manual/interaction-controls.md new file mode 100644 index 000000000..5f8a37f5e --- /dev/null +++ b/docs/manual/interaction-controls.md @@ -0,0 +1,328 @@ +--- +description: >- + Interaction controls let servers ask for, grant, deny, and revoke + permission for likes, replies, announces, quotes, and featured actors. +--- + +Interaction controls +==================== + +Interaction controls let an object owner publish policy and exchange explicit +request and authorization objects before another actor interacts with that +object. They are useful when an application wants automatic or manual approval +for interactions such as likes, replies, announces, quotes, and featuring an +actor in a public collection. + +Fedify provides the `@fedify/interaction-controls` package for the helper logic +around the vocabulary terms defined by [GoToSocial interaction controls], +[FEP-044f], and [FEP-7aa9]. The package does not install inbox listeners for +you. It gives you typed helpers that you can call from your own outbox, +inbox, storage, moderation, and UI code. + +[GoToSocial interaction controls]: https://docs.gotosocial.org/en/v0.21.1/federation/interaction_controls/ +[FEP-044f]: https://codeberg.org/fediverse/fep/src/branch/main/fep/044f/fep-044f.md +[FEP-7aa9]: https://codeberg.org/fediverse/fep/src/branch/main/fep/7aa9/fep-7aa9.md + + +Supported interactions +---------------------- + +The package exports one helper per interaction: + +`likeInteraction` +: Works with `LikeRequest`, `LikeAuthorization`, and `Like`. + It evaluates `canLike`. + +`replyInteraction` +: Works with `ReplyRequest`, `ReplyAuthorization`, and reply objects such as + `Note`, `Article`, `Question`, and `ChatMessage`. + It evaluates `canReply`. + +`announceInteraction` +: Works with `AnnounceRequest`, `AnnounceAuthorization`, and `Announce`. + It evaluates `canAnnounce`. + +`quoteInteraction` +: Works with `QuoteRequest`, `QuoteAuthorization`, and quote objects. + It evaluates `canQuote` and accepts both the FEP-044f `quote` property and + compatible `quoteUrl` values. + +`featureInteraction` +: Works with `FeatureRequest`, `FeatureAuthorization`, `FeaturedCollection`, + and ActivityPub actor objects. + It evaluates `canFeature` on the actor being featured. + + +Policy evaluation +----------------- + +Each helper can evaluate an object's `InteractionPolicy` and report whether +the interaction should be accepted automatically, queued for manual approval, +or denied: + +~~~~ typescript twoslash +import { likeInteraction } from "@fedify/interaction-controls"; +import { InteractionPolicy, InteractionRule, Note, PUBLIC_COLLECTION } from "@fedify/vocab"; +import type { Context } from "@fedify/fedify"; + +const context = {} as Context; +// ---cut-before--- +const target = new Note({ + id: new URL("https://example.com/notes/1"), + attribution: new URL("https://example.com/users/alice"), + interactionPolicy: new InteractionPolicy({ + canLike: new InteractionRule({ + automaticApproval: PUBLIC_COLLECTION, + }), + }), +}); + +const decision = await likeInteraction.evaluatePolicy(context, { + subject: target, + requester: new URL("https://remote.example/users/bob"), +}); +~~~~ + +A decision with `result: "automatic"` means the interaction can be accepted +without a moderation step. A decision with `result: "manual"` means the +application should store the request for review and create the authorization +only after approval. A decision with `result: "denied"` means the request +does not match the policy. + +Missing policy is handled conservatively for feature and quote requests, and +permissively for legacy-compatible interactions: + + - Like, reply, and announce helpers treat missing policy as automatic + approval for compatibility with existing ActivityPub objects. + - The quote helper treats missing `canQuote` policy as denied, because + FEP-044f quote authorization is consent-based unless the object owner + explicitly advertises automatic approval. + - The feature helper treats missing `canFeature` policy as denied, because + featuring another actor is a profile/discovery action. + + +End-to-end flow +--------------- + +The helpers cover the request, authorization, and final interaction checks. +Your application still owns delivery, persistence, and moderation: + +~~~~ mermaid +sequenceDiagram + participant Requester + participant Owner as Object owner + participant Moderator + + Requester->>Owner: createRequest() activity + Owner->>Owner: verifyRequest() and evaluatePolicy() + + alt denied + Owner-->>Requester: Reject or ignore + else manual approval + Owner->>Moderator: Queue request + Moderator-->>Owner: Approve + Owner-->>Requester: Accept with createAuthorization() + else automatic approval + Owner-->>Requester: Accept with createAuthorization() + end + + opt accepted authorization + Requester->>Owner: Interaction with authorization + Owner->>Owner: verifyAuthorization() + alt authorization valid + Owner->>Owner: Accept interaction + else invalid authorization + Owner-->>Requester: Reject or ignore + end + end +~~~~ + + +Request flow +------------ + +When your actor wants to perform an interaction, create a request activity and +send it to the object owner. The request `actor` is the actor asking for +permission, the `object` is the interaction target, and the `instrument` is the +object or collection that would perform the interaction: + +~~~~ typescript twoslash +import { likeInteraction } from "@fedify/interaction-controls"; +import { Like, Note } from "@fedify/vocab"; + +const actor = new URL("https://remote.example/users/bob"); +const target = new Note({ + id: new URL("https://example.com/notes/1"), + attribution: new URL("https://example.com/users/alice"), +}); +const like = new Like({ + id: new URL("https://remote.example/likes/1"), + actor, + object: target.id, +}); + +const request = likeInteraction.createRequest({ + id: new URL("https://remote.example/requests/1"), + actor, + object: target, + instrument: like, +}); +~~~~ + +On the receiving side, verify that the request is dereferenceable, has the +expected type, and that the instrument matches both the requester and target: + +~~~~ typescript twoslash +import type { Context } from "@fedify/fedify"; +import { likeInteraction } from "@fedify/interaction-controls"; +import { LikeRequest } from "@fedify/vocab"; + +const context = {} as Context; +const request = null as unknown as LikeRequest; +// ---cut-before--- + +const verified = await likeInteraction.verifyRequest(context, { request }); +if (!verified.verified) { + throw new Error(`Invalid interaction request: ${verified.failure.type}`); +} +~~~~ + +For `replyInteraction`, `quoteInteraction`, and `featureInteraction`, the +request `instrument` has this meaning: + + - Reply: the reply object whose `inReplyTo` target is being requested. + - Quote: the post object whose FEP-044f `quote` or compatible `quoteUrl` + target is being requested. + - Feature: the `FeaturedCollection` owned by the requester. The request + `object` is the actor being featured. + + +Authorization flow +------------------ + +After a policy decision is automatic or a moderator approves a manual request, +create an authorization object and include it with the resulting interaction: + +~~~~ typescript twoslash +import { likeInteraction } from "@fedify/interaction-controls"; +import { Like, Note } from "@fedify/vocab"; + +const owner = new URL("https://example.com/users/alice"); +const target = new Note({ + id: new URL("https://example.com/notes/1"), + attribution: owner, +}); +const like = new Like({ + id: new URL("https://remote.example/likes/1"), + actor: new URL("https://remote.example/users/bob"), + object: target.id, +}); + +const authorization = likeInteraction.createAuthorization({ + id: new URL("https://example.com/authorizations/1"), + attributedTo: owner, + interactingObject: like, + interactionTarget: target, +}); +~~~~ + +When a signed interaction arrives with an authorization, verify that the +authorization still refers to the same interaction object and target, and that +the grant came from the target owner. If you pass an embedded authorization +object instead of a URL, provide `verifyAuthenticity` so your HTTP signature, +object proof, or transport-level trust decision is part of verification: + +~~~~ typescript twoslash +import type { Context } from "@fedify/fedify"; +import { likeInteraction } from "@fedify/interaction-controls"; +import { Like, LikeAuthorization, Note } from "@fedify/vocab"; + +const context = {} as Context; +const authorization = null as unknown as LikeAuthorization; +const like = null as unknown as Like; +const target = null as unknown as Note; +const verifyEmbeddedAuthorization = async ( + authorization: LikeAuthorization, +) => authorization.id?.origin === "https://example.com"; +// ---cut-before--- + +const verified = await likeInteraction.verifyAuthorization(context, { + authorization, + interactingObject: like, + interactionTarget: target, + attributedTo: target.attributionId ?? undefined, + verifyAuthenticity: verifyEmbeddedAuthorization, +}); +if (!verified.verified) { + throw new Error(`Invalid authorization: ${verified.failure.type}`); +} +~~~~ + +You can also create `Accept`, `Reject`, and revocation activities from the same +helper. Store authorization IDs with the interaction object so that later +revocation checks can reject stale approvals. + + +Recognizing unrequested interactions +------------------------------------ + +Some remote servers will send a bare `Like`, reply, announce, or quote without +first sending a request. The helpers expose `recognizeImpolite()` for +best-effort detection: + +~~~~ typescript twoslash +import { likeInteraction } from "@fedify/interaction-controls"; +import { Like } from "@fedify/vocab"; + +const activity = new Like({ + id: new URL("https://remote.example/likes/1"), + actor: new URL("https://remote.example/users/bob"), + object: new URL("https://example.com/notes/1"), +}); + +const recognized = likeInteraction.recognizeImpolite(activity); +~~~~ + +This method is synchronous and only recognizes objects whose interaction target +can be read without dereferencing. In particular, `Create` activities wrapping +reply or quote objects are not recognized by this method; unwrap and verify the +created object in your inbox listener before passing it to the helper. + + +Stable storage keys +------------------- + +Each helper provides stable keys for persistence: + +~~~~ typescript twoslash +import { + formatAuthorizationKey, + formatInteractionKey, + likeInteraction, +} from "@fedify/interaction-controls"; +import { Like, LikeAuthorization, Note } from "@fedify/vocab"; + +const actor = new URL("https://remote.example/users/bob"); +const target = new URL("https://example.com/notes/1"); +const like = new Like({ + id: new URL("https://remote.example/likes/1"), + actor, + object: target, +}); +const authorization = new URL("https://example.com/authorizations/1"); + +const interactionKey = formatInteractionKey(likeInteraction.getInteractionKey({ + requester: actor, + interactingObject: like, + interactionTarget: new Note({ id: target }), +})); +const authorizationKey = formatAuthorizationKey( + likeInteraction.getAuthorizationKey({ + authorization: new LikeAuthorization({ id: authorization }), + }), +); +~~~~ + +The keys are plain strings. Use them with your existing database or key–value +store to record pending requests, accepted authorizations, and revoked +authorizations. diff --git a/docs/package.json b/docs/package.json index 45865af46..ac04c4302 100644 --- a/docs/package.json +++ b/docs/package.json @@ -13,6 +13,7 @@ "@fedify/fedify": "workspace:^", "@fedify/h3": "workspace:^", "@fedify/hono": "workspace:^", + "@fedify/interaction-controls": "workspace:^", "@fedify/koa": "workspace:^", "@fedify/lint": "workspace:^", "@fedify/mysql": "workspace:^", @@ -76,7 +77,7 @@ }, "scripts": { "dev": "cd ../ && pnpm run --filter '@fedify/*' -r build:self && cd docs/ && vitepress dev --host", - "build": "cd ../ && pnpm run --filter '@fedify/*' -r build:self && cd docs/ && NODE_OPTIONS='--max-old-space-size=8192' vitepress build", + "build": "cd ../ && pnpm run --filter '@fedify/*' -r build:self && cd docs/ && NODE_OPTIONS='--max-old-space-size=12288' vitepress build", "preview": "cd ../ && pnpm run --filter '@fedify/*' -r build:self && cd docs/ && vitepress preview" } } diff --git a/packages/fedify/README.md b/packages/fedify/README.md index 9fe4e09b3..56783cf6f 100644 --- a/packages/fedify/README.md +++ b/packages/fedify/README.md @@ -95,42 +95,43 @@ which provides the core functionality of the framework. Other packages provide integrations with various web frameworks, database drivers, and other features. Here is the list of packages: -| Package | JSR | npm | Description | -| ------------------------------------------------- | -------------------------------- | -------------------------------- | ---------------------------------------- | -| [@fedify/fedify](/packages/fedify/) | [JSR] | [npm] | The core framework of Fedify | -| [@fedify/cli](/packages/cli/) | [JSR][jsr:@fedify/cli] | [npm][npm:@fedify/cli] | CLI toolchain for testing and debugging | -| [@fedify/create](/packages/create/) | | [npm][npm:@fedify/create] | Create a new Fedify project | -| [@fedify/amqp](/packages/amqp/) | [JSR][jsr:@fedify/amqp] | [npm][npm:@fedify/amqp] | AMQP/RabbitMQ driver | -| [@fedify/astro](/packages/astro/) | [JSR][jsr:@fedify/astro] | [npm][npm:@fedify/astro] | Astro integration | -| [@fedify/backfill](/packages/backfill/) | [JSR][jsr:@fedify/backfill] | [npm][npm:@fedify/backfill] | ActivityPub backfill support | -| [@fedify/cfworkers](/packages/cfworkers/) | [JSR][jsr:@fedify/cfworkers] | [npm][npm:@fedify/cfworkers] | Cloudflare Workers integration | -| [@fedify/debugger](/packages/debugger/) | [JSR][jsr:@fedify/debugger] | [npm][npm:@fedify/debugger] | Embedded ActivityPub debug dashboard | -| [@fedify/denokv](/packages/denokv/) | [JSR][jsr:@fedify/denokv] | | Deno KV integration | -| [@fedify/elysia](/packages/elysia/) | | [npm][npm:@fedify/elysia] | Elysia integration | -| [@fedify/express](/packages/express/) | [JSR][jsr:@fedify/express] | [npm][npm:@fedify/express] | Express integration | -| [@fedify/fastify](/packages/fastify/) | [JSR][jsr:@fedify/fastify] | [npm][npm:@fedify/fastify] | Fastify integration | -| [@fedify/fresh](/packages/fresh/) | [JSR][jsr:@fedify/fresh] | | Fresh integration | -| [@fedify/h3](/packages/h3/) | [JSR][jsr:@fedify/h3] | [npm][npm:@fedify/h3] | H3 integration | -| [@fedify/hono](/packages/hono/) | [JSR][jsr:@fedify/hono] | [npm][npm:@fedify/hono] | Hono integration | -| [@fedify/init](/packages/init/) | [JSR][jsr:@fedify/init] | [npm][npm:@fedify/init] | Project initializer for Fedify | -| [@fedify/koa](/packages/koa/) | [JSR][jsr:@fedify/koa] | [npm][npm:@fedify/koa] | Koa integration | -| [@fedify/lint](/packages/lint/) | [JSR][jsr:@fedify/lint] | [npm][npm:@fedify/lint] | Linting utilities | -| [@fedify/nestjs](/packages/nestjs/) | | [npm][npm:@fedify/nestjs] | NestJS integration | -| [@fedify/next](/packages/next/) | | [npm][npm:@fedify/next] | Next.js integration | -| [@fedify/nuxt](/packages/nuxt/) | [JSR][jsr:@fedify/nuxt] | [npm][npm:@fedify/nuxt] | Nuxt integration | -| [@fedify/mysql](/packages/mysql/) | [JSR][jsr:@fedify/mysql] | [npm][npm:@fedify/mysql] | MySQL/MariaDB driver | -| [@fedify/postgres](/packages/postgres/) | [JSR][jsr:@fedify/postgres] | [npm][npm:@fedify/postgres] | PostgreSQL driver | -| [@fedify/redis](/packages/redis/) | [JSR][jsr:@fedify/redis] | [npm][npm:@fedify/redis] | Redis driver | -| [@fedify/relay](/packages/relay/) | [JSR][jsr:@fedify/relay] | [npm][npm:@fedify/relay] | ActivityPub relay support | -| [@fedify/solidstart](/packages/solidstart/) | [JSR][jsr:@fedify/solidstart] | [npm][npm:@fedify/solidstart] | SolidStart integration | -| [@fedify/sqlite](/packages/sqlite/) | [JSR][jsr:@fedify/sqlite] | [npm][npm:@fedify/sqlite] | SQLite driver | -| [@fedify/sveltekit](/packages/sveltekit/) | [JSR][jsr:@fedify/sveltekit] | [npm][npm:@fedify/sveltekit] | SvelteKit integration | -| [@fedify/testing](/packages/testing/) | [JSR][jsr:@fedify/testing] | [npm][npm:@fedify/testing] | Testing utilities | -| [@fedify/uri-template](/packages/uri-template/) | [JSR][jsr:@fedify/uri-template] | [npm][npm:@fedify/uri-template] | RFC 6570 URI Template library | -| [@fedify/vocab](/packages/vocab/) | [JSR][jsr:@fedify/vocab] | [npm][npm:@fedify/vocab] | Activity Vocabulary library | -| [@fedify/vocab-runtime](/packages/vocab-runtime/) | [JSR][jsr:@fedify/vocab-runtime] | [npm][npm:@fedify/vocab-runtime] | Runtime library for code-generated vocab | -| [@fedify/vocab-tools](/packages/vocab-tools/) | [JSR][jsr:@fedify/vocab-tools] | [npm][npm:@fedify/vocab-tools] | Code generation tools for Activity Vocab | -| [@fedify/webfinger](/packages/webfinger/) | [JSR][jsr:@fedify/webfinger] | [npm][npm:@fedify/webfinger] | WebFinger client library for ActivityPub | +| Package | JSR | npm | Description | +| --------------------------------------------------------------- | --------------------------------------- | --------------------------------------- | ---------------------------------------- | +| [@fedify/fedify](/packages/fedify/) | [JSR] | [npm] | The core framework of Fedify | +| [@fedify/cli](/packages/cli/) | [JSR][jsr:@fedify/cli] | [npm][npm:@fedify/cli] | CLI toolchain for testing and debugging | +| [@fedify/create](/packages/create/) | | [npm][npm:@fedify/create] | Create a new Fedify project | +| [@fedify/amqp](/packages/amqp/) | [JSR][jsr:@fedify/amqp] | [npm][npm:@fedify/amqp] | AMQP/RabbitMQ driver | +| [@fedify/astro](/packages/astro/) | [JSR][jsr:@fedify/astro] | [npm][npm:@fedify/astro] | Astro integration | +| [@fedify/backfill](/packages/backfill/) | [JSR][jsr:@fedify/backfill] | [npm][npm:@fedify/backfill] | ActivityPub backfill support | +| [@fedify/cfworkers](/packages/cfworkers/) | [JSR][jsr:@fedify/cfworkers] | [npm][npm:@fedify/cfworkers] | Cloudflare Workers integration | +| [@fedify/debugger](/packages/debugger/) | [JSR][jsr:@fedify/debugger] | [npm][npm:@fedify/debugger] | Embedded ActivityPub debug dashboard | +| [@fedify/denokv](/packages/denokv/) | [JSR][jsr:@fedify/denokv] | | Deno KV integration | +| [@fedify/elysia](/packages/elysia/) | | [npm][npm:@fedify/elysia] | Elysia integration | +| [@fedify/express](/packages/express/) | [JSR][jsr:@fedify/express] | [npm][npm:@fedify/express] | Express integration | +| [@fedify/fastify](/packages/fastify/) | [JSR][jsr:@fedify/fastify] | [npm][npm:@fedify/fastify] | Fastify integration | +| [@fedify/fresh](/packages/fresh/) | [JSR][jsr:@fedify/fresh] | | Fresh integration | +| [@fedify/h3](/packages/h3/) | [JSR][jsr:@fedify/h3] | [npm][npm:@fedify/h3] | H3 integration | +| [@fedify/hono](/packages/hono/) | [JSR][jsr:@fedify/hono] | [npm][npm:@fedify/hono] | Hono integration | +| [@fedify/init](/packages/init/) | [JSR][jsr:@fedify/init] | [npm][npm:@fedify/init] | Project initializer for Fedify | +| [@fedify/interaction-controls](/packages/interaction-controls/) | [JSR][jsr:@fedify/interaction-controls] | [npm][npm:@fedify/interaction-controls] | Interaction control helpers | +| [@fedify/koa](/packages/koa/) | [JSR][jsr:@fedify/koa] | [npm][npm:@fedify/koa] | Koa integration | +| [@fedify/lint](/packages/lint/) | [JSR][jsr:@fedify/lint] | [npm][npm:@fedify/lint] | Linting utilities | +| [@fedify/nestjs](/packages/nestjs/) | | [npm][npm:@fedify/nestjs] | NestJS integration | +| [@fedify/next](/packages/next/) | | [npm][npm:@fedify/next] | Next.js integration | +| [@fedify/nuxt](/packages/nuxt/) | [JSR][jsr:@fedify/nuxt] | [npm][npm:@fedify/nuxt] | Nuxt integration | +| [@fedify/mysql](/packages/mysql/) | [JSR][jsr:@fedify/mysql] | [npm][npm:@fedify/mysql] | MySQL/MariaDB driver | +| [@fedify/postgres](/packages/postgres/) | [JSR][jsr:@fedify/postgres] | [npm][npm:@fedify/postgres] | PostgreSQL driver | +| [@fedify/redis](/packages/redis/) | [JSR][jsr:@fedify/redis] | [npm][npm:@fedify/redis] | Redis driver | +| [@fedify/relay](/packages/relay/) | [JSR][jsr:@fedify/relay] | [npm][npm:@fedify/relay] | ActivityPub relay support | +| [@fedify/solidstart](/packages/solidstart/) | [JSR][jsr:@fedify/solidstart] | [npm][npm:@fedify/solidstart] | SolidStart integration | +| [@fedify/sqlite](/packages/sqlite/) | [JSR][jsr:@fedify/sqlite] | [npm][npm:@fedify/sqlite] | SQLite driver | +| [@fedify/sveltekit](/packages/sveltekit/) | [JSR][jsr:@fedify/sveltekit] | [npm][npm:@fedify/sveltekit] | SvelteKit integration | +| [@fedify/testing](/packages/testing/) | [JSR][jsr:@fedify/testing] | [npm][npm:@fedify/testing] | Testing utilities | +| [@fedify/uri-template](/packages/uri-template/) | [JSR][jsr:@fedify/uri-template] | [npm][npm:@fedify/uri-template] | RFC 6570 URI Template library | +| [@fedify/vocab](/packages/vocab/) | [JSR][jsr:@fedify/vocab] | [npm][npm:@fedify/vocab] | Activity Vocabulary library | +| [@fedify/vocab-runtime](/packages/vocab-runtime/) | [JSR][jsr:@fedify/vocab-runtime] | [npm][npm:@fedify/vocab-runtime] | Runtime library for code-generated vocab | +| [@fedify/vocab-tools](/packages/vocab-tools/) | [JSR][jsr:@fedify/vocab-tools] | [npm][npm:@fedify/vocab-tools] | Code generation tools for Activity Vocab | +| [@fedify/webfinger](/packages/webfinger/) | [JSR][jsr:@fedify/webfinger] | [npm][npm:@fedify/webfinger] | WebFinger client library for ActivityPub | [jsr:@fedify/cli]: https://jsr.io/@fedify/cli [npm:@fedify/cli]: https://www.npmjs.com/package/@fedify/cli @@ -158,6 +159,8 @@ Here is the list of packages: [npm:@fedify/hono]: https://www.npmjs.com/package/@fedify/hono [jsr:@fedify/init]: https://jsr.io/@fedify/init [npm:@fedify/init]: https://www.npmjs.com/package/@fedify/init +[jsr:@fedify/interaction-controls]: https://jsr.io/@fedify/interaction-controls +[npm:@fedify/interaction-controls]: https://www.npmjs.com/package/@fedify/interaction-controls [jsr:@fedify/koa]: https://jsr.io/@fedify/koa [npm:@fedify/koa]: https://www.npmjs.com/package/@fedify/koa [jsr:@fedify/lint]: https://jsr.io/@fedify/lint diff --git a/packages/interaction-controls/.gitignore b/packages/interaction-controls/.gitignore new file mode 100644 index 000000000..1eae0cf67 --- /dev/null +++ b/packages/interaction-controls/.gitignore @@ -0,0 +1,2 @@ +dist/ +node_modules/ diff --git a/packages/interaction-controls/README.md b/packages/interaction-controls/README.md new file mode 100644 index 000000000..1516dbb3a --- /dev/null +++ b/packages/interaction-controls/README.md @@ -0,0 +1,13 @@ +@fedify/interaction-controls +============================ + +Interaction control helpers for Fedify applications. + +Use this package when an application needs to evaluate interaction policies, +request approval, issue authorizations, or verify authorizations for likes, +replies, announces, quotes, and featured actors. + +See the [interaction controls manual] for usage examples and the full request +and authorization flow. + +[interaction controls manual]: https://fedify.dev/manual/interaction-controls diff --git a/packages/interaction-controls/deno.json b/packages/interaction-controls/deno.json new file mode 100644 index 000000000..33f63b5d4 --- /dev/null +++ b/packages/interaction-controls/deno.json @@ -0,0 +1,22 @@ +{ + "name": "@fedify/interaction-controls", + "version": "2.4.0", + "license": "MIT", + "exports": { + ".": "./src/mod.ts" + }, + "exclude": [ + "dist/", + "node_modules/" + ], + "publish": { + "exclude": [ + "**/*.test.ts", + "tsdown.config.ts" + ] + }, + "tasks": { + "check": "deno fmt --check && deno lint && deno check src/**/*.ts", + "test": "deno test" + } +} diff --git a/packages/interaction-controls/package.json b/packages/interaction-controls/package.json new file mode 100644 index 000000000..b7eb02b79 --- /dev/null +++ b/packages/interaction-controls/package.json @@ -0,0 +1,73 @@ +{ + "name": "@fedify/interaction-controls", + "version": "2.4.0", + "description": "Interaction control helpers for Fedify", + "keywords": [ + "ActivityPub", + "Fedify", + "fediverse", + "interaction controls" + ], + "license": "MIT", + "author": { + "name": "Hong Minhee", + "email": "hong@minhee.org", + "url": "https://hongminhee.org/" + }, + "homepage": "https://fedify.dev/", + "repository": { + "type": "git", + "url": "git+https://github.com/fedify-dev/fedify.git", + "directory": "packages/interaction-controls" + }, + "bugs": { + "url": "https://github.com/fedify-dev/fedify/issues" + }, + "funding": "https://opencollective.com/fedify", + "type": "module", + "main": "./dist/mod.cjs", + "module": "./dist/mod.js", + "types": "./dist/mod.d.ts", + "exports": { + ".": { + "types": { + "import": "./dist/mod.d.ts", + "require": "./dist/mod.d.cts", + "default": "./dist/mod.d.ts" + }, + "import": "./dist/mod.js", + "require": "./dist/mod.cjs", + "default": "./dist/mod.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "dist/", + "package.json", + "README.md" + ], + "peerDependencies": { + "@fedify/fedify": "workspace:^" + }, + "dependencies": { + "@fedify/vocab": "workspace:*", + "@fedify/vocab-runtime": "workspace:^", + "temporal-polyfill": "catalog:" + }, + "devDependencies": { + "tsdown": "catalog:", + "typescript": "catalog:" + }, + "scripts": { + "build:self": "tsdown", + "build": "pnpm --filter @fedify/interaction-controls... run build:self", + "prepack": "pnpm build", + "prepublish": "pnpm build", + "pretest": "pnpm build", + "test": "node --experimental-transform-types --test", + "pretest:bun": "pnpm build", + "test:bun": "bun test --timeout 15000", + "test:deno": "deno task test", + "test-all": "pnpm build && node --experimental-transform-types --test && bun test --timeout 15000 && deno task test" + } +} diff --git a/packages/interaction-controls/src/announce.test.ts b/packages/interaction-controls/src/announce.test.ts new file mode 100644 index 000000000..c79c45850 --- /dev/null +++ b/packages/interaction-controls/src/announce.test.ts @@ -0,0 +1,151 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import type { Context } from "@fedify/fedify"; +import { + Announce, + AnnounceAuthorization, + AnnounceRequest, + InteractionPolicy, + InteractionRule, + Note, + PUBLIC_COLLECTION, +} from "@fedify/vocab"; +import { announceInteraction } from "./mod.ts"; + +const context = {} as Context; +const actor = new URL("https://example.com/users/alice"); +const author = new URL("https://example.net/users/bob"); +const targetId = new URL("https://example.net/notes/1"); +const announceId = new URL("https://example.com/announces/1"); +const authorizationId = new URL("https://example.net/authorizations/3"); +const verifyAuthenticity = () => true; + +test("announceInteraction creates and verifies requests", async () => { + const target = new Note({ id: targetId, attribution: author }); + const announce = new Announce({ + id: announceId, + actor, + object: targetId, + }); + const request = announceInteraction.createRequest({ + id: new URL("https://example.com/requests/3"), + actor, + object: target, + instrument: announce, + }); + + assert.ok(request instanceof AnnounceRequest); + + const result = await announceInteraction.verifyRequest(context, { request }); + + assert.equal(result.verified, true); + assert.equal(result.requester.href, actor.href); + assert.equal(result.interactingObjectId.href, announceId.href); + assert.equal(result.interactionTargetId.href, targetId.href); +}); + +test("announceInteraction denies mismatched targets", async () => { + const target = new Note({ id: targetId, attribution: author }); + const announce = new Announce({ + id: announceId, + actor, + object: new URL("https://example.net/notes/elsewhere"), + }); + const request = new AnnounceRequest({ + id: new URL("https://example.com/requests/3"), + actor, + object: target, + instrument: announce, + }); + + const result = await announceInteraction.verifyRequest(context, { request }); + + assert.equal(result.verified, false); + assert.equal(result.failure.type, "objectMismatch"); +}); + +test("announceInteraction denies mismatched requesters", async () => { + const target = new Note({ id: targetId, attribution: author }); + const announce = new Announce({ + id: announceId, + actor: new URL("https://example.org/users/carol"), + object: targetId, + }); + const request = new AnnounceRequest({ + id: new URL("https://example.com/requests/3"), + actor, + object: target, + instrument: announce, + }); + + const result = await announceInteraction.verifyRequest(context, { request }); + + assert.equal(result.verified, false); + assert.equal(result.failure.type, "requesterMismatch"); +}); + +test("announceInteraction evaluates canAnnounce rules", async () => { + const target = new Note({ + id: targetId, + attribution: author, + interactionPolicy: new InteractionPolicy({ + canAnnounce: new InteractionRule({ + automaticApproval: PUBLIC_COLLECTION, + }), + }), + }); + + assert.deepEqual( + await announceInteraction.evaluatePolicy(context, { + subject: target, + requester: actor, + }), + { + result: "automatic", + reason: { type: "public" }, + }, + ); +}); + +test("announceInteraction creates and verifies authorizations", async () => { + const target = new Note({ id: targetId, attribution: author }); + const announce = new Announce({ + id: announceId, + actor, + object: targetId, + }); + const authorization = announceInteraction.createAuthorization({ + id: authorizationId, + attributedTo: author, + interactingObject: announce, + interactionTarget: target, + }); + + assert.ok(authorization instanceof AnnounceAuthorization); + + const result = await announceInteraction.verifyAuthorization(context, { + authorization, + interactingObject: announce, + interactionTarget: target, + attributedTo: author, + verifyAuthenticity, + }); + + assert.equal(result.verified, true); +}); + +test("announceInteraction recognizes bare announce activities", () => { + const announce = new Announce({ + id: announceId, + actor, + object: targetId, + }); + + const recognized = announceInteraction.recognizeImpolite(announce); + + assert.ok(recognized); + assert.equal(recognized.requester.href, actor.href); + assert.equal(recognized.interactingObjectId.href, announceId.href); + assert.equal(recognized.interactionTargetId.href, targetId.href); + assert.equal(recognized.evidence.type, "activity"); +}); diff --git a/packages/interaction-controls/src/announce.ts b/packages/interaction-controls/src/announce.ts new file mode 100644 index 000000000..b3be07623 --- /dev/null +++ b/packages/interaction-controls/src/announce.ts @@ -0,0 +1,62 @@ +import { + Announce, + AnnounceAuthorization, + AnnounceRequest, + type Object as ASObject, +} from "@fedify/vocab"; +import { + createInteractionControl, + getRequiredId, + idsEqual, + recognized, +} from "./control.ts"; +import type { InteractionControl } from "./types.ts"; + +export const announceInteraction: InteractionControl< + AnnounceRequest, + AnnounceAuthorization, + Announce, + ASObject, + Announce +> = createInteractionControl({ + name: "announce", + policyProperty: "canAnnounce", + requestClass: AnnounceRequest, + authorizationClass: AnnounceAuthorization, + getInteractingObject: (request, options) => + request.getInstrument(options) as Promise, + isInteractingObject: (object): object is Announce => + object instanceof Announce, + interactingObjectTypes: [Announce.typeId], + getInteractionTarget: (request, options) => + request.getObject(options) as Promise, + validateRequest: (_request, announce, target, requester) => { + const targetId = getRequiredId(target, "interactionTarget"); + if (!idsEqual(announce.objectId, targetId)) { + return { + type: "objectMismatch", + expected: targetId, + actual: announce.objectId ?? undefined, + }; + } + if (!idsEqual(announce.actorId, requester)) { + return { + type: "requesterMismatch", + expected: requester, + actual: announce.actorId ?? undefined, + }; + } + return null; + }, + getSelfActor: (subject) => subject.attributionId, + defaultMissingPolicy: "automatic", + recognizeImpolite: (source) => + recognized({ + requester: source.actorId, + interactingObject: source, + interactionTarget: undefined, + interactionTargetId: source.objectId, + source, + evidence: { type: "activity", activityType: Announce.typeId }, + }), +}); diff --git a/packages/interaction-controls/src/control.ts b/packages/interaction-controls/src/control.ts new file mode 100644 index 000000000..fd7f432c3 --- /dev/null +++ b/packages/interaction-controls/src/control.ts @@ -0,0 +1,1115 @@ +import type { Context } from "@fedify/fedify"; +import { type DocumentLoader, getFe34Origin } from "@fedify/vocab-runtime"; +import { + Accept, + type Activity, + Delete, + type InteractionRule, + type Object as ASObject, + PUBLIC_COLLECTION, + Reject, +} from "@fedify/vocab"; +import type { + ImpoliteInteractionEvidence, + InteractionAcceptOptions, + InteractionAuthorizationVerification, + InteractionAuthorizationVerificationOptions, + InteractionControl, + InteractionName, + InteractionPolicyDecision, + InteractionPolicyEvaluationOptions, + InteractionPolicyMatchReason, + InteractionPolicyProperty, + InteractionRejectOptions, + InteractionRequestVerification, + InteractionRequestVerificationOptions, + MatchesApprovalCollection, + RecognizedImpoliteInteraction, +} from "./types.ts"; + +type VocabConstructor = { + readonly typeId: URL; + new ( + values: Record, + options?: Record, + ): T; + fromJsonLd( + json: unknown, + options?: { + documentLoader?: DocumentLoader; + contextLoader?: DocumentLoader; + baseUrl?: URL; + }, + ): Promise; +}; + +interface ControlConfig< + TRequest extends Activity, + TAuthorization extends ASObject, + TInteracting extends ASObject, + TTarget extends ASObject, + TImpoliteSource extends ASObject, +> { + readonly name: InteractionName; + readonly policyProperty: InteractionPolicyProperty; + readonly requestClass: VocabConstructor; + readonly authorizationClass: VocabConstructor; + readonly getInteractingObject: ( + request: TRequest, + options: DereferenceOptions, + ) => Promise; + readonly isInteractingObject?: (object: ASObject) => object is TInteracting; + readonly interactingObjectTypes?: readonly URL[]; + readonly getInteractionTarget: ( + request: TRequest, + options: DereferenceOptions, + ) => Promise; + readonly isInteractionTarget?: (object: ASObject) => object is TTarget; + readonly interactionTargetTypes?: readonly URL[]; + readonly getRequester?: ( + request: TRequest, + interactingObject: TInteracting, + interactionTarget: TTarget, + ) => URL | null; + readonly validateRequest: ( + request: TRequest, + interactingObject: TInteracting, + interactionTarget: TTarget, + requester: URL, + ) => RequestValidationFailure | null; + readonly authorizationAttribution?: "required" | "optional"; + readonly getSelfActor: (subject: TTarget) => URL | null; + readonly defaultMissingPolicy: "automatic" | "denied"; + readonly recognizeImpolite: ( + source: TImpoliteSource, + ) => + | RecognizedImpoliteInteraction< + TInteracting, + TTarget, + TImpoliteSource + > + | null; + readonly getImplicitAutomaticActors?: ( + subject: TTarget, + options: DereferenceOptions, + ) => AsyncIterable | Iterable; +} + +export interface DereferenceOptions { + readonly documentLoader?: DocumentLoader; + readonly contextLoader?: DocumentLoader; + readonly suppressError?: boolean; +} + +type RequestValidationFailure = + | { + readonly type: "objectMismatch" | "instrumentMismatch"; + readonly expected: URL; + readonly actual?: URL; + } + | { + readonly type: "requesterMismatch"; + readonly expected: URL; + readonly actual?: URL; + }; + +type RuleMatchResult = + | { + readonly result: "matched"; + readonly reason: InteractionPolicyMatchReason; + } + | { + readonly result: "unverifiableCollection"; + readonly collection: URL; + } + | null; + +export function createInteractionControl< + TRequest extends Activity, + TAuthorization extends ASObject, + TInteracting extends ASObject, + TTarget extends ASObject, + TImpoliteSource extends ASObject, +>( + config: ControlConfig< + TRequest, + TAuthorization, + TInteracting, + TTarget, + TImpoliteSource + >, +): InteractionControl< + TRequest, + TAuthorization, + TInteracting, + TTarget, + TImpoliteSource +> { + return { + name: config.name, + policyProperty: config.policyProperty, + requestTypeId: config.requestClass.typeId, + authorizationTypeId: config.authorizationClass.typeId, + verifyRequest: (context, options) => + verifyRequest(context, options, config), + evaluatePolicy: (context, options) => + evaluatePolicy(context, options, config), + createRequest: (options) => + new config.requestClass({ + id: options.id, + actor: options.actor, + object: options.object, + instrument: options.instrument, + ...audience(options), + }), + createAuthorization: (options) => + new config.authorizationClass({ + id: options.id, + attribution: options.attributedTo, + interactingObject: getRequiredId( + options.interactingObject, + "interactingObject", + ), + interactionTarget: getRequiredId( + options.interactionTarget, + "interactionTarget", + ), + }), + verifyAuthorization: (context, options) => + verifyAuthorization(context, options, config), + createAccept: (options) => createAccept(options), + createReject: (options) => createReject(options), + createRevocation: (options) => + new Delete({ + id: options.id, + actor: options.actor, + object: getRequiredId(options.authorization, "authorization"), + ...audience(options), + }), + recognizeImpolite: config.recognizeImpolite, + getInteractionKey: (input) => ({ + interaction: config.name, + requester: input.requester, + interactingObjectId: getRequiredId( + input.interactingObject, + "interactingObject", + ), + interactionTargetId: getRequiredId( + input.interactionTarget, + "interactionTarget", + ), + }), + getAuthorizationKey: (input) => ({ + interaction: config.name, + authorizationId: getRequiredId(input.authorization, "authorization"), + }), + }; +} + +function audience( + options: { + readonly to?: URL | readonly URL[]; + readonly cc?: URL | readonly URL[]; + }, +): { + readonly to?: URL; + readonly tos?: URL[]; + readonly cc?: URL; + readonly ccs?: URL[]; +} { + const to = options.to; + const cc = options.cc; + return { + ...(to == null + ? {} + : Array.isArray(to) + ? { tos: to as URL[] } + : { to: to as URL }), + ...(cc == null + ? {} + : Array.isArray(cc) + ? { ccs: cc as URL[] } + : { cc: cc as URL }), + }; +} + +export function getRequiredId(value: ASObject | URL, name: string): URL { + if (value instanceof URL) return value; + if (value.id == null) { + throw new TypeError(`The ${name} must have an id.`); + } + return value.id; +} + +function getId(value: ASObject | URL): URL | null { + return value instanceof URL ? value : value.id; +} + +export function idsEqual( + left: URL | null | undefined, + right: URL | null | undefined, +): boolean { + return left != null && right != null && left.href === right.href; +} + +export function getTypeId(object: ASObject): URL { + return (object.constructor as unknown as { readonly typeId: URL }).typeId; +} + +async function verifyRequest< + TRequest extends Activity, + TAuthorization extends ASObject, + TInteracting extends ASObject, + TTarget extends ASObject, + TImpoliteSource extends ASObject, + TContextData, +>( + context: Context, + options: InteractionRequestVerificationOptions, + config: ControlConfig< + TRequest, + TAuthorization, + TInteracting, + TTarget, + TImpoliteSource + >, +): Promise> { + const documentLoader = options.documentLoader ?? context.documentLoader; + const expectedRequestId = options.request instanceof URL + ? options.request + : null; + const requestResult = await materialize( + options.request, + config.requestClass, + documentLoader, + ); + if (!requestResult.ok) { + return { + verified: false, + failure: requestResult.failure, + }; + } + const request = requestResult.object; + if (!(request instanceof config.requestClass)) { + return { + verified: false, + request, + requestId: request.id ?? undefined, + failure: { + category: "invalid", + type: "wrongType", + expectedType: config.requestClass.typeId, + actualTypes: [getTypeId(request)], + }, + }; + } + if (request.id == null) { + return { + verified: false, + request, + failure: { category: "invalid", type: "missingId" }, + }; + } + if (expectedRequestId != null && !idsEqual(request.id, expectedRequestId)) { + return { + verified: false, + request, + requestId: request.id, + failure: { + category: "invalid", + type: "idMismatch", + expected: expectedRequestId, + actual: request.id, + }, + }; + } + const dereferenceOptions = { + documentLoader, + contextLoader: documentLoader, + suppressError: true, + }; + const interactionTarget = await config.getInteractionTarget( + request, + dereferenceOptions, + ); + if (interactionTarget == null) { + return { + verified: false, + request, + requestId: request.id, + failure: { category: "invalid", type: "missingObject" }, + }; + } + if ( + config.isInteractionTarget != null && + !config.isInteractionTarget(interactionTarget) + ) { + return { + verified: false, + request, + requestId: request.id, + failure: { + category: "invalid", + type: "wrongObjectType", + expectedTypes: config.interactionTargetTypes ?? [], + actualTypes: [getTypeId(interactionTarget)], + }, + }; + } + const interactingObject = await config.getInteractingObject( + request, + dereferenceOptions, + ); + if (interactingObject == null) { + return { + verified: false, + request, + requestId: request.id, + failure: { category: "invalid", type: "missingInstrument" }, + }; + } + if ( + config.isInteractingObject != null && + !config.isInteractingObject(interactingObject) + ) { + return { + verified: false, + request, + requestId: request.id, + failure: { + category: "invalid", + type: "wrongInstrumentType", + expectedTypes: config.interactingObjectTypes ?? [], + actualTypes: [getTypeId(interactingObject)], + }, + }; + } + const requester = request.actorId ?? + config.getRequester?.(request, interactingObject, interactionTarget); + if (requester == null) { + return { + verified: false, + request, + requestId: request.id, + failure: { category: "invalid", type: "missingActor" }, + }; + } + const interactingObjectId = getId(interactingObject); + if (interactingObjectId == null) { + return { + verified: false, + request, + requestId: request.id, + failure: { category: "invalid", type: "missingInstrumentId" }, + }; + } + const interactionTargetId = getId(interactionTarget); + if (interactionTargetId == null) { + return { + verified: false, + request, + requestId: request.id, + failure: { category: "invalid", type: "missingObjectId" }, + }; + } + const validation = config.validateRequest( + request, + interactingObject, + interactionTarget, + requester, + ); + if (validation != null) { + return { + verified: false, + request, + requestId: request.id, + failure: validation.type === "requesterMismatch" + ? { + category: "unauthorized", + type: validation.type, + expected: validation.expected, + actual: validation.actual, + } + : { + category: "invalid", + type: validation.type, + expected: validation.expected, + actual: validation.actual, + }, + }; + } + return { + verified: true, + request, + requestId: request.id, + requester, + interactingObject, + interactingObjectId, + interactionTarget, + interactionTargetId, + }; +} + +async function verifyAuthorization< + TRequest extends Activity, + TAuthorization extends ASObject, + TInteracting extends ASObject, + TTarget extends ASObject, + TImpoliteSource extends ASObject, + TContextData, +>( + context: Context, + options: InteractionAuthorizationVerificationOptions< + TContextData, + TAuthorization, + TInteracting, + TTarget + >, + config: ControlConfig< + TRequest, + TAuthorization, + TInteracting, + TTarget, + TImpoliteSource + >, +): Promise> { + const embeddedAuthorization = !(options.authorization instanceof URL); + const expectedAuthorizationId = options.authorization instanceof URL + ? options.authorization + : null; + const expectedAttribution = options.attributedTo ?? + (!(options.interactionTarget instanceof URL) + ? config.getSelfActor(options.interactionTarget) + : null); + if (expectedAuthorizationId != null && expectedAttribution == null) { + return { + verified: false, + authorizationId: expectedAuthorizationId, + failure: { category: "unauthorized", type: "missingAttribution" }, + }; + } + if (expectedAuthorizationId != null && expectedAttribution != null) { + const expectedOrigin = getAuthorizationOrigin(expectedAttribution); + const actualOrigin = getAuthorizationOrigin(expectedAuthorizationId); + if ( + expectedOrigin === "null" || + actualOrigin === "null" || + expectedOrigin !== actualOrigin + ) { + return { + verified: false, + authorizationId: expectedAuthorizationId, + failure: { + category: "unauthorized", + type: "originMismatch", + expectedOrigin, + actualOrigin, + }, + }; + } + } + const authorizationResult = await materialize( + options.authorization, + config.authorizationClass, + options.documentLoader ?? context.documentLoader, + ); + if (!authorizationResult.ok) { + return { verified: false, failure: authorizationResult.failure }; + } + const authorization = authorizationResult.object; + if (!(authorization instanceof config.authorizationClass)) { + return { + verified: false, + authorization, + authorizationId: authorization.id ?? undefined, + failure: { + category: "unauthorized", + type: "wrongType", + expectedType: config.authorizationClass.typeId, + actualTypes: [getTypeId(authorization)], + }, + }; + } + if (authorization.id == null) { + return { + verified: false, + authorization, + failure: { category: "unauthorized", type: "missingId" }, + }; + } + if ( + expectedAuthorizationId != null && + !idsEqual(authorization.id, expectedAuthorizationId) + ) { + return { + verified: false, + authorization, + authorizationId: authorization.id, + failure: { + category: "unauthorized", + type: "idMismatch", + expected: expectedAuthorizationId, + actual: authorization.id, + }, + }; + } + const revocation = await options.getRevocation?.(authorization.id, context); + if (revocation != null) { + return { + verified: false, + authorization, + authorizationId: authorization.id, + failure: { category: "revoked", type: "deleted", ...revocation }, + }; + } + const interactingObjectId = getId(options.interactingObject); + if (interactingObjectId == null) { + return { + verified: false, + authorization, + authorizationId: authorization.id, + failure: { category: "unauthorized", type: "missingObjectId" }, + }; + } + const interactionTargetId = getId(options.interactionTarget); + if (interactionTargetId == null) { + return { + verified: false, + authorization, + authorizationId: authorization.id, + failure: { category: "unauthorized", type: "missingTargetId" }, + }; + } + const actualInteractingId = ( + authorization as ASObject & { readonly interactingObjectId?: URL | null } + ).interactingObjectId; + if (!idsEqual(actualInteractingId, interactingObjectId)) { + return { + verified: false, + authorization, + authorizationId: authorization.id, + failure: { + category: "unauthorized", + type: "objectMismatch", + expected: interactingObjectId, + actual: actualInteractingId ?? undefined, + }, + }; + } + const actualTargetId = ( + authorization as ASObject & { readonly interactionTargetId?: URL | null } + ).interactionTargetId; + if (!idsEqual(actualTargetId, interactionTargetId)) { + return { + verified: false, + authorization, + authorizationId: authorization.id, + failure: { + category: "unauthorized", + type: "targetMismatch", + expected: interactionTargetId, + actual: actualTargetId ?? undefined, + }, + }; + } + if (expectedAttribution == null) { + return { + verified: false, + authorization, + authorizationId: authorization.id, + failure: { category: "unauthorized", type: "missingAttribution" }, + }; + } + const attributionRequired = config.authorizationAttribution !== "optional"; + if ( + attributionRequired && + !idsEqual(authorization.attributionId, expectedAttribution) + ) { + return { + verified: false, + authorization, + authorizationId: authorization.id, + failure: { + category: "unauthorized", + type: "attributionMismatch", + expected: expectedAttribution, + actual: authorization.attributionId ?? undefined, + }, + }; + } + if ( + authorization.attributionId != null && + !idsEqual(authorization.attributionId, expectedAttribution) + ) { + return { + verified: false, + authorization, + authorizationId: authorization.id, + failure: { + category: "unauthorized", + type: "attributionMismatch", + expected: expectedAttribution, + actual: authorization.attributionId, + }, + }; + } + const expectedOrigin = getAuthorizationOrigin(expectedAttribution); + const actualOrigin = getAuthorizationOrigin(authorization.id); + if ( + expectedOrigin === "null" || + actualOrigin === "null" || + expectedOrigin !== actualOrigin + ) { + return { + verified: false, + authorization, + authorizationId: authorization.id, + failure: { + category: "unauthorized", + type: "originMismatch", + expectedOrigin, + actualOrigin, + }, + }; + } + if (embeddedAuthorization && options.verifyAuthenticity == null) { + return { + verified: false, + authorization, + authorizationId: authorization.id, + failure: { + category: "unauthorized", + type: "notAuthentic", + detail: "Embedded authorizations require authenticity verification.", + }, + }; + } + if (options.verifyAuthenticity != null) { + let authentic: boolean; + try { + authentic = await options.verifyAuthenticity(authorization, context); + } catch (cause) { + return { + verified: false, + authorization, + authorizationId: authorization.id, + failure: { category: "unauthorized", type: "notAuthentic", cause }, + }; + } + if (!authentic) { + return { + verified: false, + authorization, + authorizationId: authorization.id, + failure: { category: "unauthorized", type: "notAuthentic" }, + }; + } + } + return { verified: true, authorization, authorizationId: authorization.id }; +} + +async function materialize( + value: T | URL, + constructor: VocabConstructor, + documentLoader: DocumentLoader | undefined, +): Promise< + | { readonly ok: true; readonly object: T } + | { + readonly ok: false; + readonly failure: + | { + readonly category: "unverifiable"; + readonly type: "notDereferenceable"; + readonly url: URL; + readonly cause?: unknown; + } + | { + readonly category: "unverifiable"; + readonly type: "invalidJsonLd"; + readonly cause?: unknown; + }; + } +> { + if (!(value instanceof URL)) return { ok: true, object: value }; + const loader = documentLoader; + if (loader == null) { + return { + ok: false, + failure: { + category: "unverifiable", + type: "notDereferenceable", + url: value, + }, + }; + } + let remoteDocument; + try { + remoteDocument = await loader(value.href); + } catch (cause) { + return { + ok: false, + failure: { + category: "unverifiable", + type: "notDereferenceable", + url: value, + cause, + }, + }; + } + if (remoteDocument == null) { + return { + ok: false, + failure: { + category: "unverifiable", + type: "notDereferenceable", + url: value, + }, + }; + } + try { + return { + ok: true, + object: await constructor.fromJsonLd(remoteDocument.document, { + documentLoader: loader, + contextLoader: loader, + baseUrl: value, + }), + }; + } catch (cause) { + return { + ok: false, + failure: { category: "unverifiable", type: "invalidJsonLd", cause }, + }; + } +} + +async function evaluatePolicy< + TRequest extends Activity, + TAuthorization extends ASObject, + TInteracting extends ASObject, + TTarget extends ASObject, + TImpoliteSource extends ASObject, + TContextData, +>( + context: Context, + options: InteractionPolicyEvaluationOptions, + config: ControlConfig< + TRequest, + TAuthorization, + TInteracting, + TTarget, + TImpoliteSource + >, +): Promise { + const selfActor = config.getSelfActor(options.subject); + if (idsEqual(selfActor, options.requester)) { + return { result: "automatic", reason: { type: "self" } }; + } + const implicitAutomatic = await matchImplicitAutomaticActors( + options.subject, + options.requester, + { + documentLoader: options.documentLoader ?? context.documentLoader, + suppressError: true, + }, + config, + ); + if (implicitAutomatic != null) { + return { result: "automatic", reason: implicitAutomatic }; + } + const policy = options.subject.interactionPolicy; + if (policy == null) return missingPolicyDecision(config); + const rule = policy[config.policyProperty] as InteractionRule | null; + if (rule == null) return missingRuleDecision(config); + const automaticApprovals = rule.automaticApprovals ?? []; + const manualApprovals = rule.manualApprovals ?? []; + if (automaticApprovals.length < 1 && manualApprovals.length < 1) { + return missingRuleDecision(config); + } + const automatic = await matchRule( + automaticApprovals, + options.requester, + context, + options.matchesApprovalCollection, + { actorOnly: true }, + ); + if (automatic?.result === "matched") { + return { result: "automatic", reason: automatic.reason }; + } + const manual = await matchRule( + manualApprovals, + options.requester, + context, + options.matchesApprovalCollection, + { actorOnly: true }, + ); + if (manual?.result === "matched") { + return { result: "manual", reason: manual.reason }; + } + const broadAutomatic = await matchRule( + automaticApprovals, + options.requester, + context, + options.matchesApprovalCollection, + ); + const unverifiableAutomatic = broadAutomatic?.result === + "unverifiableCollection" + ? broadAutomatic.collection + : null; + if (broadAutomatic?.result === "matched") { + return { result: "automatic", reason: broadAutomatic.reason }; + } + const broadManual = await matchRule( + manualApprovals, + options.requester, + context, + options.matchesApprovalCollection, + ); + if (broadManual?.result === "unverifiableCollection") { + return deniedUnverifiableCollection(broadManual.collection); + } else if (broadManual?.result === "matched") { + return { result: "manual", reason: broadManual.reason }; + } + if (unverifiableAutomatic != null) { + return deniedUnverifiableCollection(unverifiableAutomatic); + } + return { result: "denied", reason: { type: "noMatch" } }; +} + +async function matchImplicitAutomaticActors< + TRequest extends Activity, + TAuthorization extends ASObject, + TInteracting extends ASObject, + TTarget extends ASObject, + TImpoliteSource extends ASObject, +>( + subject: TTarget, + requester: URL, + options: DereferenceOptions, + config: ControlConfig< + TRequest, + TAuthorization, + TInteracting, + TTarget, + TImpoliteSource + >, +): Promise { + if (config.getImplicitAutomaticActors == null) return null; + for await ( + const actor of config.getImplicitAutomaticActors(subject, options) + ) { + if (idsEqual(actor, requester)) { + return { type: "actor", actor }; + } + } + return null; +} + +function missingPolicyDecision< + TRequest extends Activity, + TAuthorization extends ASObject, + TInteracting extends ASObject, + TTarget extends ASObject, + TImpoliteSource extends ASObject, +>( + config: ControlConfig< + TRequest, + TAuthorization, + TInteracting, + TTarget, + TImpoliteSource + >, +): InteractionPolicyDecision { + if (config.defaultMissingPolicy === "automatic") { + return { + result: "automatic", + reason: { type: "default", default: "publicAutomatic" }, + }; + } + return { result: "denied", reason: { type: "missingPolicy" } }; +} + +function missingRuleDecision< + TRequest extends Activity, + TAuthorization extends ASObject, + TInteracting extends ASObject, + TTarget extends ASObject, + TImpoliteSource extends ASObject, +>( + config: ControlConfig< + TRequest, + TAuthorization, + TInteracting, + TTarget, + TImpoliteSource + >, +): InteractionPolicyDecision { + if (config.defaultMissingPolicy === "automatic") { + return { + result: "automatic", + reason: { type: "default", default: "publicAutomatic" }, + }; + } + return { result: "denied", reason: { type: "missingRule" } }; +} + +async function matchRule( + entries: readonly URL[], + requester: URL, + context: Context, + matchesApprovalCollection: + | MatchesApprovalCollection + | undefined, + options: { readonly actorOnly?: boolean } = {}, +): Promise { + for (const entry of entries) { + if (entry.href === requester.href) { + return { result: "matched", reason: { type: "actor", actor: entry } }; + } + } + if (options.actorOnly) return null; + for (const entry of entries) { + if (entry.href === PUBLIC_COLLECTION.href) { + return { result: "matched", reason: { type: "public" } }; + } + } + if (matchesApprovalCollection != null) { + let unverifiableCollection: URL | null = null; + for (const entry of entries) { + if ( + entry.href === PUBLIC_COLLECTION.href || entry.href === requester.href + ) { + continue; + } + let matched; + try { + matched = await matchesApprovalCollection(entry, requester, context); + } catch { + unverifiableCollection ??= entry; + continue; + } + if (matched) { + return { + result: "matched", + reason: { type: "collection", collection: entry }, + }; + } + } + if (unverifiableCollection != null) { + return { + result: "unverifiableCollection", + collection: unverifiableCollection, + }; + } + } + return null; +} + +function deniedUnverifiableCollection( + collection: URL, +): InteractionPolicyDecision { + return { + result: "denied", + reason: { type: "unverifiableCollection", collection }, + }; +} + +function getAuthorizationOrigin(id: URL): string { + try { + return getFe34Origin(id); + } catch { + return id.origin; + } +} + +function createAccept< + TRequest extends Activity, + TAuthorization extends ASObject, + TInteracting extends ASObject, + TTarget extends ASObject, +>( + options: InteractionAcceptOptions< + TRequest, + TAuthorization, + TInteracting, + TTarget + >, +): Accept { + if (options.mode === "polite") { + return new Accept({ + id: options.id, + actor: options.actor, + object: options.request, + result: options.authorization, + ...audience(options), + }); + } + return new Accept({ + id: options.id, + actor: options.actor, + object: getRequiredId(options.interactingObject, "interactingObject"), + target: getRequiredId(options.interactionTarget, "interactionTarget"), + result: getRequiredId(options.authorization, "authorization"), + ...audience(options), + }); +} + +function createReject< + TRequest extends Activity, + TInteracting extends ASObject, + TTarget extends ASObject, +>( + options: InteractionRejectOptions, +): Reject { + if (options.mode === "polite") { + return new Reject({ + id: options.id, + actor: options.actor, + object: options.request, + ...audience(options), + }); + } + return new Reject({ + id: options.id, + actor: options.actor, + object: getRequiredId(options.interactingObject, "interactingObject"), + target: getRequiredId(options.interactionTarget, "interactionTarget"), + ...audience(options), + }); +} + +export function recognized< + TInteracting extends ASObject, + TTarget extends ASObject, + TImpoliteSource extends ASObject, +>( + values: { + readonly requester: URL | null; + readonly interactingObject: TInteracting; + readonly interactionTarget?: TTarget; + readonly interactionTargetId: URL | null; + readonly source: TImpoliteSource; + readonly evidence: ImpoliteInteractionEvidence; + }, +): + | RecognizedImpoliteInteraction< + TInteracting, + TTarget, + TImpoliteSource + > + | null { + if (values.requester == null || values.interactionTargetId == null) { + return null; + } + const interactingObjectId = values.interactingObject.id; + if (interactingObjectId == null) return null; + return { + requester: values.requester, + interactingObject: values.interactingObject, + interactingObjectId, + interactionTarget: values.interactionTarget, + interactionTargetId: values.interactionTargetId, + source: values.source, + evidence: values.evidence, + }; +} diff --git a/packages/interaction-controls/src/feature.test.ts b/packages/interaction-controls/src/feature.test.ts new file mode 100644 index 000000000..08ce2f7a7 --- /dev/null +++ b/packages/interaction-controls/src/feature.test.ts @@ -0,0 +1,239 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import type { Context } from "@fedify/fedify"; +import { + FeatureAuthorization, + FeaturedCollection, + FeatureRequest, + InteractionPolicy, + InteractionRule, + Note, + Person, + PUBLIC_COLLECTION, +} from "@fedify/vocab"; +import { featureInteraction } from "./mod.ts"; + +const context = {} as Context; +const actor = new URL("https://example.com/users/alice"); +const targetId = new URL("https://example.net/users/bob"); +const collectionId = new URL("https://example.com/users/alice/featured"); +const authorizationId = new URL("https://example.net/authorizations/5"); +const verifyAuthenticity = () => true; + +test("featureInteraction creates and verifies requests", async () => { + const target = new Person({ id: targetId }); + const collection = new FeaturedCollection({ + id: collectionId, + attribution: actor, + }); + const request = featureInteraction.createRequest({ + id: new URL("https://example.com/requests/5"), + actor, + object: target, + instrument: collection, + }); + + assert.ok(request instanceof FeatureRequest); + + const result = await featureInteraction.verifyRequest(context, { request }); + + assert.equal(result.verified, true); + assert.equal(result.requester.href, actor.href); + assert.equal(result.interactingObjectId.href, collectionId.href); + assert.equal(result.interactionTargetId.href, targetId.href); +}); + +test("featureInteraction accepts actorless requests from collection owner", async () => { + const target = new Person({ id: targetId }); + const collection = new FeaturedCollection({ + id: collectionId, + attribution: actor, + }); + const request = new FeatureRequest({ + id: new URL("https://example.com/requests/5"), + object: target, + instrument: collection, + }); + + const result = await featureInteraction.verifyRequest(context, { request }); + + assert.equal(result.verified, true); + assert.equal(result.requester.href, actor.href); + assert.equal(result.interactingObjectId.href, collectionId.href); + assert.equal(result.interactionTargetId.href, targetId.href); +}); + +test("featureInteraction rejects non-actor request targets", async () => { + const target = new Note({ id: targetId }); + const collection = new FeaturedCollection({ + id: collectionId, + attribution: actor, + }); + const request = new FeatureRequest({ + id: new URL("https://example.com/requests/5"), + actor, + object: target, + instrument: collection, + }); + + const result = await featureInteraction.verifyRequest(context, { request }); + + assert.equal(result.verified, false); + assert.equal(result.failure.type, "wrongObjectType"); +}); + +test("featureInteraction denies mismatched collection owners", async () => { + const target = new Person({ id: targetId }); + const collection = new FeaturedCollection({ + id: collectionId, + attribution: new URL("https://example.org/users/carol"), + }); + const request = new FeatureRequest({ + id: new URL("https://example.com/requests/5"), + actor, + object: target, + instrument: collection, + }); + + const result = await featureInteraction.verifyRequest(context, { request }); + + assert.equal(result.verified, false); + assert.equal(result.failure.type, "requesterMismatch"); +}); + +test("featureInteraction denies missing canFeature by default", async () => { + const target = new Person({ id: targetId }); + + assert.deepEqual( + await featureInteraction.evaluatePolicy(context, { + subject: target, + requester: actor, + }), + { + result: "denied", + reason: { type: "missingPolicy" }, + }, + ); +}); + +test("featureInteraction evaluates canFeature rules", async () => { + const target = new Person({ + id: targetId, + interactionPolicy: new InteractionPolicy({ + canFeature: new InteractionRule({ manualApproval: PUBLIC_COLLECTION }), + }), + }); + + assert.deepEqual( + await featureInteraction.evaluatePolicy(context, { + subject: target, + requester: actor, + }), + { + result: "manual", + reason: { type: "public" }, + }, + ); +}); + +test("featureInteraction grants self approval to the featured actor", async () => { + const target = new Person({ id: targetId }); + + assert.deepEqual( + await featureInteraction.evaluatePolicy(context, { + subject: target, + requester: targetId, + }), + { + result: "automatic", + reason: { type: "self" }, + }, + ); +}); + +test("featureInteraction creates and verifies authorizations", async () => { + const target = new Person({ id: targetId }); + const collection = new FeaturedCollection({ + id: collectionId, + attribution: actor, + }); + const authorization = featureInteraction.createAuthorization({ + id: authorizationId, + attributedTo: targetId, + interactingObject: collection, + interactionTarget: target, + }); + + assert.ok(authorization instanceof FeatureAuthorization); + + const result = await featureInteraction.verifyAuthorization(context, { + authorization, + interactingObject: collection, + interactionTarget: target, + attributedTo: targetId, + verifyAuthenticity, + }); + + assert.equal(result.verified, true); + + const inferredResult = await featureInteraction.verifyAuthorization(context, { + authorization, + interactingObject: collection, + interactionTarget: target, + verifyAuthenticity, + }); + + assert.equal(inferredResult.verified, true); +}); + +test("featureInteraction verifies actorless authorizations", async () => { + const target = new Person({ id: targetId }); + const collection = new FeaturedCollection({ + id: collectionId, + attribution: actor, + }); + const authorization = new FeatureAuthorization({ + id: authorizationId, + interactingObject: collectionId, + interactionTarget: targetId, + }); + + const result = await featureInteraction.verifyAuthorization(context, { + authorization, + interactingObject: collection, + interactionTarget: target, + attributedTo: targetId, + verifyAuthenticity, + }); + + assert.equal(result.verified, true); +}); + +test("featureInteraction checks actorless authorization origins", async () => { + const target = new Person({ id: targetId }); + const collection = new FeaturedCollection({ + id: collectionId, + attribution: actor, + }); + const authorization = new FeatureAuthorization({ + id: new URL("https://example.org/authorizations/5"), + interactingObject: collectionId, + interactionTarget: targetId, + }); + + const result = await featureInteraction.verifyAuthorization(context, { + authorization, + interactingObject: collection, + interactionTarget: target, + attributedTo: targetId, + }); + + assert.equal(result.verified, false); + assert.equal(result.failure.type, "originMismatch"); +}); + +test("featureInteraction does not recognize standalone featured items", () => { + const target = new Person({ id: targetId }); + + assert.equal(featureInteraction.recognizeImpolite(target), null); +}); diff --git a/packages/interaction-controls/src/feature.ts b/packages/interaction-controls/src/feature.ts new file mode 100644 index 000000000..c8e271634 --- /dev/null +++ b/packages/interaction-controls/src/feature.ts @@ -0,0 +1,58 @@ +import { + type Actor, + Application, + FeatureAuthorization, + FeaturedCollection, + FeatureRequest, + Group, + isActor, + type Object as ASObject, + Organization, + Person, + Service, +} from "@fedify/vocab"; +import { createInteractionControl, idsEqual } from "./control.ts"; +import type { InteractionControl } from "./types.ts"; + +export const featureInteraction: InteractionControl< + FeatureRequest, + FeatureAuthorization, + FeaturedCollection, + Actor, + ASObject +> = createInteractionControl({ + name: "feature", + policyProperty: "canFeature", + requestClass: FeatureRequest, + authorizationClass: FeatureAuthorization, + getInteractingObject: (request, options) => + request.getInstrument(options) as Promise, + isInteractingObject: (object): object is FeaturedCollection => + object instanceof FeaturedCollection, + interactingObjectTypes: [FeaturedCollection.typeId], + getInteractionTarget: (request, options) => + request.getObject(options) as Promise, + isInteractionTarget: isActor, + interactionTargetTypes: [ + Application.typeId, + Group.typeId, + Organization.typeId, + Person.typeId, + Service.typeId, + ], + getRequester: (_request, collection) => collection.attributionId, + validateRequest: (_request, collection, _target, requester) => { + if (!idsEqual(collection.attributionId, requester)) { + return { + type: "requesterMismatch", + expected: requester, + actual: collection.attributionId ?? undefined, + }; + } + return null; + }, + authorizationAttribution: "optional", + getSelfActor: (subject) => subject.id, + defaultMissingPolicy: "denied", + recognizeImpolite: () => null, +}); diff --git a/packages/interaction-controls/src/key.ts b/packages/interaction-controls/src/key.ts new file mode 100644 index 000000000..89b1b9dc6 --- /dev/null +++ b/packages/interaction-controls/src/key.ts @@ -0,0 +1,16 @@ +import type { InteractionAuthorizationKey, InteractionKey } from "./types.ts"; + +export function formatInteractionKey(key: InteractionKey): string { + return JSON.stringify([ + key.interaction, + key.requester.href, + key.interactingObjectId.href, + key.interactionTargetId.href, + ]); +} + +export function formatAuthorizationKey( + key: InteractionAuthorizationKey, +): string { + return JSON.stringify([key.interaction, key.authorizationId.href]); +} diff --git a/packages/interaction-controls/src/like.test.ts b/packages/interaction-controls/src/like.test.ts new file mode 100644 index 000000000..8e619c391 --- /dev/null +++ b/packages/interaction-controls/src/like.test.ts @@ -0,0 +1,1047 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import type { Context } from "@fedify/fedify"; +import { + type DocumentLoader, + getDocumentLoader, + type RemoteDocument, +} from "@fedify/vocab-runtime"; +import { + Accept, + Announce, + InteractionPolicy, + InteractionRule, + Like, + LikeAuthorization, + LikeRequest, + Note, + PUBLIC_COLLECTION, +} from "@fedify/vocab"; +import { + formatAuthorizationKey, + formatInteractionKey, + likeInteraction, +} from "./mod.ts"; + +const context = {} as Context; +const actor = new URL("https://example.com/users/alice"); +const author = new URL("https://example.net/users/bob"); +const targetId = new URL("https://example.net/notes/1"); +const likeId = new URL("https://example.com/likes/1"); +const authorizationId = new URL("https://example.net/authorizations/1"); +const throwingDocumentLoader: DocumentLoader = async () => { + await Promise.resolve(); + throw new Error("not dereferenceable"); +}; +const verifyAuthenticity = () => true; +const preloadedDocumentLoader = getDocumentLoader(); + +function remoteDocument(url: URL, document: unknown): RemoteDocument { + return { contextUrl: null, document, documentUrl: url.href }; +} + +function withContextFallback(loader: DocumentLoader): DocumentLoader { + return async (url: string) => { + try { + return await loader(url); + } catch (error) { + const preloaded = await preloadedDocumentLoader(url); + if (preloaded != null) return preloaded; + throw error; + } + }; +} + +test("likeInteraction creates typed requests", () => { + const target = new Note({ id: targetId, attribution: author }); + const like = new Like({ id: likeId, actor, object: targetId }); + + const request = likeInteraction.createRequest({ + id: new URL("https://example.com/requests/1"), + actor, + object: target, + instrument: like, + to: author, + }); + + assert.ok(request instanceof LikeRequest); + assert.equal(request.actorId?.href, actor.href); + assert.equal(request.objectId?.href, targetId.href); + assert.equal(request.instrumentId?.href, likeId.href); +}); + +test("likeInteraction evaluates self and default policies", async () => { + const target = new Note({ id: targetId, attribution: author }); + + assert.deepEqual( + await likeInteraction.evaluatePolicy(context, { + subject: target, + requester: author, + }), + { + result: "automatic", + reason: { type: "self" }, + }, + ); + + assert.deepEqual( + await likeInteraction.evaluatePolicy(context, { + subject: target, + requester: actor, + }), + { + result: "automatic", + reason: { type: "default", default: "publicAutomatic" }, + }, + ); +}); + +test("likeInteraction evaluates explicit policy rules", async () => { + const followers = new URL("https://example.net/users/bob/followers"); + const target = new Note({ + id: targetId, + attribution: author, + interactionPolicy: new InteractionPolicy({ + canLike: new InteractionRule({ + automaticApproval: followers, + manualApproval: PUBLIC_COLLECTION, + }), + }), + }); + + assert.deepEqual( + await likeInteraction.evaluatePolicy(context, { + subject: target, + requester: actor, + matchesApprovalCollection: (collection, requester) => + collection.href === followers.href && requester.href === actor.href, + }), + { + result: "automatic", + reason: { type: "collection", collection: followers }, + }, + ); + + assert.deepEqual( + await likeInteraction.evaluatePolicy(context, { + subject: target, + requester: new URL("https://example.org/users/carol"), + }), + { + result: "manual", + reason: { type: "public" }, + }, + ); +}); + +test("likeInteraction denies failed collection policy checks", async () => { + const followers = new URL("https://example.net/users/bob/followers"); + const target = new Note({ + id: targetId, + attribution: author, + interactionPolicy: new InteractionPolicy({ + canLike: new InteractionRule({ automaticApproval: followers }), + }), + }); + + assert.deepEqual( + await likeInteraction.evaluatePolicy(context, { + subject: target, + requester: actor, + matchesApprovalCollection: () => { + throw new Error("collection unavailable"); + }, + }), + { + result: "denied", + reason: { + type: "unverifiableCollection", + collection: followers, + }, + }, + ); +}); + +test("likeInteraction checks later collections after earlier failures", async () => { + const brokenFollowers = new URL( + "https://example.net/users/bob/followers-unavailable", + ); + const matchingFollowers = new URL( + "https://example.net/users/bob/followers", + ); + const target = new Note({ + id: targetId, + attribution: author, + interactionPolicy: new InteractionPolicy({ + canLike: new InteractionRule({ + automaticApprovals: [brokenFollowers, matchingFollowers], + }), + }), + }); + + assert.deepEqual( + await likeInteraction.evaluatePolicy(context, { + subject: target, + requester: actor, + matchesApprovalCollection: (collection) => { + if (collection.href === brokenFollowers.href) { + throw new Error("collection unavailable"); + } + return collection.href === matchingFollowers.href; + }, + }), + { + result: "automatic", + reason: { type: "collection", collection: matchingFollowers }, + }, + ); +}); + +test("likeInteraction checks manual fallback after failed automatic collections", async () => { + const followers = new URL("https://example.net/users/bob/followers"); + const target = new Note({ + id: targetId, + attribution: author, + interactionPolicy: new InteractionPolicy({ + canLike: new InteractionRule({ + automaticApproval: followers, + manualApproval: PUBLIC_COLLECTION, + }), + }), + }); + + assert.deepEqual( + await likeInteraction.evaluatePolicy(context, { + subject: target, + requester: actor, + matchesApprovalCollection: () => { + throw new Error("collection unavailable"); + }, + }), + { + result: "manual", + reason: { type: "public" }, + }, + ); +}); + +test("likeInteraction evaluates actor rules before public rules", async () => { + const target = new Note({ + id: targetId, + attribution: author, + interactionPolicy: new InteractionPolicy({ + canLike: new InteractionRule({ + automaticApproval: PUBLIC_COLLECTION, + manualApproval: actor, + }), + }), + }); + + assert.deepEqual( + await likeInteraction.evaluatePolicy(context, { + subject: target, + requester: actor, + }), + { + result: "manual", + reason: { type: "actor", actor }, + }, + ); +}); + +test("likeInteraction tolerates missing parsed approval arrays", async () => { + const rule = new InteractionRule({}); + Object.defineProperties(rule, { + automaticApprovals: { value: null }, + manualApprovals: { value: null }, + }); + const target = new Note({ + id: targetId, + attribution: author, + interactionPolicy: new InteractionPolicy({ canLike: rule }), + }); + + assert.deepEqual( + await likeInteraction.evaluatePolicy(context, { + subject: target, + requester: actor, + }), + { + result: "automatic", + reason: { type: "default", default: "publicAutomatic" }, + }, + ); +}); + +test("likeInteraction reports no match for unmatched actor rules", async () => { + const target = new Note({ + id: targetId, + attribution: author, + interactionPolicy: new InteractionPolicy({ + canLike: new InteractionRule({ + automaticApproval: new URL("https://example.org/users/carol"), + }), + }), + }); + + assert.deepEqual( + await likeInteraction.evaluatePolicy(context, { + subject: target, + requester: actor, + }), + { + result: "denied", + reason: { type: "noMatch" }, + }, + ); +}); + +test("likeInteraction creates and verifies authorizations", async () => { + const target = new Note({ id: targetId, attribution: author }); + const like = new Like({ id: likeId, actor, object: target }); + + const authorization = likeInteraction.createAuthorization({ + id: authorizationId, + attributedTo: author, + interactingObject: like, + interactionTarget: target, + }); + + assert.ok(authorization instanceof LikeAuthorization); + assert.equal(authorization.interactingObjectId?.href, likeId.href); + assert.equal(authorization.interactionTargetId?.href, targetId.href); + + const result = await likeInteraction.verifyAuthorization(context, { + authorization, + interactingObject: like, + interactionTarget: target, + attributedTo: author, + verifyAuthenticity, + }); + + assert.equal(result.verified, true); + assert.equal(result.authorizationId.href, authorizationId.href); + + const inferredResult = await likeInteraction.verifyAuthorization(context, { + authorization, + interactingObject: like, + interactionTarget: target, + verifyAuthenticity, + }); + + assert.equal(inferredResult.verified, true); +}); + +test("likeInteraction verifies authorization URLs with context document loaders", async () => { + const target = new Note({ id: targetId, attribution: author }); + const like = new Like({ id: likeId, actor, object: target }); + const authorization = new LikeAuthorization({ + id: authorizationId, + attribution: author, + interactingObject: likeId, + interactionTarget: targetId, + }); + const loaderContext = { + documentLoader: withContextFallback(async (url: string) => { + assert.equal(url, authorizationId.href); + return remoteDocument(authorizationId, await authorization.toJsonLd()); + }), + } as unknown as Context; + + const result = await likeInteraction.verifyAuthorization(loaderContext, { + authorization: authorizationId, + interactingObject: like, + interactionTarget: target, + }); + + assert.equal(result.verified, true); + assert.equal(result.authorizationId.href, authorizationId.href); +}); + +test("likeInteraction rejects mismatched authorization URL origins before fetching", async () => { + const target = new Note({ id: targetId, attribution: author }); + const like = new Like({ id: likeId, actor, object: target }); + const mismatchedAuthorizationId = new URL( + "https://example.org/authorizations/1", + ); + let called = false; + + const result = await likeInteraction.verifyAuthorization(context, { + authorization: mismatchedAuthorizationId, + interactingObject: like, + interactionTarget: target, + documentLoader: async () => { + called = true; + return remoteDocument( + mismatchedAuthorizationId, + await new LikeAuthorization({ + id: mismatchedAuthorizationId, + attribution: author, + interactingObject: likeId, + interactionTarget: targetId, + }).toJsonLd(), + ); + }, + }); + + assert.equal(called, false); + assert.equal(result.verified, false); + assert.equal(result.authorizationId?.href, mismatchedAuthorizationId.href); + assert.equal(result.failure.type, "originMismatch"); + assert.equal(result.failure.expectedOrigin, author.origin); + assert.equal(result.failure.actualOrigin, mismatchedAuthorizationId.origin); +}); + +test("likeInteraction rejects unknown authorization grantors before fetching", async () => { + const like = new Like({ id: likeId, actor, object: targetId }); + let called = false; + + const result = await likeInteraction.verifyAuthorization(context, { + authorization: authorizationId, + interactingObject: like, + interactionTarget: targetId, + documentLoader: async () => { + called = true; + return remoteDocument( + authorizationId, + await new LikeAuthorization({ + id: authorizationId, + attribution: author, + interactingObject: likeId, + interactionTarget: targetId, + }).toJsonLd(), + ); + }, + }); + + assert.equal(called, false); + assert.equal(result.verified, false); + assert.equal(result.authorizationId?.href, authorizationId.href); + assert.equal(result.failure.type, "missingAttribution"); +}); + +test("likeInteraction verifies requests", async () => { + const target = new Note({ id: targetId, attribution: author }); + const like = new Like({ id: likeId, actor, object: targetId }); + const request = new LikeRequest({ + id: new URL("https://example.com/requests/1"), + actor, + object: target, + instrument: like, + }); + + const result = await likeInteraction.verifyRequest(context, { request }); + + assert.equal(result.verified, true); + assert.equal(result.requester.href, actor.href); + assert.equal(result.interactingObjectId.href, likeId.href); + assert.equal(result.interactionTargetId.href, targetId.href); +}); + +test("likeInteraction rejects dereferenced requests with mismatched IDs", async () => { + const requestUrl = new URL("https://example.com/requests/1"); + const actualRequestId = new URL("https://example.com/requests/other"); + const target = new Note({ id: targetId, attribution: author }); + const like = new Like({ id: likeId, actor, object: targetId }); + const request = new LikeRequest({ + id: actualRequestId, + actor, + object: target, + instrument: like, + }); + + const result = await likeInteraction.verifyRequest(context, { + request: requestUrl, + documentLoader: withContextFallback(async (url) => { + assert.equal(url, requestUrl.href); + return remoteDocument(requestUrl, await request.toJsonLd()); + }), + }); + + assert.equal(result.verified, false); + assert.equal(result.requestId?.href, actualRequestId.href); + assert.equal(result.failure.type, "idMismatch"); + assert.equal(result.failure.expected.href, requestUrl.href); + assert.equal(result.failure.actual?.href, actualRequestId.href); +}); + +test("likeInteraction verifies request URLs with context document loaders", async () => { + const requestUrl = new URL("https://example.com/requests/1"); + const target = new Note({ id: targetId, attribution: author }); + const like = new Like({ id: likeId, actor, object: targetId }); + const request = new LikeRequest({ + id: requestUrl, + actor, + object: target, + instrument: like, + }); + const loaderContext = { + documentLoader: withContextFallback(async (url: string) => { + if (url === requestUrl.href) { + return remoteDocument(requestUrl, await request.toJsonLd()); + } + assert.equal(url, targetId.href); + return remoteDocument(targetId, await target.toJsonLd()); + }), + } as unknown as Context; + + const result = await likeInteraction.verifyRequest(loaderContext, { + request: requestUrl, + }); + + assert.equal(result.verified, true); + assert.equal(result.requestId.href, requestUrl.href); +}); + +test("likeInteraction uses request loaders for remote contexts", async () => { + const requestUrl = new URL("https://example.com/requests/1"); + const contextUrl = new URL("https://example.com/contexts/interaction"); + const target = new Note({ id: targetId, attribution: author }); + const like = new Like({ id: likeId, actor, object: targetId }); + let loadedContext = false; + const loaderContext = { + documentLoader: withContextFallback(async (url: string) => { + switch (url) { + case requestUrl.href: + return remoteDocument(requestUrl, { + "@context": contextUrl.href, + id: requestUrl.href, + type: "LikeRequest", + actor: actor.href, + object: targetId.href, + instrument: likeId.href, + }); + case contextUrl.href: + loadedContext = true; + return remoteDocument(contextUrl, { + "@context": { + id: "@id", + type: "@type", + LikeRequest: "https://gotosocial.org/ns#LikeRequest", + actor: { + "@id": "https://www.w3.org/ns/activitystreams#actor", + "@type": "@id", + }, + object: { + "@id": "https://www.w3.org/ns/activitystreams#object", + "@type": "@id", + }, + instrument: { + "@id": "https://www.w3.org/ns/activitystreams#instrument", + "@type": "@id", + }, + }, + }); + case targetId.href: + return remoteDocument(targetId, await target.toJsonLd()); + case likeId.href: + return remoteDocument(likeId, await like.toJsonLd()); + default: + throw new Error(`Unexpected document load: ${url}`); + } + }), + } as unknown as Context; + + const result = await likeInteraction.verifyRequest(loaderContext, { + request: requestUrl, + }); + + assert.equal(loadedContext, true); + assert.equal(result.verified, true); + assert.equal(result.requestId.href, requestUrl.href); +}); + +test("likeInteraction dereferences request fields with context loaders", async () => { + const request = new LikeRequest({ + id: new URL("https://example.com/requests/1"), + actor, + object: targetId, + instrument: new Like({ id: likeId, actor, object: targetId }), + }); + const loaderContext = { + documentLoader: withContextFallback(async (url: string) => { + assert.equal(url, targetId.href); + return remoteDocument( + targetId, + await new Note({ id: targetId, attribution: author }).toJsonLd(), + ); + }), + } as unknown as Context; + + const result = await likeInteraction.verifyRequest(loaderContext, { + request, + }); + + assert.equal(result.verified, true); + assert.equal(result.interactionTargetId.href, targetId.href); +}); + +test("likeInteraction uses request loaders for dereferenced field contexts", async () => { + const contextUrl = new URL("https://example.net/contexts/note"); + const request = new LikeRequest({ + id: new URL("https://example.com/requests/1"), + actor, + object: targetId, + instrument: new Like({ id: likeId, actor, object: targetId }), + }); + let loadedContext = false; + const loaderContext = { + documentLoader: async (url: string) => { + await Promise.resolve(); + switch (url) { + case targetId.href: + return remoteDocument(targetId, { + "@context": contextUrl.href, + id: targetId.href, + type: "Note", + attributedTo: author.href, + }); + case contextUrl.href: + loadedContext = true; + return remoteDocument(contextUrl, { + "@context": { + id: "@id", + type: "@type", + Note: "https://www.w3.org/ns/activitystreams#Note", + attributedTo: { + "@id": "https://www.w3.org/ns/activitystreams#attributedTo", + "@type": "@id", + }, + }, + }); + default: + throw new Error(`Unexpected document load: ${url}`); + } + }, + } as unknown as Context; + + const result = await likeInteraction.verifyRequest(loaderContext, { + request, + }); + + assert.equal(loadedContext, true); + assert.equal(result.verified, true); + assert.equal(result.interactionTargetId.href, targetId.href); +}); + +test("likeInteraction reports null remote documents as not dereferenceable", async () => { + const requestUrl = new URL("https://example.com/requests/1"); + let called = false; + + const result = await likeInteraction.verifyRequest(context, { + request: requestUrl, + documentLoader: async () => { + await Promise.resolve(); + called = true; + return null as unknown as RemoteDocument; + }, + }); + + assert.equal(called, true); + assert.equal(result.verified, false); + assert.equal(result.failure.type, "notDereferenceable"); + assert.equal(result.failure.url.href, requestUrl.href); +}); + +test("likeInteraction rejects wrong request instrument types", async () => { + const target = new Note({ id: targetId, attribution: author }); + const announce = new Announce({ id: likeId, actor, object: targetId }); + const request = new LikeRequest({ + id: new URL("https://example.com/requests/1"), + actor, + object: target, + instrument: announce, + }); + + const result = await likeInteraction.verifyRequest(context, { request }); + + assert.equal(result.verified, false); + assert.equal(result.failure.type, "wrongInstrumentType"); +}); + +test("likeInteraction returns failures for dereferenceable object errors", async () => { + const like = new Like({ id: likeId, actor, object: targetId }); + const request = new LikeRequest({ + id: new URL("https://example.com/requests/1"), + actor, + object: targetId, + instrument: like, + }); + + const result = await likeInteraction.verifyRequest(context, { + request, + documentLoader: throwingDocumentLoader, + }); + + assert.equal(result.verified, false); + assert.equal(result.failure.type, "missingObject"); +}); + +test("likeInteraction returns failures for dereferenceable instrument errors", async () => { + const target = new Note({ id: targetId, attribution: author }); + const request = new LikeRequest({ + id: new URL("https://example.com/requests/1"), + actor, + object: target, + instrument: likeId, + }); + + const result = await likeInteraction.verifyRequest(context, { + request, + documentLoader: throwingDocumentLoader, + }); + + assert.equal(result.verified, false); + assert.equal(result.failure.type, "missingInstrument"); +}); + +test("likeInteraction rejects id-less request objects", async () => { + const target = new Note({ attribution: author }); + const like = new Like({ id: likeId, actor, object: targetId }); + const request = new LikeRequest({ + id: new URL("https://example.com/requests/1"), + actor, + object: target, + instrument: like, + }); + + const result = await likeInteraction.verifyRequest(context, { request }); + + assert.equal(result.verified, false); + assert.equal(result.failure.type, "missingObjectId"); +}); + +test("likeInteraction rejects id-less request instruments", async () => { + const target = new Note({ id: targetId, attribution: author }); + const like = new Like({ actor, object: targetId }); + const request = new LikeRequest({ + id: new URL("https://example.com/requests/1"), + actor, + object: target, + instrument: like, + }); + + const result = await likeInteraction.verifyRequest(context, { request }); + + assert.equal(result.verified, false); + assert.equal(result.failure.type, "missingInstrumentId"); +}); + +test("likeInteraction rejects unverifiable authorization grantors", async () => { + const target = new Note({ id: targetId, attribution: author }); + const like = new Like({ id: likeId, actor, object: target }); + const authorization = new LikeAuthorization({ + id: new URL("https://example.org/authorizations/1"), + attribution: new URL("https://example.org/users/carol"), + interactingObject: likeId, + interactionTarget: targetId, + }); + + const result = await likeInteraction.verifyAuthorization(context, { + authorization, + interactingObject: like, + interactionTarget: target, + }); + + assert.equal(result.verified, false); + assert.equal(result.failure.type, "attributionMismatch"); +}); + +test("likeInteraction rejects authorization grantors without origins", async () => { + const opaqueAuthor = new URL("urn:example:users:bob"); + const target = new Note({ id: targetId, attribution: opaqueAuthor }); + const like = new Like({ id: likeId, actor, object: target }); + const authorization = new LikeAuthorization({ + id: authorizationId, + attribution: opaqueAuthor, + interactingObject: likeId, + interactionTarget: targetId, + }); + + const result = await likeInteraction.verifyAuthorization(context, { + authorization, + interactingObject: like, + interactionTarget: target, + verifyAuthenticity, + }); + + assert.equal(result.verified, false); + assert.equal(result.failure.type, "originMismatch"); +}); + +test("likeInteraction rejects authorization IDs without origins", async () => { + const target = new Note({ id: targetId, attribution: author }); + const like = new Like({ id: likeId, actor, object: target }); + const authorization = new LikeAuthorization({ + id: new URL("urn:example:authorizations:1"), + attribution: author, + interactingObject: likeId, + interactionTarget: targetId, + }); + + const result = await likeInteraction.verifyAuthorization(context, { + authorization, + interactingObject: like, + interactionTarget: target, + verifyAuthenticity, + }); + + assert.equal(result.verified, false); + assert.equal(result.failure.type, "originMismatch"); +}); + +test("likeInteraction accepts matching portable authorization origins", async () => { + const portableAuthor = new URL("ap://did%3Akey%3Az6Mkabc/users/bob"); + const portableTargetId = new URL("ap://did%3Akey%3Az6Mkabc/notes/1"); + const portableAuthorizationId = new URL( + "ap://did%3Akey%3Az6Mkabc/authorizations/1", + ); + const target = new Note({ + id: portableTargetId, + attribution: portableAuthor, + }); + const like = new Like({ id: likeId, actor, object: target }); + const authorization = new LikeAuthorization({ + id: portableAuthorizationId, + attribution: portableAuthor, + interactingObject: likeId, + interactionTarget: portableTargetId, + }); + + const result = await likeInteraction.verifyAuthorization(context, { + authorization, + interactingObject: like, + interactionTarget: target, + verifyAuthenticity, + }); + + assert.equal(result.verified, true); + assert.equal(result.authorizationId.href, portableAuthorizationId.href); +}); + +test("likeInteraction rejects mismatched portable authorization origins", async () => { + const portableAuthor = new URL("ap://did%3Akey%3Az6Mkabc/users/bob"); + const portableTargetId = new URL("ap://did%3Akey%3Az6Mkabc/notes/1"); + const portableAuthorizationId = new URL( + "ap://did%3Akey%3Az6Mkdef/authorizations/1", + ); + const target = new Note({ + id: portableTargetId, + attribution: portableAuthor, + }); + const like = new Like({ id: likeId, actor, object: target }); + const authorization = new LikeAuthorization({ + id: portableAuthorizationId, + attribution: portableAuthor, + interactingObject: likeId, + interactionTarget: portableTargetId, + }); + + const result = await likeInteraction.verifyAuthorization(context, { + authorization, + interactingObject: like, + interactionTarget: target, + verifyAuthenticity, + }); + + assert.equal(result.verified, false); + assert.equal(result.failure.type, "originMismatch"); + assert.equal(result.failure.expectedOrigin, "did:key:z6Mkabc"); + assert.equal(result.failure.actualOrigin, "did:key:z6Mkdef"); +}); + +test("likeInteraction rejects missing authorization grantors", async () => { + const target = new Note({ id: targetId }); + const like = new Like({ id: likeId, actor, object: target }); + const authorization = new LikeAuthorization({ + id: authorizationId, + interactingObject: likeId, + interactionTarget: targetId, + }); + + const result = await likeInteraction.verifyAuthorization(context, { + authorization, + interactingObject: like, + interactionTarget: target, + }); + + assert.equal(result.verified, false); + assert.equal(result.failure.type, "missingAttribution"); +}); + +test("likeInteraction rejects id-less authorization interactions", async () => { + const target = new Note({ id: targetId, attribution: author }); + const like = new Like({ actor, object: target }); + const authorization = new LikeAuthorization({ + id: authorizationId, + attribution: author, + interactingObject: likeId, + interactionTarget: targetId, + }); + + const result = await likeInteraction.verifyAuthorization(context, { + authorization, + interactingObject: like, + interactionTarget: target, + verifyAuthenticity, + }); + + assert.equal(result.verified, false); + assert.equal(result.failure.type, "missingObjectId"); +}); + +test("likeInteraction rejects id-less authorization targets", async () => { + const target = new Note({ attribution: author }); + const like = new Like({ id: likeId, actor, object: target }); + const authorization = new LikeAuthorization({ + id: authorizationId, + attribution: author, + interactingObject: likeId, + interactionTarget: targetId, + }); + + const result = await likeInteraction.verifyAuthorization(context, { + authorization, + interactingObject: like, + interactionTarget: target, + verifyAuthenticity, + }); + + assert.equal(result.verified, false); + assert.equal(result.failure.type, "missingTargetId"); +}); + +test("likeInteraction rejects unauthenticated embedded authorizations", async () => { + const target = new Note({ id: targetId, attribution: author }); + const like = new Like({ id: likeId, actor, object: target }); + const authorization = new LikeAuthorization({ + id: authorizationId, + attribution: author, + interactingObject: likeId, + interactionTarget: targetId, + }); + + const result = await likeInteraction.verifyAuthorization(context, { + authorization, + interactingObject: like, + interactionTarget: target, + }); + + assert.equal(result.verified, false); + assert.equal(result.failure.type, "notAuthentic"); +}); + +test("likeInteraction reports authenticity verification errors", async () => { + const target = new Note({ id: targetId, attribution: author }); + const like = new Like({ id: likeId, actor, object: target }); + const authorization = new LikeAuthorization({ + id: authorizationId, + attribution: author, + interactingObject: likeId, + interactionTarget: targetId, + }); + const cause = new Error("bad proof"); + + const result = await likeInteraction.verifyAuthorization(context, { + authorization, + interactingObject: like, + interactionTarget: target, + verifyAuthenticity: () => { + throw cause; + }, + }); + + assert.equal(result.verified, false); + assert.equal(result.failure.type, "notAuthentic"); + assert.equal(result.failure.cause, cause); +}); + +test("likeInteraction builds responses and revocations", () => { + const target = new Note({ id: targetId, attribution: author }); + const like = new Like({ id: likeId, actor, object: targetId }); + const authorization = new LikeAuthorization({ + id: authorizationId, + attribution: author, + interactingObject: likeId, + interactionTarget: targetId, + }); + + const accept = likeInteraction.createAccept({ + mode: "impolite", + id: new URL("https://example.net/accepts/1"), + actor: author, + interactingObject: like, + interactionTarget: target, + authorization, + to: actor, + cc: PUBLIC_COLLECTION, + }); + + assert.ok(accept instanceof Accept); + assert.equal(accept.objectId?.href, likeId.href); + assert.equal(accept.targetId?.href, targetId.href); + assert.equal(accept.resultId?.href, authorizationId.href); + + const reject = likeInteraction.createReject({ + mode: "impolite", + id: new URL("https://example.net/rejects/1"), + actor: author, + interactingObject: like, + interactionTarget: target, + to: actor, + }); + + assert.equal(reject.objectId?.href, likeId.href); + assert.equal(reject.targetId?.href, targetId.href); + + const revocation = likeInteraction.createRevocation({ + id: new URL("https://example.net/deletes/1"), + actor: author, + authorization, + to: actor, + }); + + assert.equal(revocation.objectId?.href, authorizationId.href); +}); + +test("likeInteraction recognizes impolite Like activities", () => { + const target = new Note({ id: targetId, attribution: author }); + const like = new Like({ id: likeId, actor, object: target }); + + const recognized = likeInteraction.recognizeImpolite(like); + + assert.ok(recognized); + assert.equal(recognized.requester.href, actor.href); + assert.equal(recognized.interactingObjectId.href, likeId.href); + assert.equal(recognized.interactionTargetId.href, targetId.href); + assert.equal(recognized.evidence.type, "activity"); +}); + +test("likeInteraction derives stable keys", () => { + const target = new Note({ id: targetId, attribution: author }); + const like = new Like({ id: likeId, actor, object: target }); + const authorization = new LikeAuthorization({ + id: authorizationId, + attribution: author, + interactingObject: likeId, + interactionTarget: targetId, + }); + + const interactionKey = likeInteraction.getInteractionKey({ + requester: actor, + interactingObject: like, + interactionTarget: target, + }); + const authorizationKey = likeInteraction.getAuthorizationKey({ + authorization, + }); + + assert.equal( + formatInteractionKey(interactionKey), + JSON.stringify(["like", actor.href, likeId.href, targetId.href]), + ); + assert.equal( + formatAuthorizationKey(authorizationKey), + JSON.stringify(["like", authorizationId.href]), + ); +}); diff --git a/packages/interaction-controls/src/like.ts b/packages/interaction-controls/src/like.ts new file mode 100644 index 000000000..c44132960 --- /dev/null +++ b/packages/interaction-controls/src/like.ts @@ -0,0 +1,61 @@ +import { + Like, + LikeAuthorization, + LikeRequest, + type Object as ASObject, +} from "@fedify/vocab"; +import { + createInteractionControl, + getRequiredId, + idsEqual, + recognized, +} from "./control.ts"; +import type { InteractionControl } from "./types.ts"; + +export const likeInteraction: InteractionControl< + LikeRequest, + LikeAuthorization, + Like, + ASObject, + Like +> = createInteractionControl({ + name: "like", + policyProperty: "canLike", + requestClass: LikeRequest, + authorizationClass: LikeAuthorization, + getInteractingObject: (request, options) => + request.getInstrument(options) as Promise, + isInteractingObject: (object): object is Like => object instanceof Like, + interactingObjectTypes: [Like.typeId], + getInteractionTarget: (request, options) => + request.getObject(options) as Promise, + validateRequest: (_request, like, target, requester) => { + const targetId = getRequiredId(target, "interactionTarget"); + if (!idsEqual(like.objectId, targetId)) { + return { + type: "objectMismatch", + expected: targetId, + actual: like.objectId ?? undefined, + }; + } + if (!idsEqual(like.actorId, requester)) { + return { + type: "requesterMismatch", + expected: requester, + actual: like.actorId ?? undefined, + }; + } + return null; + }, + getSelfActor: (subject) => subject.attributionId, + defaultMissingPolicy: "automatic", + recognizeImpolite: (source) => + recognized({ + requester: source.actorId, + interactingObject: source, + interactionTarget: undefined, + interactionTargetId: source.objectId, + source, + evidence: { type: "activity", activityType: Like.typeId }, + }), +}); diff --git a/packages/interaction-controls/src/mod.ts b/packages/interaction-controls/src/mod.ts new file mode 100644 index 000000000..26cb314b6 --- /dev/null +++ b/packages/interaction-controls/src/mod.ts @@ -0,0 +1,9 @@ +export { announceInteraction } from "./announce.ts"; +export { featureInteraction } from "./feature.ts"; +export { formatAuthorizationKey, formatInteractionKey } from "./key.ts"; +export { likeInteraction } from "./like.ts"; +export { quoteInteraction } from "./quote.ts"; +export type { QuoteImpoliteSource, QuotePost } from "./quote.ts"; +export { replyInteraction } from "./reply.ts"; +export type { ReplyImpoliteSource, ReplyPost } from "./reply.ts"; +export type * from "./types.ts"; diff --git a/packages/interaction-controls/src/quote.test.ts b/packages/interaction-controls/src/quote.test.ts new file mode 100644 index 000000000..42bc2a9dc --- /dev/null +++ b/packages/interaction-controls/src/quote.test.ts @@ -0,0 +1,204 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import type { Context } from "@fedify/fedify"; +import { + InteractionPolicy, + InteractionRule, + Note, + PUBLIC_COLLECTION, + QuoteAuthorization, + QuoteRequest, +} from "@fedify/vocab"; +import { quoteInteraction } from "./mod.ts"; + +const context = {} as Context; +const actor = new URL("https://example.com/users/alice"); +const author = new URL("https://example.net/users/bob"); +const targetId = new URL("https://example.net/notes/1"); +const quoteId = new URL("https://example.com/notes/3"); +const authorizationId = new URL("https://example.net/authorizations/4"); +const verifyAuthenticity = () => true; + +test("quoteInteraction creates and verifies requests", async () => { + const target = new Note({ id: targetId, attribution: author }); + const quote = new Note({ + id: quoteId, + attribution: actor, + quote: targetId, + }); + const request = quoteInteraction.createRequest({ + id: new URL("https://example.com/requests/4"), + actor, + object: target, + instrument: quote, + }); + + assert.ok(request instanceof QuoteRequest); + + const result = await quoteInteraction.verifyRequest(context, { request }); + + assert.equal(result.verified, true); + assert.equal(result.requester.href, actor.href); + assert.equal(result.interactingObjectId.href, quoteId.href); + assert.equal(result.interactionTargetId.href, targetId.href); +}); + +test("quoteInteraction accepts compatible quoteUrl targets", async () => { + const target = new Note({ id: targetId, attribution: author }); + const quote = new Note({ + id: quoteId, + attribution: actor, + quoteUrl: targetId, + }); + const request = new QuoteRequest({ + id: new URL("https://example.com/requests/4"), + actor, + object: target, + instrument: quote, + }); + + const result = await quoteInteraction.verifyRequest(context, { request }); + + assert.equal(result.verified, true); + assert.equal(result.interactionTargetId.href, targetId.href); +}); + +test("quoteInteraction denies mismatched targets", async () => { + const target = new Note({ id: targetId, attribution: author }); + const quote = new Note({ + id: quoteId, + attribution: actor, + quote: new URL("https://example.net/notes/elsewhere"), + }); + const request = new QuoteRequest({ + id: new URL("https://example.com/requests/4"), + actor, + object: target, + instrument: quote, + }); + + const result = await quoteInteraction.verifyRequest(context, { request }); + + assert.equal(result.verified, false); + assert.equal(result.failure.type, "objectMismatch"); +}); + +test("quoteInteraction denies conflicting quote target fields", async () => { + const target = new Note({ id: targetId, attribution: author }); + const quote = new Note({ + id: quoteId, + attribution: actor, + quote: targetId, + quoteUrl: new URL("https://example.net/notes/elsewhere"), + }); + const request = new QuoteRequest({ + id: new URL("https://example.com/requests/4"), + actor, + object: target, + instrument: quote, + }); + + const result = await quoteInteraction.verifyRequest(context, { request }); + + assert.equal(result.verified, false); + assert.equal(result.failure.type, "objectMismatch"); +}); + +test("quoteInteraction denies mismatched requesters", async () => { + const target = new Note({ id: targetId, attribution: author }); + const quote = new Note({ + id: quoteId, + attribution: new URL("https://example.org/users/carol"), + quote: targetId, + }); + const request = new QuoteRequest({ + id: new URL("https://example.com/requests/4"), + actor, + object: target, + instrument: quote, + }); + + const result = await quoteInteraction.verifyRequest(context, { request }); + + assert.equal(result.verified, false); + assert.equal(result.failure.type, "requesterMismatch"); +}); + +test("quoteInteraction evaluates canQuote rules", async () => { + const target = new Note({ + id: targetId, + attribution: author, + interactionPolicy: new InteractionPolicy({ + canQuote: new InteractionRule({ manualApproval: PUBLIC_COLLECTION }), + }), + }); + + assert.deepEqual( + await quoteInteraction.evaluatePolicy(context, { + subject: target, + requester: actor, + }), + { + result: "manual", + reason: { type: "public" }, + }, + ); +}); + +test("quoteInteraction denies missing canQuote by default", async () => { + const target = new Note({ id: targetId, attribution: author }); + + assert.deepEqual( + await quoteInteraction.evaluatePolicy(context, { + subject: target, + requester: actor, + }), + { + result: "denied", + reason: { type: "missingPolicy" }, + }, + ); +}); + +test("quoteInteraction creates and verifies authorizations", async () => { + const target = new Note({ id: targetId, attribution: author }); + const quote = new Note({ + id: quoteId, + attribution: actor, + quote: targetId, + }); + const authorization = quoteInteraction.createAuthorization({ + id: authorizationId, + attributedTo: author, + interactingObject: quote, + interactionTarget: target, + }); + + assert.ok(authorization instanceof QuoteAuthorization); + + const result = await quoteInteraction.verifyAuthorization(context, { + authorization, + interactingObject: quote, + interactionTarget: target, + attributedTo: author, + verifyAuthenticity, + }); + + assert.equal(result.verified, true); +}); + +test("quoteInteraction recognizes bare quote objects", () => { + const quote = new Note({ + id: quoteId, + attribution: actor, + quote: targetId, + }); + + const recognized = quoteInteraction.recognizeImpolite(quote); + + assert.ok(recognized); + assert.equal(recognized.requester.href, actor.href); + assert.equal(recognized.interactingObjectId.href, quoteId.href); + assert.equal(recognized.interactionTargetId.href, targetId.href); + assert.equal(recognized.evidence.type, "property"); +}); diff --git a/packages/interaction-controls/src/quote.ts b/packages/interaction-controls/src/quote.ts new file mode 100644 index 000000000..501b49502 --- /dev/null +++ b/packages/interaction-controls/src/quote.ts @@ -0,0 +1,97 @@ +import { + Article, + ChatMessage, + Create, + Note, + type Object as ASObject, + Question, + QuoteAuthorization, + QuoteRequest, +} from "@fedify/vocab"; +import { + createInteractionControl, + getRequiredId, + idsEqual, + recognized, +} from "./control.ts"; +import type { InteractionControl } from "./types.ts"; + +export type QuotePost = Note | Article | Question | ChatMessage; + +export type QuoteImpoliteSource = Create | QuotePost; + +const QUOTE = new URL("https://w3id.org/fep/044f#quote"); + +function getQuoteTargetId(quote: QuotePost): URL | null { + return quote.quoteId ?? quote.quoteUrl; +} + +export const quoteInteraction: InteractionControl< + QuoteRequest, + QuoteAuthorization, + QuotePost, + ASObject, + QuoteImpoliteSource +> = createInteractionControl({ + name: "quote", + policyProperty: "canQuote", + requestClass: QuoteRequest, + authorizationClass: QuoteAuthorization, + getInteractingObject: (request, options) => + request.getInstrument(options) as Promise, + isInteractingObject: (object): object is QuotePost => + object instanceof Note || + object instanceof Article || + object instanceof Question || + object instanceof ChatMessage, + interactingObjectTypes: [ + Note.typeId, + Article.typeId, + Question.typeId, + ChatMessage.typeId, + ], + getInteractionTarget: (request, options) => + request.getObject(options) as Promise, + validateRequest: (_request, quote, target, requester) => { + const targetId = getRequiredId(target, "interactionTarget"); + if ( + quote.quoteId != null && quote.quoteUrl != null && + !idsEqual(quote.quoteUrl, quote.quoteId) + ) { + return { + type: "objectMismatch", + expected: quote.quoteId, + actual: quote.quoteUrl, + }; + } + const quoteTargetId = getQuoteTargetId(quote); + if (!idsEqual(quoteTargetId, targetId)) { + return { + type: "objectMismatch", + expected: targetId, + actual: quoteTargetId ?? undefined, + }; + } + if (!idsEqual(quote.attributionId, requester)) { + return { + type: "requesterMismatch", + expected: requester, + actual: quote.attributionId ?? undefined, + }; + } + return null; + }, + getSelfActor: (subject) => subject.attributionId, + defaultMissingPolicy: "denied", + recognizeImpolite: (source) => { + if (source instanceof Create) return null; + return recognized({ + requester: source.attributionId, + interactingObject: source, + interactionTarget: undefined, + interactionTargetId: getQuoteTargetId(source), + source, + evidence: { type: "property", property: QUOTE }, + }); + }, +}); diff --git a/packages/interaction-controls/src/reply.test.ts b/packages/interaction-controls/src/reply.test.ts new file mode 100644 index 000000000..a2f97db6b --- /dev/null +++ b/packages/interaction-controls/src/reply.test.ts @@ -0,0 +1,179 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import type { Context } from "@fedify/fedify"; +import { + InteractionPolicy, + InteractionRule, + Mention, + Note, + PUBLIC_COLLECTION, + ReplyAuthorization, + ReplyRequest, +} from "@fedify/vocab"; +import { replyInteraction } from "./mod.ts"; + +const context = {} as Context; +const actor = new URL("https://example.com/users/alice"); +const author = new URL("https://example.net/users/bob"); +const targetId = new URL("https://example.net/notes/1"); +const replyId = new URL("https://example.com/notes/2"); +const authorizationId = new URL("https://example.net/authorizations/2"); +const verifyAuthenticity = () => true; + +test("replyInteraction creates and verifies requests", async () => { + const target = new Note({ id: targetId, attribution: author }); + const reply = new Note({ + id: replyId, + attribution: actor, + replyTarget: targetId, + }); + const request = replyInteraction.createRequest({ + id: new URL("https://example.com/requests/2"), + actor, + object: target, + instrument: reply, + }); + + assert.ok(request instanceof ReplyRequest); + + const result = await replyInteraction.verifyRequest(context, { request }); + + assert.equal(result.verified, true); + assert.equal(result.requester.href, actor.href); + assert.equal(result.interactingObjectId.href, replyId.href); + assert.equal(result.interactionTargetId.href, targetId.href); +}); + +test("replyInteraction denies mismatched requesters", async () => { + const target = new Note({ id: targetId, attribution: author }); + const reply = new Note({ + id: replyId, + attribution: new URL("https://example.org/users/carol"), + replyTarget: targetId, + }); + const request = new ReplyRequest({ + id: new URL("https://example.com/requests/2"), + actor, + object: target, + instrument: reply, + }); + + const result = await replyInteraction.verifyRequest(context, { request }); + + assert.equal(result.verified, false); + assert.equal(result.failure.type, "requesterMismatch"); +}); + +test("replyInteraction evaluates canReply rules", async () => { + const target = new Note({ + id: targetId, + attribution: author, + interactionPolicy: new InteractionPolicy({ + canReply: new InteractionRule({ manualApproval: PUBLIC_COLLECTION }), + }), + }); + + assert.deepEqual( + await replyInteraction.evaluatePolicy(context, { + subject: target, + requester: actor, + }), + { + result: "manual", + reason: { type: "public" }, + }, + ); +}); + +test("replyInteraction auto-approves mentioned participants", async () => { + const target = new Note({ + id: targetId, + attribution: author, + tags: [new Mention({ href: actor })], + interactionPolicy: new InteractionPolicy({ + canReply: new InteractionRule({ manualApproval: PUBLIC_COLLECTION }), + }), + }); + + assert.deepEqual( + await replyInteraction.evaluatePolicy(context, { + subject: target, + requester: actor, + }), + { + result: "automatic", + reason: { type: "actor", actor }, + }, + ); +}); + +test("replyInteraction auto-approves replied-to actors", async () => { + const parentAuthor = new URL("https://example.org/users/carol"); + const parent = new Note({ + id: new URL("https://example.org/notes/parent"), + attribution: parentAuthor, + }); + const target = new Note({ + id: targetId, + attribution: author, + replyTarget: parent, + interactionPolicy: new InteractionPolicy({ + canReply: new InteractionRule({ manualApproval: PUBLIC_COLLECTION }), + }), + }); + + assert.deepEqual( + await replyInteraction.evaluatePolicy(context, { + subject: target, + requester: parentAuthor, + }), + { + result: "automatic", + reason: { type: "actor", actor: parentAuthor }, + }, + ); +}); + +test("replyInteraction creates and verifies authorizations", async () => { + const target = new Note({ id: targetId, attribution: author }); + const reply = new Note({ + id: replyId, + attribution: actor, + replyTarget: targetId, + }); + const authorization = replyInteraction.createAuthorization({ + id: authorizationId, + attributedTo: author, + interactingObject: reply, + interactionTarget: target, + }); + + assert.ok(authorization instanceof ReplyAuthorization); + + const result = await replyInteraction.verifyAuthorization(context, { + authorization, + interactingObject: reply, + interactionTarget: target, + attributedTo: author, + verifyAuthenticity, + }); + + assert.equal(result.verified, true); +}); + +test("replyInteraction recognizes bare reply objects", () => { + const target = new Note({ id: targetId, attribution: author }); + const reply = new Note({ + id: replyId, + attribution: actor, + replyTarget: target, + }); + + const recognized = replyInteraction.recognizeImpolite(reply); + + assert.ok(recognized); + assert.equal(recognized.requester.href, actor.href); + assert.equal(recognized.interactingObjectId.href, replyId.href); + assert.equal(recognized.interactionTargetId.href, targetId.href); + assert.equal(recognized.evidence.type, "property"); +}); diff --git a/packages/interaction-controls/src/reply.ts b/packages/interaction-controls/src/reply.ts new file mode 100644 index 000000000..9b1f49015 --- /dev/null +++ b/packages/interaction-controls/src/reply.ts @@ -0,0 +1,98 @@ +import { + Article, + ChatMessage, + Create, + Mention, + Note, + Object as ASObject, + Question, + ReplyAuthorization, + ReplyRequest, +} from "@fedify/vocab"; +import { + createInteractionControl, + getRequiredId, + idsEqual, + recognized, +} from "./control.ts"; +import type { InteractionControl } from "./types.ts"; +import type { DereferenceOptions } from "./control.ts"; + +export type ReplyPost = Note | Article | Question | ChatMessage; + +export type ReplyImpoliteSource = Create | ReplyPost; + +const IN_REPLY_TO = new URL("https://www.w3.org/ns/activitystreams#inReplyTo"); + +export const replyInteraction: InteractionControl< + ReplyRequest, + ReplyAuthorization, + ReplyPost, + ASObject, + ReplyImpoliteSource +> = createInteractionControl({ + name: "reply", + policyProperty: "canReply", + requestClass: ReplyRequest, + authorizationClass: ReplyAuthorization, + getInteractingObject: (request, options) => + request.getInstrument(options) as Promise, + isInteractingObject: (object): object is ReplyPost => + object instanceof Note || + object instanceof Article || + object instanceof Question || + object instanceof ChatMessage, + interactingObjectTypes: [ + Note.typeId, + Article.typeId, + Question.typeId, + ChatMessage.typeId, + ], + getInteractionTarget: (request, options) => + request.getObject(options) as Promise, + validateRequest: (_request, reply, target, requester) => { + const targetId = getRequiredId(target, "interactionTarget"); + if (!idsEqual(reply.replyTargetId, targetId)) { + return { + type: "objectMismatch", + expected: targetId, + actual: reply.replyTargetId ?? undefined, + }; + } + if (!idsEqual(reply.attributionId, requester)) { + return { + type: "requesterMismatch", + expected: requester, + actual: reply.attributionId ?? undefined, + }; + } + return null; + }, + getSelfActor: (subject) => subject.attributionId, + getImplicitAutomaticActors: getReplyParticipants, + defaultMissingPolicy: "automatic", + recognizeImpolite: (source) => { + if (source instanceof Create) return null; + return recognized({ + requester: source.attributionId, + interactingObject: source, + interactionTarget: undefined, + interactionTargetId: source.replyTargetId, + source, + evidence: { type: "property", property: IN_REPLY_TO }, + }); + }, +}); + +async function* getReplyParticipants( + subject: ASObject, + options: DereferenceOptions, +): AsyncIterable { + const replyTarget = await subject.getReplyTarget(options); + if (replyTarget instanceof ASObject && replyTarget.attributionId != null) { + yield replyTarget.attributionId; + } + for await (const tag of subject.getTags(options)) { + if (tag instanceof Mention && tag.href != null) yield tag.href; + } +} diff --git a/packages/interaction-controls/src/types.test.ts b/packages/interaction-controls/src/types.test.ts new file mode 100644 index 000000000..d4a779298 --- /dev/null +++ b/packages/interaction-controls/src/types.test.ts @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import type { + InteractionAuthorizationVerificationFailure, + InteractionKey, + InteractionPolicyDecision, + InteractionRequestCreationOptions, +} from "./mod.ts"; +import type { Object as ASObject } from "@fedify/vocab"; + +test("public types expose readonly fields", () => { + const key: InteractionKey = { + interaction: "quote", + requester: new URL("https://example.com/actors/1"), + interactingObjectId: new URL("https://example.com/objects/1"), + interactionTargetId: new URL("https://example.net/objects/1"), + }; + + // @ts-expect-error Interaction keys are immutable. + key.requester = new URL("https://example.com/actors/2"); + + const requestOptions: InteractionRequestCreationOptions = + { + id: new URL("https://example.com/requests/1"), + actor: new URL("https://example.com/actors/1"), + object: new URL("https://example.net/objects/1"), + instrument: new URL("https://example.com/objects/1"), + to: [new URL("https://example.net/actors/1")], + }; + + // @ts-expect-error Option arrays are immutable. + requestOptions.to?.push(new URL("https://example.net/actors/2")); + + const decision: InteractionPolicyDecision = { + result: "automatic", + reason: { type: "default", default: "publicAutomatic" }, + }; + + // @ts-expect-error Policy decisions are immutable. + decision.result = "denied"; + + const failure: InteractionAuthorizationVerificationFailure = { + category: "unauthorized", + type: "wrongType", + expectedType: new URL("https://example.com/Expected"), + actualTypes: [new URL("https://example.com/Actual")], + }; + + // @ts-expect-error Failure arrays are immutable. + failure.actualTypes.push(new URL("https://example.com/Other")); + + assert.equal(key.interaction, "quote"); +}); diff --git a/packages/interaction-controls/src/types.ts b/packages/interaction-controls/src/types.ts new file mode 100644 index 000000000..c4bba3f5b --- /dev/null +++ b/packages/interaction-controls/src/types.ts @@ -0,0 +1,462 @@ +import type { Context } from "@fedify/fedify"; +import type { DocumentLoader } from "@fedify/vocab-runtime"; +import type { Temporal } from "temporal-polyfill"; +import type { + Accept, + Activity, + Delete, + Object as ASObject, + Reject, +} from "@fedify/vocab"; + +export type InteractionName = + | "like" + | "reply" + | "announce" + | "quote" + | "feature"; + +export type InteractionPolicyProperty = + | "canLike" + | "canReply" + | "canAnnounce" + | "canQuote" + | "canFeature"; + +export interface InteractionControl< + TRequest extends Activity, + TAuthorization extends ASObject, + TInteracting extends ASObject, + TTarget extends ASObject, + TImpoliteSource extends ASObject, +> { + readonly name: InteractionName; + readonly policyProperty: InteractionPolicyProperty; + readonly requestTypeId: URL; + readonly authorizationTypeId: URL; + readonly verifyRequest: ( + context: Context, + options: InteractionRequestVerificationOptions, + ) => Promise< + InteractionRequestVerification< + TRequest, + TInteracting, + TTarget + > + >; + readonly evaluatePolicy: ( + context: Context, + options: InteractionPolicyEvaluationOptions, + ) => Promise; + readonly createRequest: ( + options: InteractionRequestCreationOptions, + ) => TRequest; + readonly createAuthorization: ( + options: InteractionAuthorizationCreationOptions, + ) => TAuthorization; + readonly verifyAuthorization: ( + context: Context, + options: InteractionAuthorizationVerificationOptions< + TContextData, + TAuthorization, + TInteracting, + TTarget + >, + ) => Promise>; + readonly createAccept: ( + options: InteractionAcceptOptions< + TRequest, + TAuthorization, + TInteracting, + TTarget + >, + ) => Accept; + readonly createReject: ( + options: InteractionRejectOptions, + ) => Reject; + readonly createRevocation: ( + options: InteractionRevocationCreationOptions, + ) => Delete; + readonly recognizeImpolite: ( + source: TImpoliteSource, + ) => + | RecognizedImpoliteInteraction< + TInteracting, + TTarget, + TImpoliteSource + > + | null; + readonly getInteractionKey: ( + input: InteractionKeyInput, + ) => InteractionKey; + readonly getAuthorizationKey: ( + input: InteractionAuthorizationKeyInput, + ) => InteractionAuthorizationKey; +} + +export interface InteractionRequestVerificationOptions< + TRequest extends Activity, +> { + readonly request: TRequest | URL; + readonly documentLoader?: DocumentLoader; +} + +export type InteractionRequestVerification< + TRequest extends Activity, + TInteracting extends ASObject, + TTarget extends ASObject, +> = + | { + readonly verified: true; + readonly request: TRequest; + readonly requestId: URL; + readonly requester: URL; + readonly interactingObject: TInteracting; + readonly interactingObjectId: URL; + readonly interactionTarget: TTarget; + readonly interactionTargetId: URL; + } + | { + readonly verified: false; + readonly failure: InteractionRequestVerificationFailure; + readonly request?: TRequest; + readonly requestId?: URL; + }; + +export type InteractionRequestVerificationFailure = + | { + readonly category: "unverifiable"; + readonly type: "notDereferenceable"; + readonly url: URL; + readonly cause?: unknown; + } + | { + readonly category: "unverifiable"; + readonly type: "unauthorizedFetchRequired"; + readonly url: URL; + } + | { + readonly category: "unverifiable"; + readonly type: "invalidJsonLd"; + readonly cause?: unknown; + } + | { + readonly category: "invalid"; + readonly type: "wrongType"; + readonly expectedType: URL; + readonly actualTypes: readonly URL[]; + } + | { + readonly category: "invalid"; + readonly type: "wrongInstrumentType"; + readonly expectedTypes: readonly URL[]; + readonly actualTypes: readonly URL[]; + } + | { + readonly category: "invalid"; + readonly type: "wrongObjectType"; + readonly expectedTypes: readonly URL[]; + readonly actualTypes: readonly URL[]; + } + | { + readonly category: "invalid"; + readonly type: + | "missingId" + | "missingActor" + | "missingObject" + | "missingObjectId" + | "missingInstrument" + | "missingInstrumentId"; + } + | { + readonly category: "invalid"; + readonly type: "idMismatch" | "objectMismatch" | "instrumentMismatch"; + readonly expected: URL; + readonly actual?: URL; + } + | { + readonly category: "unauthorized"; + readonly type: "requesterMismatch"; + readonly expected: URL; + readonly actual?: URL; + }; + +export interface InteractionPolicyEvaluationOptions< + TContextData, + TTarget extends ASObject, +> { + readonly subject: TTarget; + readonly requester: URL; + readonly documentLoader?: DocumentLoader; + readonly matchesApprovalCollection?: MatchesApprovalCollection; +} + +export type MatchesApprovalCollection = ( + collection: URL, + actor: URL, + context: Context, +) => boolean | Promise; + +export type InteractionPolicyDecision = + | { + readonly result: "automatic"; + readonly reason: InteractionPolicyMatchReason; + } + | { + readonly result: "manual"; + readonly reason: InteractionPolicyMatchReason; + } + | { + readonly result: "denied"; + readonly reason: InteractionPolicyDenialReason; + }; + +export type InteractionPolicyMatchReason = + | { readonly type: "self" } + | { readonly type: "default"; readonly default: "publicAutomatic" } + | { readonly type: "public" } + | { readonly type: "actor"; readonly actor: URL } + | { readonly type: "collection"; readonly collection: URL }; + +export type InteractionPolicyDenialReason = + | { readonly type: "missingPolicy" } + | { readonly type: "missingRule" } + | { readonly type: "noMatch" } + | { readonly type: "unverifiableCollection"; readonly collection: URL }; + +export interface InteractionRequestCreationOptions< + TInteracting extends ASObject, + TTarget extends ASObject, +> { + readonly id: URL; + readonly actor: URL; + readonly object: TTarget | URL; + readonly instrument: TInteracting | URL; + readonly to?: URL | readonly URL[]; + readonly cc?: URL | readonly URL[]; +} + +export interface InteractionAuthorizationCreationOptions< + TInteracting extends ASObject, + TTarget extends ASObject, +> { + readonly id: URL; + readonly attributedTo?: URL; + readonly interactingObject: TInteracting | URL; + readonly interactionTarget: TTarget | URL; +} + +export interface InteractionAuthorizationVerificationOptions< + TContextData, + TAuthorization extends ASObject, + TInteracting extends ASObject, + TTarget extends ASObject, +> { + readonly authorization: TAuthorization | URL; + readonly interactingObject: TInteracting | URL; + readonly interactionTarget: TTarget | URL; + readonly attributedTo?: URL; + readonly documentLoader?: DocumentLoader; + readonly getRevocation?: GetInteractionAuthorizationRevocation; + readonly verifyAuthenticity?: ( + authorization: TAuthorization, + context: Context, + ) => boolean | Promise; +} + +export type InteractionAuthorizationVerification< + TAuthorization extends ASObject, +> = + | { + readonly verified: true; + readonly authorization: TAuthorization; + readonly authorizationId: URL; + } + | { + readonly verified: false; + readonly failure: InteractionAuthorizationVerificationFailure; + readonly authorization?: TAuthorization; + readonly authorizationId?: URL; + }; + +export type InteractionAuthorizationVerificationFailure = + | { + readonly category: "unverifiable"; + readonly type: "notDereferenceable"; + readonly url: URL; + readonly cause?: unknown; + } + | { + readonly category: "unverifiable"; + readonly type: "unauthorizedFetchRequired"; + readonly url: URL; + } + | { + readonly category: "unverifiable"; + readonly type: "invalidJsonLd"; + readonly cause?: unknown; + } + | { + readonly category: "unauthorized"; + readonly type: "wrongType"; + readonly expectedType: URL; + readonly actualTypes: readonly URL[]; + } + | { + readonly category: "unauthorized"; + readonly type: "missingId" | "missingObjectId" | "missingTargetId"; + } + | { + readonly category: "unauthorized"; + readonly type: "missingAttribution"; + } + | { + readonly category: "unauthorized"; + readonly type: "idMismatch" | "objectMismatch" | "targetMismatch"; + readonly expected: URL; + readonly actual?: URL; + } + | { + readonly category: "unauthorized"; + readonly type: "attributionMismatch"; + readonly expected: URL; + readonly actual?: URL; + } + | { + readonly category: "unauthorized"; + readonly type: "originMismatch"; + readonly expectedOrigin: string; + readonly actualOrigin: string; + } + | { + readonly category: "unauthorized"; + readonly type: "notAuthentic"; + readonly detail?: string; + readonly cause?: unknown; + } + | { + readonly category: "revoked"; + readonly type: "deleted"; + readonly revoker?: URL; + readonly revoked?: Temporal.Instant; + readonly activity?: Delete; + }; + +export type GetInteractionAuthorizationRevocation = ( + authorizationId: URL, + context: Context, +) => + | InteractionAuthorizationRevocation + | null + | Promise; + +export interface InteractionAuthorizationRevocation { + readonly revoker?: URL; + readonly revoked?: Temporal.Instant; + readonly activity?: Delete; +} + +export type InteractionAcceptOptions< + TRequest extends Activity, + TAuthorization extends ASObject, + TInteracting extends ASObject, + TTarget extends ASObject, +> = + | { + readonly mode: "polite"; + readonly id: URL; + readonly actor: URL; + readonly request: TRequest | URL; + readonly authorization: TAuthorization | URL; + readonly to: URL | readonly URL[]; + readonly cc?: URL | readonly URL[]; + } + | { + readonly mode: "impolite"; + readonly id: URL; + readonly actor: URL; + readonly interactingObject: TInteracting | URL; + readonly interactionTarget: TTarget | URL; + readonly authorization: TAuthorization | URL; + readonly to: URL | readonly URL[]; + readonly cc?: URL | readonly URL[]; + }; + +export type InteractionRejectOptions< + TRequest extends Activity, + TInteracting extends ASObject, + TTarget extends ASObject, +> = + | { + readonly mode: "polite"; + readonly id: URL; + readonly actor: URL; + readonly request: TRequest | URL; + readonly to: URL | readonly URL[]; + readonly cc?: URL | readonly URL[]; + } + | { + readonly mode: "impolite"; + readonly id: URL; + readonly actor: URL; + readonly interactingObject: TInteracting | URL; + readonly interactionTarget: TTarget | URL; + readonly to: URL | readonly URL[]; + readonly cc?: URL | readonly URL[]; + }; + +export interface InteractionRevocationCreationOptions< + TAuthorization extends ASObject, +> { + readonly id: URL; + readonly actor: URL; + readonly authorization: TAuthorization | URL; + readonly to: URL | readonly URL[]; + readonly cc?: URL | readonly URL[]; +} + +export interface RecognizedImpoliteInteraction< + TInteracting extends ASObject, + TTarget extends ASObject, + TImpoliteSource extends ASObject, +> { + readonly requester: URL; + readonly interactingObject: TInteracting; + readonly interactingObjectId: URL; + readonly interactionTarget?: TTarget; + readonly interactionTargetId: URL; + readonly source: TImpoliteSource; + readonly evidence: ImpoliteInteractionEvidence; +} + +export type ImpoliteInteractionEvidence = + | { readonly type: "activity"; readonly activityType: URL } + | { readonly type: "property"; readonly property: URL } + | { readonly type: "linkRel"; readonly rel: URL }; + +export interface InteractionKeyInput< + TInteracting extends ASObject, + TTarget extends ASObject, +> { + readonly requester: URL; + readonly interactingObject: TInteracting | URL; + readonly interactionTarget: TTarget | URL; +} + +export interface InteractionKey { + readonly interaction: string; + readonly requester: URL; + readonly interactingObjectId: URL; + readonly interactionTargetId: URL; +} + +export interface InteractionAuthorizationKeyInput< + TAuthorization extends ASObject, +> { + readonly authorization: TAuthorization | URL; +} + +export interface InteractionAuthorizationKey { + readonly interaction: string; + readonly authorizationId: URL; +} diff --git a/packages/interaction-controls/tsdown.config.ts b/packages/interaction-controls/tsdown.config.ts new file mode 100644 index 000000000..727829a59 --- /dev/null +++ b/packages/interaction-controls/tsdown.config.ts @@ -0,0 +1,44 @@ +import { defineConfig } from "tsdown"; +import { + temporalPolyfillCjsBanner, + temporalPolyfillCjsDeps, + temporalPolyfillEsmBanner, + temporalPolyfillImportPlugin, +} from "../../scripts/tsdown/temporal.mts"; + +export default defineConfig({ + entry: "src/mod.ts", + dts: { compilerOptions: { isolatedDeclarations: true, declaration: true } }, + format: { + esm: { + banner: temporalPolyfillEsmBanner(), + }, + cjs: { + deps: temporalPolyfillCjsDeps({ + neverBundle: [ + "@fedify/fedify", + "@fedify/fedify/federation", + "@fedify/vocab", + "@fedify/vocab-runtime", + ], + }), + plugins: [temporalPolyfillImportPlugin], + banner: temporalPolyfillCjsBanner(), + }, + }, + platform: "node", + outExtensions({ format }) { + return { + js: format === "cjs" ? ".cjs" : ".js", + dts: format === "cjs" ? ".d.cts" : ".d.ts", + }; + }, + deps: { + neverBundle: [ + "@fedify/fedify", + "@fedify/fedify/federation", + "@fedify/vocab", + "@fedify/vocab-runtime", + ], + }, +}); diff --git a/packages/testing/package.json b/packages/testing/package.json index 0a4f17a51..af50b60e7 100644 --- a/packages/testing/package.json +++ b/packages/testing/package.json @@ -50,6 +50,8 @@ "@fedify/fedify": "workspace:^" }, "dependencies": { + "@fedify/vocab": "workspace:*", + "@fedify/vocab-runtime": "workspace:^", "es-toolkit": "catalog:", "temporal-polyfill": "catalog:" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index acb0e15a5..71bbb8baf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -232,6 +232,9 @@ importers: '@fedify/hono': specifier: workspace:^ version: link:../packages/hono + '@fedify/interaction-controls': + specifier: workspace:^ + version: link:../packages/interaction-controls '@fedify/koa': specifier: workspace:^ version: link:../packages/koa @@ -1409,6 +1412,28 @@ importers: specifier: 'catalog:' version: 6.0.3 + packages/interaction-controls: + dependencies: + '@fedify/fedify': + specifier: workspace:^ + version: link:../fedify + '@fedify/vocab': + specifier: workspace:* + version: link:../vocab + '@fedify/vocab-runtime': + specifier: workspace:^ + version: link:../vocab-runtime + temporal-polyfill: + specifier: 'catalog:' + version: 1.0.1 + devDependencies: + tsdown: + specifier: 'catalog:' + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) + typescript: + specifier: 'catalog:' + version: 6.0.3 + packages/koa: dependencies: '@fedify/fedify': @@ -1741,6 +1766,12 @@ importers: '@fedify/fedify': specifier: workspace:^ version: link:../fedify + '@fedify/vocab': + specifier: workspace:* + version: link:../vocab + '@fedify/vocab-runtime': + specifier: workspace:^ + version: link:../vocab-runtime es-toolkit: specifier: 'catalog:' version: 1.46.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e746f204e..67bd57ed8 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -14,6 +14,7 @@ packages: - packages/h3 - packages/hono - packages/init +- packages/interaction-controls - packages/koa - packages/lint - packages/mysql