Skip to content

Capability-based activities cannot be handled by app backends #448

Description

@srosset81

Issue

A capability attached to an activity is deliberately kept in memory and never persisted:

// activitypub/services/activitypub/subservices/outbox.ts, post()
// There might be a capability attached which cannot be persisted, if it has a non-resolvable id.
// So we won't persist it and re-attach it afterwards.
let { capability, ...activityToPersist } = activity;
// activitypub/services/activitypub/subservices/outbox.ts, localPost()
// Leave the capability separate because we don't want to store it.
const { capability, ...activity } = activityToPost;

Services running inside the Pod are unaffected: processInbox receives the whole activity,
verifyCapabilityIntegrity runs, and any processor declaring capabilityGrantMatchFnGenerator
gets its grants checked. This is how the Pod provider's own contact invite links work.

ActivityPods app backends cannot do the same. An app learns about an activity through a Solid
notification that carries only its URI, then re-fetches it from the Pod
(pod-activities-watcher.processWebhook). By then the capability is gone.

Verified against a real Pod provider — a Join posted with both capability (a presentation
object) and instrument (the credential URI), read back from the sender's outbox:

POST outbox -> 201
GET of the stored activity -> 200
{ "type": "Join", "actor": ".../linkguest", "object": "...", "to": ".../linkorga",
  "instrument": "http://localhost:3000/linkorga/data/d0a60a68-..." }

capability survived?  false
instrument survived?  "http://localhost:3000/linkorga/data/d0a60a68-..."

Same result when posting with the Pod's full JSON-LD context, so this is the deliberate stripping
above and not a JSON-LD term resolution problem (capability is defined, as sec:capability).

Consequence: any feature where an app must decide something on the strength of a capability has to
smuggle the credential URI through another predicate and re-verify it itself — duplicating what the
Pod already did, and without the presentation's proof, which is precisely the part an app cannot
re-check (the challenge is single-use and already consumed on the Pod).

Real case: "anyone with the link" event sharing in Welcome to my place, where the app backend must
decide whether to accept a Join from someone who was never invited.

Components affected

  • @semapps/activitypubactivitypub.outbox (post, localPost), activitypub.inbox (post)
  • @semapps/solidsolid-notifications.provider, notification-channel.mixin
  • @activitypods/apppod-activities-watcher, pod-activities-handler (consumer side)

Proposal

Half the plumbing already exists. The notification mixin already accepts a whole activity in place
of a URI (used today for unfetchable Mastodon activities):

// solid/services/notifications/channels/notification-channel.mixin.ts
const { collectionUri, itemUri, item } = ctx.params;
this.onContainerOrCollectionEvent(collectionUri, itemUri || item, ACTIVITY_TYPES.ADD);

And the app side already handles both shapes, so apps would need no change at all:

// @activitypods/app — pod-activities-watcher.js
const activity = isURL(object) ? await fetcher(object) : object;

Option A — transport the capability. When an activity carries one, emit
activitypub.collection.added with the in-memory activity as item, so the notification carries
it. Simple, but the presentation would then reach every listener registered on that inbox, not
just the app the activity concerns.

Option B — transport the verification result (preferred). Ship what the app actually needs
rather than bearer material, e.g. capability: { verified: true, verifiableCredential: [<uris>] }.
Nothing replayable is broadcast, and the Pod stays the single place where verification happens —
which it has to be, since it is the only holder of the challenge.

Complement. Give PodActivitiesHandlerMixin the same capabilityGrantMatchFnGenerator support
that ActivitiesHandlerMixin already has, so an app declares the grant it requires and the
framework enforces it, instead of every app hand-rolling the check.

Related

inbox.post catches errors from processInbox and only logs them, and stores the activity anyway
— so a failed verifyCapabilityIntegrity does not stop delivery. An app cannot treat "it reached
me" as "the Pod validated it", which is worth stating explicitly in whichever option is chosen.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions