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/activitypub — activitypub.outbox (post, localPost), activitypub.inbox (post)
@semapps/solid — solid-notifications.provider, notification-channel.mixin
@activitypods/app — pod-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.
Issue
A capability attached to an activity is deliberately kept in memory and never persisted:
Services running inside the Pod are unaffected:
processInboxreceives the whole activity,verifyCapabilityIntegrityruns, and any processor declaringcapabilityGrantMatchFnGeneratorgets 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
Joinposted with bothcapability(a presentationobject) and
instrument(the credential URI), read back from the sender's outbox: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 (
capabilityis defined, assec: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
Joinfrom someone who was never invited.Components affected
@semapps/activitypub—activitypub.outbox(post,localPost),activitypub.inbox(post)@semapps/solid—solid-notifications.provider,notification-channel.mixin@activitypods/app—pod-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):
And the app side already handles both shapes, so apps would need no change at all:
Option A — transport the capability. When an activity carries one, emit
activitypub.collection.addedwith the in-memory activity asitem, so the notification carriesit. 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
PodActivitiesHandlerMixinthe samecapabilityGrantMatchFnGeneratorsupportthat
ActivitiesHandlerMixinalready has, so an app declares the grant it requires and theframework enforces it, instead of every app hand-rolling the check.
Related
inbox.postcatches errors fromprocessInboxand only logs them, and stores the activity anyway— so a failed
verifyCapabilityIntegritydoes not stop delivery. An app cannot treat "it reachedme" as "the Pod validated it", which is worth stating explicitly in whichever option is chosen.