Compare commits
1 Commits
ea359488b6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e2df2478b |
@@ -8,7 +8,7 @@ against: 66616484b96fb2227cd8cdf02321aacfb4811365
|
||||
|
||||
Festipod is a mobile-first web application: users create **meeting points** grafted onto public events, and sign up to them. Its entire persistence goes through Refine, behind a **backend seam** that exists so the app can support several Refine backends and let the user switch between them at runtime. This package is one of the two backends targeted; the other is a Refine provider over NextGraph plus its polyfill. The seam's own contract — what a backend must answer before it can be added — lives in code, in `src/shared/refine/providers/types.ts`.
|
||||
|
||||
The seam is wired since 2026-10-09 (T04.e): this backend is the registered default, and Festipod reads and writes through it — `useList` over the shared `as:Event` container (split into events and meeting points by the bindings' `isInstance`), creates and updates through the data provider, and the embedded `apods:attendees` collection through the participation binding's `membersOf`. Joins/leaves are the one write beside Refine (the package implements no `custom`): an `as:Join`/`as:Leave` posted to the user's own outbox by `src/shared/data/outbox.ts`, naming the meeting point with no addressee, so that only its author and the app can read it. What the list below declares is therefore measured where a runtime check exists and declared elsewhere; `against:` names the observation's commit, and the package publishes no engagement, so there is nothing to pull and nothing that can drift — what is recorded here is read against our own reconstruction, and re-read when it is re-stamped.
|
||||
The seam is wired since 2026-10-09 (T04.e): this backend is the registered default, and Festipod reads and writes through it — `useList` over the shared `as:Event` container (split into events and meeting points by the bindings' `isInstance`), creates and updates through the data provider, and the embedded `apods:attendees` collection through the participation binding's `membersOf`. Joins/leaves are the one write beside Refine (the package implements no `custom`): an `as:Join`/`as:Leave` posted to the user's own outbox by `src/shared/data/outbox.ts`, naming the meeting point and addressed to the Festipod app actor alone, so that only its author and the app can read it; the pod delivers it to the app's inbox. What the list below declares is therefore measured where a runtime check exists and declared elsewhere; `against:` names the observation's commit, and the package publishes no engagement, so there is nothing to pull and nothing that can drift — what is recorded here is read against our own reconstruction, and re-read when it is re-stamped.
|
||||
|
||||
## Consumed surface
|
||||
|
||||
|
||||
@@ -29,4 +29,5 @@ The rule states the **what**, never the **how** — and that is intended. Which
|
||||
|
||||
- [[knowledge_data-scopes-and-discovery]] — the scopes model this rule tightens (participation: public as an object, the identity link is protected)
|
||||
- [[decision_2026-10-29_participation-privacy-mechanism]] — the mechanism enforcing this rule on ActivityPods (private Join, hashed member URNs, contacts-readable participations collection)
|
||||
- [[decision_2026-11-03_participation-delivered-to-the-app]] — how that private Join reaches the backend: addressed to the app actor alone, delivered to the app's inbox
|
||||
- [[decision_2026-10-21_contacts-are-the-pod-social-graph]] — what "contact" means (the Pod's bilateral `apods:contacts`)
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
---
|
||||
type: decision
|
||||
summary: Join/Leave are addressed to the Festipod app actor alone and handled on reception in the app's inbox, no longer picked up unaddressed by an outbox webhook; any compatible sender may send, authorship rests on the signature and the pod, the meeting point is read in its creator's pod
|
||||
---
|
||||
|
||||
# Decision (2026-11-03): participation activities are delivered to the app
|
||||
|
||||
User-validated 2026-11-03, on the advice of the ActivityPods developer: an activity meant for an actor who acts on it is addressed to that actor and reaches it through its inbox — the ActivityPub protocol rather than a side channel. This replaces steps 1 and 2 (the unaddressed sign-up, "the backend acts on emission") of [[decision_2026-10-29_participation-privacy-mechanism]]; every other step of that mechanism (member URN, secret, participations collection, recognition, anonymous notification) and all its residuals stand.
|
||||
|
||||
## The delivery
|
||||
|
||||
- The participant posts the `as:Join` (withdrawal: `as:Leave`) to their own outbox, naming the meeting point, with `to` = the Festipod app actor (`<backend>/as/actor/app`, which is also the Solid-OIDC client id) and no other addressee. Their pod delivers it to the app's inbox.
|
||||
- The backend handles it with `onReceive` of `@semapps/activitypub`'s `ActivitiesHandlerMixin` (`registration.service.js`), the path `@activitypods/app` itself uses for `Create interop:ApplicationRegistration`. No reference app receives user activities in the app's inbox: this is a named exception to the project rule `festipod-only-reference-app-patterns`, justified by the platform developer's advice and by the framework's own use of that path.
|
||||
- The outbox webhook (`apods:ReadOutbox`) stays: the catalog, the contacts and the attendee lists still follow the user's own resources there, which have no actor to address.
|
||||
|
||||
## Who may send: anyone, nobody for someone else
|
||||
|
||||
- **No "app installed" gate** (user decision): interoperability is the aim of the ActivityPods architecture, and a Join sent by another compatible app is legitimate. The processing filters by what it needs: a sender whose profile secret the backend cannot read is refused, nothing written.
|
||||
- **Authorship.** The inbox accepts a delivery only when it is signed by the activity's `actor` (semapps 1.2.7 `inbox.post` refuses any other actor, `INVALID_ACTOR`; read in code 2026-11-03). The handler further requires the activity to lie in that actor's pod and its stored copy to name them, and acts on the stored copy (`verifiedDelivery` in `emitted-activity.js`). A pod refuses writes under another actor's path, so nobody can join or leave for someone else.
|
||||
- **The meeting point is read in its creator's pod.** Read through the participant's pod, a resource of another pod goes through the proxy the participant's own actor document declares: for a participant on another server, an answer that server chooses — another meeting point's attendee list, a forged name (reproduced by the adversarial review 2026-11-03 against mocked platform calls). The handler reads the meeting point again through its creator's access and uses that copy alone (`meetingPointOf`).
|
||||
|
||||
## Confidentiality: unchanged
|
||||
|
||||
The Join stays readable by its author and its only addressee, the app; the creator's inbox receives nothing. The rights matrix lines on the Join's read rights pass unchanged (148/148 hard lines, run 2026-11-03 with the change as committed, creator re-read included). The backend learns the participant's identity as before.
|
||||
|
||||
## Costs accepted
|
||||
|
||||
- The inbox POST awaits the processing (semapps `processInbox` waits for its job), so each Join/Leave delivery holds the participant's pod's request for the whole handling: 2.6–6.2 s over the 9 deliveries of a full validation run (measured 2026-11-03 on the local stack, creator re-read included, from the queue's job timestamps), against 0.7–1.5 s for the app's other inbox jobs. The app's inbox queue runs 5 jobs at a time, shared with app registrations. Whether the pod provider serializes its outgoing deliveries is not verified.
|
||||
- The app's inbox stores a copy of every Join/Leave in the backend's dataset — the person ↔ meeting point link the attendance ledger already holds. An anonymous GET of the app's inbox answers 404 (observed 2026-11-03).
|
||||
- Known exposure, not introduced here and not settled: the backend issues app-signed GETs to URIs a sender chooses — the meeting point it names, its own documents — as the framework's app-registration handler already does with the registration URI a delivery names. Festipod filters no private or internal address; whether the framework does is not verified.
|
||||
|
||||
## Open
|
||||
|
||||
How to welcome a participant from another app, whose secret the backend cannot read — refused today. Leads: count them under a member URN only the backend can compute (counted, recognised by nobody); agree with the ActivityPods developer on a shared vocabulary for the secret; another policy. A product decision, not taken.
|
||||
@@ -12,4 +12,4 @@ created: 2026-10-28
|
||||
|
||||
**Why:** La validation live du 2026-10-28 a montré que tout chemin de plateforme non exercé par une app de référence est un terrain miné : le provisioning holder-side navigateur (création de conteneur dans `as/collection` + PATCH `/_acl` avec un token de session Solid-OIDC `azp`) a révélé deux bugs fournisseurs (500 après création effective du conteneur ; hang sans écriture dans `webacl.resource.addRights`), alors que les chemins utilisés par les apps de référence fonctionnent en production. Aucune app de référence ne provisionne d'infrastructure depuis le frontend : toute écriture pod hors données (conteneur, ACL, groupe, collection) se fait **backend-side, signée app-actor** via `signature.proxy.query`.
|
||||
|
||||
**How to apply:** Références canoniques (2026-10-28, les mieux maintenues, ajoutées au dossier) : **`welcometomyplace`, `mutual-aid.app`, `portejunes`** — en priorité welcometomyplace, seule à exercer les patterns sociaux avancés. Secondaires : `mastopod` (access needs simples), `refine-providers/examples/antd-app` (frontend), et les services du app-framework (`activitypods/app-framework/app`). Avant toute écriture pod, vérifier le pattern dans ces sources ; s'il est absent des trois canoniques, ne pas coder : chercher une composition de patterns existants, et à défaut demander la décision utilisateur en nommant l'exception. Patterns welcometomyplace que Festipod réutilise : traiter un Join/Leave sur `onEmit` de l'outbox du participant (`registration.service.js`) ; collection lisible par un groupe = `pod-permissions.add({agentPredicate: 'acl:agentGroup', mode: 'acl:Read'})` sur une collection `pod-collections` (`attendees.service.js`), le groupe `/contacts` d'un utilisateur trouvé par `pod-wac-groups.getUriFromCollectionUri({collectionUri: apods:contacts})` — jamais un droit par élément de la liste `apods:contacts`, falsifiable par un `Accept` forgé ; notifier = `pod-notifications.send`, qui exige un backend en WebACL activé sur un dataset sécurisé, comme les trois apps de référence. Ne JAMAIS reprendre leur `addMember` de l'organisateur dans le groupe `/contacts` du participant : c'est un choix produit de welcometomyplace (événements privés), une fuite d'identité sous la règle de Festipod (`behavior_participation-listing-is-contacts-only`). Couvre l'usage de la plateforme, pas les choix produit propres à Festipod.
|
||||
**How to apply:** Références canoniques (2026-10-28, les mieux maintenues, ajoutées au dossier) : **`welcometomyplace`, `mutual-aid.app`, `portejunes`** — en priorité welcometomyplace, seule à exercer les patterns sociaux avancés. Secondaires : `mastopod` (access needs simples), `refine-providers/examples/antd-app` (frontend), et les services du app-framework (`activitypods/app-framework/app`). Avant toute écriture pod, vérifier le pattern dans ces sources ; s'il est absent des trois canoniques, ne pas coder : chercher une composition de patterns existants, et à défaut demander la décision utilisateur en nommant l'exception. Patterns welcometomyplace que Festipod réutilise : traiter une activité sur `onEmit` de l'outbox de son émetteur (catalogue, contacts, listes de participants) ; exception nommée (2026-11-03, sur avis du développeur d'ActivityPods, `decision_2026-11-03_participation-delivered-to-the-app`) : un Join/Leave de participation est adressé à l'acteur de l'app et traité à sa réception dans l'inbox de l'app (`onReceive` de l'`ActivitiesHandlerMixin` de semapps, le chemin du framework pour l'installation d'une app, `registration.service.js`) ; collection lisible par un groupe = `pod-permissions.add({agentPredicate: 'acl:agentGroup', mode: 'acl:Read'})` sur une collection `pod-collections` (`attendees.service.js`), le groupe `/contacts` d'un utilisateur trouvé par `pod-wac-groups.getUriFromCollectionUri({collectionUri: apods:contacts})` — jamais un droit par élément de la liste `apods:contacts`, falsifiable par un `Accept` forgé ; notifier = `pod-notifications.send`, qui exige un backend en WebACL activé sur un dataset sécurisé, comme les trois apps de référence. Ne JAMAIS reprendre leur `addMember` de l'organisateur dans le groupe `/contacts` du participant : c'est un choix produit de welcometomyplace (événements privés), une fuite d'identité sous la règle de Festipod (`behavior_participation-listing-is-contacts-only`). Couvre l'usage de la plateforme, pas les choix produit propres à Festipod.
|
||||
|
||||
@@ -14,8 +14,11 @@
|
||||
// `pim:storage` (verified 2026-10-12: PATCH → 204, the profile then lists both) — so it proves
|
||||
// nothing about who owns a resource.
|
||||
//
|
||||
// `actorUri` must come from the server side — the actor whose outbox the webhook fired on — never
|
||||
// from the activity's own `actor` field, which the emitting client writes.
|
||||
// `actorUri` must come from the server side — the actor whose outbox a webhook fired on, or the
|
||||
// signer the app's inbox verified for a delivered activity — never from a field nothing vouches for,
|
||||
// such as a webhook body's `actor`, which the emitting client writes. One use takes an unvouched
|
||||
// value as a HINT only: registration's `meetingPointOf` reads a meeting point in the pod its first
|
||||
// copy names, and believes that pod only once the copy it serves names the same creator.
|
||||
|
||||
const WEB_PROTOCOLS = new Set(['http:', 'https:']);
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// What the backend may believe about an activity its watcher reports — the check in front of every
|
||||
// Festipod handler fed by that watcher (public-read, catalog, attendees, contacts, registration).
|
||||
// What the backend may believe about an activity its watcher reports, or a server delivers to the
|
||||
// app's inbox — the check in front of every Festipod handler fed by that watcher (public-read,
|
||||
// catalog, attendees, contacts) or by that inbox (registration). A delivered body, like a webhook
|
||||
// body, is only a pointer.
|
||||
//
|
||||
// THE WEBHOOK IS A HINT, NOT A SOURCE. The webhook route (`/.webhooks/:uuid`) is unauthenticated: the
|
||||
// listener only matches the uuid, the watcher derives the box owner from the body's `target`, and an
|
||||
@@ -16,7 +18,11 @@
|
||||
// activity must lie in that owner's pod and the stored activity must name that owner as `actor`;
|
||||
// - an INBOX notification does not (`verifiedReceive`): the emitter is the stored activity's own
|
||||
// `actor`, believed only because the activity lies in THAT actor's pod — someone else's claim
|
||||
// about an actor cannot be stored there.
|
||||
// about an actor cannot be stored there;
|
||||
// - a DELIVERY to the app's own inbox names its emitter (`verifiedDelivery`): the inbox accepts it
|
||||
// only signed by the activity's `actor` (@semapps/activitypub 1.2.7 `inbox.post` refuses an actor
|
||||
// other than the signer, INVALID_ACTOR). Nothing else in the body is believed: the activity must
|
||||
// lie in that actor's pod and the copy stored there name them.
|
||||
//
|
||||
// An activity is accepted only when all of this holds:
|
||||
// 1. it is identified by a URI (an embedded activity without an id was never stored);
|
||||
@@ -87,13 +93,14 @@ const ANY_POD = Symbol('any pod');
|
||||
* { ok: true, activity, emitterUri, objectUri, object } (object: the stored object; undefined for a Delete)
|
||||
* { ok: false, reason }
|
||||
*
|
||||
* `activity` is what the watcher handed over (a URI or a node — only its id is used). `type` is the
|
||||
* activity type the handler expects. `emitterUri` is the owner of the outbox the notification names,
|
||||
* or `null` for an inbox notification (the emitter is then the stored activity's actor). The object
|
||||
* must lie in the pod of `objectOwnerUri`, or anywhere with `ANY_POD`. `objectType` is the type the
|
||||
* stored object must declare — an ActivityStreams name or a full IRI; not checked for a Delete.
|
||||
* `read(uri)` reads a resource as the box owner and resolves `{ status, body }` (`status` 0 when the
|
||||
* request itself failed). Never throws for a refusal; a throwing `read` propagates.
|
||||
* `activity` is what the watcher or the app's inbox handed over (a URI or a node — only its id is
|
||||
* used). `type` is the activity type the handler expects. `emitterUri` is the owner of the outbox the
|
||||
* notification names, the `actor` the app's inbox verified as the signer of a delivery, or `null` for
|
||||
* an inbox notification (the emitter is then the stored activity's actor). The object must lie in the
|
||||
* pod of `objectOwnerUri`, or anywhere with `ANY_POD`. `objectType` is the type the stored object
|
||||
* must declare — an ActivityStreams name or a full IRI; not checked for a Delete. `read(uri)` reads a
|
||||
* resource as the box owner — for a delivery, as its emitter — and resolves `{ status, body }`
|
||||
* (`status` 0 when the request itself failed). Never throws for a refusal; a throwing `read` propagates.
|
||||
*/
|
||||
async function verifyActivity({ activity, type, emitterUri, objectOwnerUri, objectType, read }) {
|
||||
if (!ACTIVITY_TYPES.has(type)) throw new Error(`Unsupported activity type ${type}`);
|
||||
@@ -227,6 +234,45 @@ function verifiedReceive(type, handle, { objectType } = {}) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* An `onReceive` for the APP's own inbox (`@semapps/activitypub` `ActivitiesHandlerMixin`, whose
|
||||
* third argument is the inbox owner, the app): runs `handle(ctx, verdict, emitterUri)` only for an
|
||||
* activity `verifyActivity` accepts as emitted by the delivered activity's `actor`, the signer the
|
||||
* inbox verified. Before anything is read, the activity's URI must lie in that actor's pod; the
|
||||
* activity and its object are then read through that actor's pod (`podReader`), the stored activity
|
||||
* having to name that same actor. `objectType` and `objectIn` are those of `verifiedEmit`. With
|
||||
* `objectIn: 'any'`, an object outside the actor's pod is read through the proxy THEIR actor document
|
||||
* declares — for an actor on another server, an answer that server chooses: a handler acting on such
|
||||
* an object reads it again where it lies (registration's `meetingPointOf`).
|
||||
*/
|
||||
function verifiedDelivery(type, handle, { objectType, objectIn = 'emitter' } = {}) {
|
||||
if (objectIn !== 'emitter' && objectIn !== 'any') throw new Error(`Unknown objectIn ${objectIn}`);
|
||||
return async function (ctx, activity, inboxOwnerUri) {
|
||||
try {
|
||||
const claimed = activity && typeof activity === 'object' ? singleUriOf(activity.actor) : undefined;
|
||||
if (!claimed) {
|
||||
this.logger.warn(`${this.name} ignored a ${type} delivered to ${inboxOwnerUri}: it names no single actor`);
|
||||
return;
|
||||
}
|
||||
const verdict = await verifyActivity({
|
||||
activity,
|
||||
type,
|
||||
emitterUri: claimed,
|
||||
objectOwnerUri: objectIn === 'any' ? ANY_POD : claimed,
|
||||
objectType,
|
||||
read: podReader(ctx, claimed)
|
||||
});
|
||||
if (!verdict.ok) {
|
||||
this.logger.warn(`${this.name} ignored a ${type} delivered to ${inboxOwnerUri} as from ${claimed}: ${verdict.reason}`);
|
||||
return;
|
||||
}
|
||||
await handle.call(this, ctx, verdict, verdict.emitterUri);
|
||||
} catch (e) {
|
||||
this.logger.error(`${this.name} could not process a ${type} delivered to ${inboxOwnerUri}: ${e.message}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
verifyActivity,
|
||||
verifyEmittedActivity,
|
||||
@@ -234,6 +280,7 @@ module.exports = {
|
||||
neverThrowingMatcher,
|
||||
verifiedEmit,
|
||||
verifiedReceive,
|
||||
verifiedDelivery,
|
||||
uriOf,
|
||||
singleUriOf,
|
||||
hasAsType,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const { test, expect, describe } = require('bun:test');
|
||||
const { verifyEmittedActivity, verifyActivity, verifiedEmit, verifiedReceive, neverThrowingMatcher, ANY_POD } = require('./emitted-activity');
|
||||
const { verifyEmittedActivity, verifyActivity, verifiedEmit, verifiedReceive, verifiedDelivery, neverThrowingMatcher, ANY_POD } = require('./emitted-activity');
|
||||
|
||||
const ALICE = 'http://localhost:3000/alice';
|
||||
const BOB = 'http://localhost:3000/bob';
|
||||
@@ -265,7 +265,7 @@ describe('verifiedReceive / verifiedEmit with objectIn "any"', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifiedEmit — a Join from a participant\'s outbox (the registration flow)', () => {
|
||||
describe('verifiedEmit — a Join from a participant\'s outbox', () => {
|
||||
const MP = `${ALICE}/data/events/mp1`;
|
||||
const JOIN = `${BOB}/outbox/join-1`;
|
||||
const join = { id: JOIN, type: 'Join', actor: BOB, object: MP };
|
||||
@@ -298,3 +298,94 @@ describe('verifiedEmit — a Join from a participant\'s outbox (the registration
|
||||
expect(service.logger.w).toContain('not attributed to');
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifiedDelivery — a Join delivered to the app's inbox: the body's actor is a claim", () => {
|
||||
const APP = 'http://localhost:3005/as/actor/app';
|
||||
const MALLORY = 'http://localhost:3000/mallory';
|
||||
const MP = `${ALICE}/data/events/mp1`;
|
||||
const JOIN = `${BOB}/data/activities/join-1`;
|
||||
const join = { id: JOIN, type: 'Join', actor: BOB, object: MP, to: APP };
|
||||
const meetingPoint = { id: MP, type: 'Event', context: `${ALICE}/data/events/e1` };
|
||||
|
||||
/** A pod answering `stored` (uri → body), recording every read as [uri, actorUri]. */
|
||||
function setup(stored = { [JOIN]: join, [MP]: meetingPoint }) {
|
||||
const reads = [];
|
||||
const ctx = {
|
||||
call: async (_action, { resourceUri, actorUri }) => {
|
||||
reads.push([resourceUri, actorUri]);
|
||||
const body = stored[resourceUri];
|
||||
return body ? { status: 200, body } : { status: 404 };
|
||||
}
|
||||
};
|
||||
const logs = { warn: [], error: [] };
|
||||
const service = { name: 'test', logger: { info() {}, warn: m => logs.warn.push(m), error: m => logs.error.push(m) } };
|
||||
const ran = [];
|
||||
const deliver = (body, options = { objectType: 'Event', objectIn: 'any' }, handle = async (_ctx, verdict, emitterUri) => ran.push([emitterUri, verdict.object])) =>
|
||||
verifiedDelivery('Join', handle, options).call(service, ctx, body, APP);
|
||||
return { reads, logs, ran, deliver };
|
||||
}
|
||||
|
||||
test("the normal case: the claimed actor's pod holds the activity naming them — the handler runs for them, on the STORED object, everything read through their pod", async () => {
|
||||
const w = setup();
|
||||
await w.deliver({ ...join, object: { id: `${ALICE}/data/events/other`, type: 'Event' } });
|
||||
expect(w.ran).toEqual([[BOB, meetingPoint]]);
|
||||
expect(w.reads).toEqual([[JOIN, BOB], [MP, BOB]]);
|
||||
expect(w.logs.warn).toEqual([]);
|
||||
});
|
||||
|
||||
test("a body claiming another actor for an activity of Bob's pod is refused before anything is read", async () => {
|
||||
const w = setup();
|
||||
await w.deliver({ ...join, actor: MALLORY });
|
||||
expect(w.ran).toEqual([]);
|
||||
expect(w.reads).toEqual([]);
|
||||
expect(w.logs.warn.some(m => m.includes(`not in the pod of ${MALLORY}`))).toBe(true);
|
||||
});
|
||||
|
||||
test("an activity held in Mallory's pod claiming Bob as its actor is refused before anything is read", async () => {
|
||||
const forged = `${MALLORY}/data/activities/join-1`;
|
||||
const w = setup({ [forged]: { ...join, id: forged }, [MP]: meetingPoint });
|
||||
await w.deliver({ ...join, id: forged });
|
||||
expect(w.ran).toEqual([]);
|
||||
expect(w.reads).toEqual([]);
|
||||
expect(w.logs.warn.some(m => m.includes(`not in the pod of ${BOB}`))).toBe(true);
|
||||
});
|
||||
|
||||
test("an activity of Bob's pod whose stored copy names someone else is refused", async () => {
|
||||
const w = setup({ [JOIN]: { ...join, actor: ALICE }, [MP]: meetingPoint });
|
||||
await w.deliver(join);
|
||||
expect(w.ran).toEqual([]);
|
||||
expect(w.logs.warn.some(m => m.includes('not attributed to'))).toBe(true);
|
||||
});
|
||||
|
||||
test('a body naming no single actor, or no body at all, is refused without a read', async () => {
|
||||
for (const body of [{ ...join, actor: undefined }, { ...join, actor: [BOB, ALICE] }, JOIN, undefined]) {
|
||||
const w = setup();
|
||||
await w.deliver(body);
|
||||
expect(w.ran).toEqual([]);
|
||||
expect(w.reads).toEqual([]);
|
||||
expect(w.logs.warn.some(m => m.includes('names no single actor'))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("an activity the claimed actor's pod does not hold is refused", async () => {
|
||||
const w = setup({ [MP]: meetingPoint });
|
||||
await w.deliver(join);
|
||||
expect(w.ran).toEqual([]);
|
||||
expect(w.logs.warn.some(m => m.includes('does not return the activity'))).toBe(true);
|
||||
});
|
||||
|
||||
test('by default the object must lie in the emitter\'s pod; objectIn "any" lets the handler check it', async () => {
|
||||
const w = setup();
|
||||
await w.deliver(join, { objectType: 'Event' });
|
||||
expect(w.ran).toEqual([]);
|
||||
expect(w.logs.warn.some(m => m.includes(`not in the pod of ${BOB}`))).toBe(true);
|
||||
});
|
||||
|
||||
test('a failing handler or read is logged, never thrown', async () => {
|
||||
const w = setup();
|
||||
await w.deliver(join, { objectType: 'Event', objectIn: 'any' }, async () => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
expect(w.logs.error.some(m => m.includes('boom'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -85,7 +85,7 @@ const MEMBER = 'http://festipod.org/attendanceMember';
|
||||
* A collection (a key of `state.items`) answers a GET as the pod serves an unpaged collection: its
|
||||
* `items` inline.
|
||||
*/
|
||||
function world({ stored = {}, items = {}, acls = {}, ledger = {}, delays = {}, fail = {}, timeoutMs } = {}) {
|
||||
function world({ stored = {}, items = {}, acls = {}, ledger = {}, delays = {}, fail = {}, proxied = {}, timeoutMs } = {}) {
|
||||
const calls = [];
|
||||
const logs = { info: [], warn: [], error: [] };
|
||||
const logger = { info: m => logs.info.push(m), warn: m => logs.warn.push(m), error: m => logs.error.push(m) };
|
||||
@@ -137,6 +137,11 @@ function world({ stored = {}, items = {}, acls = {}, ledger = {}, delays = {}, f
|
||||
return { id: APP };
|
||||
case 'pod-resources.get':
|
||||
await wait(delays[`read:${params.resourceUri}`]);
|
||||
// `proxied[reader][uri]`: what the reader's OWN server answers for a resource outside the
|
||||
// reader's pod — the platform reads it through the proxy the reader's actor document declares.
|
||||
if (proxied[params.actorUri] && params.resourceUri in proxied[params.actorUri] && !params.resourceUri.startsWith(`${params.actorUri}/`)) {
|
||||
return { ok: true, status: 200, body: proxied[params.actorUri][params.resourceUri] };
|
||||
}
|
||||
return read(params.resourceUri);
|
||||
case 'pod-collections.getItems': {
|
||||
// As the platform does: the body's items, whatever the status — `[]` on a failed read.
|
||||
@@ -263,10 +268,10 @@ function world({ stored = {}, items = {}, acls = {}, ledger = {}, delays = {}, f
|
||||
}
|
||||
for (const [name, fn] of Object.entries(registration.methods)) registrationSvc[name] = fn.bind(registrationSvc);
|
||||
|
||||
/** Notifies the registration service of an activity emitted in `actorUri`'s outbox. */
|
||||
const emit = (key, hint, actorUri) => registration.activities[key].onEmit.call(registrationSvc, ctx, hint, actorUri);
|
||||
/** Delivers an activity to the app's inbox, as the participant's pod does: the body is all the handler gets. */
|
||||
const deliver = (key, body) => registration.activities[key].onReceive.call(registrationSvc, ctx, body, APP);
|
||||
|
||||
return { ctx, calls, state, logs, registration: registrationSvc, participations: participationsSvc, emit };
|
||||
return { ctx, calls, state, logs, registration: registrationSvc, participations: participationsSvc, deliver };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
const { ACTIVITY_TYPES, matchActivity } = require('@semapps/activitypub');
|
||||
const { PodActivitiesHandlerMixin } = require('@activitypods/app');
|
||||
const { ACTIVITY_TYPES, ActivitiesHandlerMixin, matchActivity } = require('@semapps/activitypub');
|
||||
const { arrayOf } = require('@semapps/ldp');
|
||||
const CONFIG = require('../config/config');
|
||||
const { isInActorPod } = require('./actor-pod');
|
||||
const { createKeyedSerializer } = require('./keyed-serializer');
|
||||
const { isNewerAct, lastAttendance, persistMember, recordAttendance } = require('./attendance-ledger');
|
||||
const { neverThrowingMatcher, verifiedEmit, singleUriOf, uriOf } = require('./emitted-activity');
|
||||
const { neverThrowingMatcher, verifiedDelivery, singleUriOf, uriOf, hasAsType } = require('./emitted-activity');
|
||||
const { participationSecretOf, memberUrn } = require('./member-urn');
|
||||
const { readItems, expectItem } = require('./collection-items');
|
||||
|
||||
@@ -37,9 +36,12 @@ const firstStringOf = value =>
|
||||
|
||||
/**
|
||||
* What a Join/Leave targets: the stored object must be a meeting point — an `as:Event` (checked by
|
||||
* `verifiedEmit`) anchored to an event by `as:context` — whose creator is its `dc:creator`, believed
|
||||
* only because the meeting point lies in that creator's pod (the pod stamps the creator, and nobody
|
||||
* can store a resource in someone else's pod). Resolves `{ ok, eventUri, creatorUri }` or a refusal.
|
||||
* `verifiedDelivery`, and by `meetingPointOf` on the creator's copy) anchored to an event by
|
||||
* `as:context` — whose creator is its `dc:creator`, required to own the pod the meeting point lies
|
||||
* in. Only on the copy the creator's pod serves (`meetingPointOf`) is that creator believed (the pod
|
||||
* stamps the creator, and nobody can store a resource in someone else's pod); on the copy read
|
||||
* through the participant's pod, it only names where to read that one. Resolves
|
||||
* `{ ok, eventUri, creatorUri }` or a refusal.
|
||||
*/
|
||||
function meetingPointTarget(meetingPoint, meetingPointUri) {
|
||||
const eventUri = linkOf(meetingPoint, 'context', 'as:context', `${AS}context`);
|
||||
@@ -81,10 +83,12 @@ function notificationTemplate(change) {
|
||||
};
|
||||
}
|
||||
|
||||
// Participation (decision_2026-10-29_participation-privacy-mechanism): a person joins (leaves) a
|
||||
// meeting point with an `as:Join` (`as:Leave`) posted to their OWN outbox, naming the meeting point,
|
||||
// addressed to no one — so only its author and the app read it. The creator's inbox receives nothing.
|
||||
// The backend acts on EMISSION (`onEmit`, the welcometomyplace registration pattern), and records:
|
||||
// Participation (decision_2026-10-29_participation-privacy-mechanism; its delivery:
|
||||
// decision_2026-11-03_participation-delivered-to-the-app): a person joins (leaves) a meeting point with an `as:Join`
|
||||
// (`as:Leave`) posted to their OWN outbox, naming the meeting point, addressed to the app actor alone —
|
||||
// so only its author and the app read it. Their pod delivers it to the app's inbox; the creator's inbox
|
||||
// receives nothing. The backend acts on RECEPTION in the app's inbox (`onReceive` of the semapps
|
||||
// `ActivitiesHandlerMixin`, as the app framework handles an app registration), and records:
|
||||
// - in the meeting point's `apods:attendees` collection (the creator's pod, acting as the creator):
|
||||
// the participant's MEMBER URN, never their WebID — `member-urn.js`, computed from the secret in
|
||||
// their profile. A Join whose author's profile carries no usable secret is refused, nothing written;
|
||||
@@ -95,11 +99,15 @@ function notificationTemplate(change) {
|
||||
// contact of the participant" signal, and the only one the app can read (the participant's
|
||||
// `apods:contacts` list) is forgeable (platform defect reported upstream, see `notificationTemplate`).
|
||||
// The creator has no other right on the participant: they learn a count and opaque URNs.
|
||||
// The webhook that reports the act is a hint (emitted-activity.js): the handler acts only on the
|
||||
// activity and the meeting point as the pod stores them, and only for the people they really name:
|
||||
// - the participant is the owner of the outbox the webhook fired on, and the stored activity's actor;
|
||||
// - the creator is the meeting point's `dc:creator`, accepted only when the meeting point lies in
|
||||
// that creator's pod (`meetingPointTarget`);
|
||||
// The delivery that reports the act is a pointer (emitted-activity.js, `verifiedDelivery`): the handler
|
||||
// acts only on the activity and the meeting point as the pods store them, and only for the people they
|
||||
// really name:
|
||||
// - the participant is the delivered activity's `actor` — the app's inbox accepts a delivery only
|
||||
// signed by its actor — and the activity must lie in that actor's pod, its stored copy naming them:
|
||||
// any server may deliver, nobody acts for someone else;
|
||||
// - the meeting point is the copy its creator's pod serves (`meetingPointOf`), never the one read
|
||||
// through the participant's server; the creator is its `dc:creator`, accepted only when the meeting
|
||||
// point lies in that creator's pod (`meetingPointTarget`);
|
||||
// - the collections written to are the ones the STORED meeting point and the participant's STORED
|
||||
// profile name, and must lie in the pod of whoever owns them.
|
||||
// Joins and leaves of one meeting point are processed one at a time (keyed-serializer.js), and the
|
||||
@@ -115,24 +123,27 @@ function notificationTemplate(change) {
|
||||
// (the two writes are not atomic).
|
||||
module.exports = {
|
||||
name: 'registration',
|
||||
mixins: [PodActivitiesHandlerMixin],
|
||||
mixins: [ActivitiesHandlerMixin],
|
||||
dependencies: ['participations'],
|
||||
created() {
|
||||
this.serialize = createKeyedSerializer();
|
||||
},
|
||||
async started() {
|
||||
this.logger.info('Registration watcher subscribed');
|
||||
this.logger.info("Registration listens to the Joins and Leaves delivered to the app's inbox");
|
||||
},
|
||||
methods: {
|
||||
/**
|
||||
* Handles one verified Join/Leave (`change`: 'join' | 'leave') of `participantUri`, the outbox
|
||||
* owner: resolves the meeting point and the participant, applies the act, then notifies the
|
||||
* Handles one verified Join/Leave (`change`: 'join' | 'leave') of `participantUri`, its verified
|
||||
* emitter: resolves the meeting point and the participant, applies the act, then notifies the
|
||||
* creator when the act changed the participant's state. A refusal is logged; nothing is written.
|
||||
*/
|
||||
async onParticipation(ctx, change, { activity, objectUri: meetingPointUri, object: meetingPoint }, participantUri) {
|
||||
async onParticipation(ctx, change, { activity, objectUri: meetingPointUri, object: claimedMeetingPoint }, participantUri) {
|
||||
const refuse = reason => this.logger.warn(`${this.name} refused the ${change} ${uriOf(activity)} of ${participantUri}: ${reason}`);
|
||||
const target = meetingPointTarget(meetingPoint, meetingPointUri);
|
||||
const claimed = meetingPointTarget(claimedMeetingPoint, meetingPointUri);
|
||||
if (!claimed.ok) return refuse(claimed.reason);
|
||||
const target = await this.meetingPointOf(ctx, meetingPointUri, claimed.creatorUri);
|
||||
if (!target.ok) return refuse(target.reason);
|
||||
const { meetingPoint } = target;
|
||||
const participant = await this.participantOf(ctx, participantUri);
|
||||
if (!participant.ok) return refuse(participant.reason);
|
||||
const outcome = await this.updateAttendance(ctx, {
|
||||
@@ -158,6 +169,28 @@ module.exports = {
|
||||
});
|
||||
}
|
||||
},
|
||||
/**
|
||||
* The meeting point as the pod that holds it serves it. The verification read it through the
|
||||
* participant's pod — for a participant on another server, through the proxy THEIR actor document
|
||||
* declares, which answers what it wants (another meeting point's list, another name). Only the
|
||||
* creator's pod speaks for the meeting point: it is read again there, through the creator's access
|
||||
* (a GET to that pod, signed by the app), and the act uses that copy alone. It must still be an
|
||||
* `as:Event` anchored by `as:context` whose `dc:creator` is that same creator. Resolves
|
||||
* `{ ok, meetingPoint, eventUri, creatorUri }` or a refusal.
|
||||
*/
|
||||
async meetingPointOf(ctx, meetingPointUri, creatorUri) {
|
||||
const { status, body } = await ctx.call('pod-resources.get', { resourceUri: meetingPointUri, actorUri: creatorUri });
|
||||
if (status !== 200 || !body || uriOf(body) !== meetingPointUri) {
|
||||
return { ok: false, reason: `the pod of ${creatorUri} does not return the meeting point ${meetingPointUri} (HTTP ${status})` };
|
||||
}
|
||||
if (!hasAsType(body, 'Event')) return { ok: false, reason: `the meeting point ${meetingPointUri} its pod returns is not an Event` };
|
||||
const target = meetingPointTarget(body, meetingPointUri);
|
||||
if (!target.ok) return target;
|
||||
if (target.creatorUri !== creatorUri) {
|
||||
return { ok: false, reason: `the meeting point ${meetingPointUri} names ${target.creatorUri} as its creator in its pod, not ${creatorUri}` };
|
||||
}
|
||||
return { ok: true, meetingPoint: body, eventUri: target.eventUri, creatorUri };
|
||||
},
|
||||
/**
|
||||
* The participant as their pod describes them, read as the participant: their actor document
|
||||
* (its `url` — the `as:Profile`, which must lie in their pod — and `apods:contacts`), then the
|
||||
@@ -364,12 +397,12 @@ module.exports = {
|
||||
match: neverThrowingMatcher(function (activity, fetcher) {
|
||||
return matchActivity(JOIN_PATTERN, activity, fetcher);
|
||||
}),
|
||||
// The participant's outbox: `actorUri` is the participant; the meeting point may lie in any pod
|
||||
// (its creator's — checked by `meetingPointTarget`).
|
||||
onEmit: verifiedEmit(
|
||||
// Delivered to the app's inbox: the participant is the verified emitter; the meeting point may lie
|
||||
// in any pod (its creator's — checked by `meetingPointTarget`).
|
||||
onReceive: verifiedDelivery(
|
||||
'Join',
|
||||
async function (ctx, verdict, actorUri) {
|
||||
await this.onParticipation(ctx, 'join', verdict, actorUri);
|
||||
async function (ctx, verdict, participantUri) {
|
||||
await this.onParticipation(ctx, 'join', verdict, participantUri);
|
||||
},
|
||||
{ objectType: 'Event', objectIn: 'any' }
|
||||
)
|
||||
@@ -378,10 +411,10 @@ module.exports = {
|
||||
match: neverThrowingMatcher(function (activity, fetcher) {
|
||||
return matchActivity(LEAVE_PATTERN, activity, fetcher);
|
||||
}),
|
||||
onEmit: verifiedEmit(
|
||||
onReceive: verifiedDelivery(
|
||||
'Leave',
|
||||
async function (ctx, verdict, actorUri) {
|
||||
await this.onParticipation(ctx, 'leave', verdict, actorUri);
|
||||
async function (ctx, verdict, participantUri) {
|
||||
await this.onParticipation(ctx, 'leave', verdict, participantUri);
|
||||
},
|
||||
{ objectType: 'Event', objectIn: 'any' }
|
||||
)
|
||||
|
||||
@@ -45,8 +45,8 @@ function setup({ acts = [], stored = {}, ...options } = {}) {
|
||||
return world({ stored: { ...MEETING_POINTS, ...Object.fromEntries(acts.map(a => [a.id, a])), ...stored }, ...options });
|
||||
}
|
||||
|
||||
const emitJoin = (w, act, actorUri = act.actor) => w.emit('join', act, actorUri);
|
||||
const emitLeave = (w, act, actorUri = act.actor) => w.emit('leave', act, actorUri);
|
||||
const emitJoin = (w, act) => w.deliver('join', act);
|
||||
const emitLeave = (w, act) => w.deliver('leave', act);
|
||||
const attendees = (w, mp = MP1) => w.state.items[attendeesOf(mp)] || [];
|
||||
const participationsUri = (w, who = BOB) => {
|
||||
const link = w.state.stored[profileOf(who)][FP_PARTICIPATIONS];
|
||||
@@ -84,7 +84,7 @@ const protectedAcl = (uri, group = groupOf(BOB)) => [
|
||||
{ '@id': `http://localhost:3000/_acl${new URL(uri).pathname}#Control`, 'acl:mode': 'acl:Control', 'acl:agent': APP }
|
||||
];
|
||||
|
||||
describe("registration — a Join emitted in the participant's outbox", () => {
|
||||
describe("registration — a Join delivered to the app's inbox", () => {
|
||||
test('the normal case: the member URN joins the list, the meeting point joins the participant\'s collection, the act is recorded', async () => {
|
||||
const j = join(MP1);
|
||||
const w = setup({ acts: [j] });
|
||||
@@ -105,20 +105,59 @@ describe("registration — a Join emitted in the participant's outbox", () => {
|
||||
expect(w.logs.error).toEqual([]);
|
||||
});
|
||||
|
||||
test('the webhook body is a hint: its actor and its embedded object are ignored', async () => {
|
||||
test('the delivered body is a pointer: its embedded object is ignored, the stored meeting point is used', async () => {
|
||||
const j = join(MP1);
|
||||
const w = setup({ acts: [j] });
|
||||
await emitJoin(w, { ...j, actor: MALLORY, object: { id: MP2, type: 'Event', 'apods:attendees': `${MALLORY}/data/c` } }, BOB);
|
||||
await emitJoin(w, { ...j, object: { id: MP2, type: 'Event', 'apods:attendees': `${MALLORY}/data/c` } });
|
||||
expect(attendees(w)).toEqual([urnOf(BOB)]);
|
||||
expect(attendees(w, MP2)).toEqual([]);
|
||||
expect(w.state.items[`${MALLORY}/data/c`]).toBeUndefined();
|
||||
});
|
||||
|
||||
test('the act is handled on emission only: the creator\'s inbox has no handler', () => {
|
||||
expect(registration.activities.join.onReceive).toBeUndefined();
|
||||
expect(registration.activities.leave.onReceive).toBeUndefined();
|
||||
expect(typeof registration.activities.join.onEmit).toBe('function');
|
||||
expect(typeof registration.activities.leave.onEmit).toBe('function');
|
||||
test("nobody joins or leaves for someone else: a delivery claiming Mallory for Bob's activity, or Bob for Mallory's, writes nothing", async () => {
|
||||
const j = join(MP1);
|
||||
const forged = { ...join(MP1, T0, MALLORY), actor: BOB };
|
||||
const w = setup({ acts: [j, forged] });
|
||||
await emitJoin(w, { ...j, actor: MALLORY });
|
||||
await emitJoin(w, forged);
|
||||
expect(writes(w.calls)).toEqual([]);
|
||||
expect(attendees(w)).toEqual([]);
|
||||
});
|
||||
|
||||
test("the meeting point is the one its creator's pod serves, not the copy the participant's server hands over", async () => {
|
||||
// Bob's server answers the reads made through him: for MP1 (in Alice's pod), a forged copy
|
||||
// naming MP2's attendee list and a name of its choosing.
|
||||
const j = join(MP1);
|
||||
const forged = meetingPoint(MP1, { name: 'Forged title', 'apods:attendees': attendeesOf(MP2) });
|
||||
const w = setup({ acts: [j], proxied: { [BOB]: { [MP1]: forged } } });
|
||||
await emitJoin(w, j);
|
||||
expect(attendees(w)).toEqual([urnOf(BOB)]);
|
||||
expect(attendees(w, MP2)).toEqual([]);
|
||||
expect(w.state.notifications.map(n => n.meetingPointName)).toEqual(['Rendez-vous mp1']);
|
||||
expect(w.calls.some(c => c.action === 'pod-resources.get' && c.params.resourceUri === MP1 && c.params.actorUri === ALICE)).toBe(true);
|
||||
});
|
||||
|
||||
test("a meeting point its creator's pod does not serve, or serves under another creator, writes nothing", async () => {
|
||||
const j = join(MP1);
|
||||
for (const options of [
|
||||
{ fail: { read: { [MP1]: 404 } }, proxied: { [BOB]: { [MP1]: meetingPoint(MP1) } }, contains: `does not return the meeting point ${MP1}` },
|
||||
{ stored: { [MP1]: meetingPoint(MP1, { 'dc:creator': CAROL }) }, proxied: { [BOB]: { [MP1]: meetingPoint(MP1) } }, contains: `not in the pod of its creator ${CAROL}` },
|
||||
{ stored: { [MP1]: meetingPoint(MP1, { type: 'Note' }) }, proxied: { [BOB]: { [MP1]: meetingPoint(MP1) } }, contains: 'is not an Event' }
|
||||
]) {
|
||||
const { contains, stored = {}, ...rest } = options;
|
||||
const w = setup({ acts: [j], stored, ...rest });
|
||||
await emitJoin(w, j);
|
||||
expect(writes(w.calls)).toEqual([]);
|
||||
expect(w.logs.warn.some(m => m.includes(contains))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("the act is handled on reception in the app's inbox only: no outbox handler", () => {
|
||||
expect(registration.mixins).toContain(require('@semapps/activitypub').ActivitiesHandlerMixin);
|
||||
expect(registration.activities.join.onEmit).toBeUndefined();
|
||||
expect(registration.activities.leave.onEmit).toBeUndefined();
|
||||
expect(typeof registration.activities.join.onReceive).toBe('function');
|
||||
expect(typeof registration.activities.leave.onReceive).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -661,7 +700,7 @@ describe('registration — a participant who rewrites their profile secret betwe
|
||||
|
||||
// THE DEFECT the sticky member fixes: a Join whose list add lands but a later step fails is left
|
||||
// unrecorded; the participant rewrites their secret and joins again — that must not list a second URN.
|
||||
const onEmitWith = (w, ctx, key, act) => registration.activities[key].onEmit.call(w.registration, ctx, act, act.actor);
|
||||
const deliverWith = (w, ctx, key, act) => registration.activities[key].onReceive.call(w.registration, ctx, act, APP);
|
||||
|
||||
test('partial failure (the pod refuses the participations write after the list add) + secret rewrite + Join: ONE URN; a Leave empties the list', async () => {
|
||||
const j1 = join(MP1, T0, BOB, 1);
|
||||
@@ -697,13 +736,13 @@ describe('registration — a participant who rewrites their profile secret betwe
|
||||
return w.ctx.call(action, params);
|
||||
}
|
||||
};
|
||||
await onEmitWith(w, ctx, 'join', j1);
|
||||
await deliverWith(w, ctx, 'join', j1);
|
||||
expect(attendees(w)).toEqual([urnOf(BOB)]);
|
||||
expect(ledgerOf(w).activityUri).toBeUndefined();
|
||||
rotate(w, BOB, secretN(5));
|
||||
await onEmitWith(w, ctx, 'join', j2);
|
||||
await deliverWith(w, ctx, 'join', j2);
|
||||
expect(attendees(w)).toEqual([urnOf(BOB)]);
|
||||
await onEmitWith(w, ctx, 'leave', l);
|
||||
await deliverWith(w, ctx, 'leave', l);
|
||||
expect(attendees(w)).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -722,15 +761,15 @@ describe('registration — a participant who rewrites their profile secret betwe
|
||||
return w.ctx.call(action, params);
|
||||
}
|
||||
};
|
||||
const first = onEmitWith(w, ctx, 'join', j1); // abandoned at 80 ms, its member write still in flight
|
||||
const first = deliverWith(w, ctx, 'join', j1); // abandoned at 80 ms, its member write still in flight
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
rotate(w, BOB, secretN(6));
|
||||
await onEmitWith(w, ctx, 'join', j2); // persists, lists and records the NEW secret's URN
|
||||
await deliverWith(w, ctx, 'join', j2); // persists, lists and records the NEW secret's URN
|
||||
await first;
|
||||
await new Promise(resolve => setTimeout(resolve, 150));
|
||||
expect(attendees(w)).toEqual([urnWith(BOB, secretN(6))]);
|
||||
expect(ledgerOf(w).member).toBe(urnWith(BOB, secretN(6))); // the late write was a no-op
|
||||
await onEmitWith(w, ctx, 'leave', l);
|
||||
await deliverWith(w, ctx, 'leave', l);
|
||||
expect(attendees(w)).toEqual([]);
|
||||
});
|
||||
|
||||
|
||||
@@ -12,8 +12,9 @@
|
||||
* meeting point through her app and Bob joins it by tapping (the forged-contact
|
||||
* group) — so EVERY participation goes the way the app does it: the app writes
|
||||
* the participant's secret on their profile with their app-session token (the
|
||||
* data provider's update), then posts the Join to their own outbox with no
|
||||
* addressee (decision_2026-10-29_participation-privacy-mechanism). Carol (a
|
||||
* data provider's update), then posts the Join to their own outbox, addressed
|
||||
* to the app actor alone (decision_2026-10-29_participation-privacy-mechanism,
|
||||
* decision_2026-11-03_participation-delivered-to-the-app). Carol (a
|
||||
* non-contact) and Bob (the creator) read the event through their own app.
|
||||
* Direct HTTP with each user's own token for the access-control lines (faster,
|
||||
* and precise about statuses). Carol installs the app programmatically (the
|
||||
@@ -781,9 +782,14 @@ async function ownActivity(session: ApodsSession, type: 'Join' | 'Leave', object
|
||||
return items.find(a => typeIs(a, type) && linkId(a['object']) === object);
|
||||
}
|
||||
|
||||
/** The addressing fields an activity carries — a participation activity must carry none. */
|
||||
/** The addressees an activity names, as `field=uri` — a participation activity names the app actor alone, in `to`. */
|
||||
const ADDRESSING = ['to', 'cc', 'bto', 'bcc', 'audience', 'as:to', 'as:cc', 'as:bto', 'as:bcc', 'as:audience'];
|
||||
const addressingOf = (activity: JsonRecord): string[] => ADDRESSING.filter(k => activity[k] !== undefined);
|
||||
const addressingOf = (activity: JsonRecord): string[] =>
|
||||
ADDRESSING.flatMap(k => arrayOf(activity[k]).map(v => `${k.replace(/^as:/, '')}=${linkId(v) ?? JSON.stringify(v)}`));
|
||||
const addressedToTheAppAlone = (activity: JsonRecord): boolean => {
|
||||
const addressees = addressingOf(activity);
|
||||
return addressees.length === 1 && addressees[0] === `to=${BACKEND}/as/actor/app`;
|
||||
};
|
||||
|
||||
/**
|
||||
* The pod's refusal of a COLLECTION: 401/403, or its 404 — which counts as one
|
||||
@@ -910,15 +916,14 @@ async function join(): Promise<void> {
|
||||
return { ok: !!back && own === sent, actual: `${reads.length} app-token read(s) after the PUT; secret returned: ${!!back} under the key ${JSON.stringify(key)}; her own read: ${own === sent ? 'same secret' : own ? 'ANOTHER secret' : 'none'}` };
|
||||
});
|
||||
|
||||
// THE JOIN: in her own outbox, addressed to no one; only she reads it.
|
||||
await m.hard('join.activity-shape', G, "Alice reads her own outbox: the Join names M and is addressed to NO ONE (not public, not the creator)", 'one Join, actor Alice, object M, no to/cc/bto/bcc/audience', async () => {
|
||||
// THE JOIN: in her own outbox, addressed to the app actor alone; only she and the app read it.
|
||||
await m.hard('join.activity-shape', G, "Alice reads her own outbox: the Join names M and is addressed to the app actor ALONE (not public, not the creator)", 'one Join, actor Alice, object M, to = the app actor, no other addressee', async () => {
|
||||
const alice = need(S.alice, 'Alice');
|
||||
const M = need(S.M, 'M');
|
||||
const act = await poll(() => ownActivity(alice, 'Join', M), v => !!v, 15000);
|
||||
if (!act) return { ok: false, actual: `no Join naming M in her outbox` };
|
||||
S.aliceJoinM = recordUri(act);
|
||||
const addressed = addressingOf(act);
|
||||
return { ok: linkId(act['actor']) === alice.webId && addressed.length === 0 && !!S.aliceJoinM, actual: `${S.aliceJoinM}: actor ${linkId(act['actor'])}, object ${linkId(act['object'])}, addressing fields: ${addressed.join(',') || 'none'}` };
|
||||
return { ok: linkId(act['actor']) === alice.webId && addressedToTheAppAlone(act) && !!S.aliceJoinM, actual: `${S.aliceJoinM}: actor ${linkId(act['actor'])}, object ${linkId(act['object'])}, addressees: ${addressingOf(act).join(',') || 'none'}` };
|
||||
});
|
||||
await m.hard('join.activity-author-reads', G, 'CONTROL: Alice (the author) GETs her Join', '200', async () => {
|
||||
const r = await http('GET', need(S.aliceJoinM, "Alice's Join (join.activity-shape)"), { token: need(S.alice, 'Alice').token });
|
||||
@@ -1101,9 +1106,9 @@ async function leave(): Promise<void> {
|
||||
await mpCard(page, TITLE_M).getByRole('button', { name: /✓ Inscrit/ }).waitFor({ timeout: 20000 }).catch(() => {});
|
||||
return tapMeetingPoint(page, TITLE_M, /✓ Inscrit/, /^S'inscrire$/);
|
||||
});
|
||||
await m.hard('leave.activity-shape', G, "Alice reads her own outbox: the Leave names M and is addressed to no one", 'a Leave, object M, no addressing field', async () => {
|
||||
await m.hard('leave.activity-shape', G, "Alice reads her own outbox: the Leave names M and is addressed to the app actor alone", 'a Leave, object M, to = the app actor, no other addressee', async () => {
|
||||
const act = await poll(() => ownActivity(need(S.alice, 'Alice'), 'Leave', need(S.M, 'M')), v => !!v, 15000);
|
||||
return { ok: !!act && addressingOf(act).length === 0, actual: act ? `${recordUri(act)}: addressing fields: ${addressingOf(act).join(',') || 'none'}` : 'no Leave naming M in her outbox' };
|
||||
return { ok: !!act && addressedToTheAppAlone(act), actual: act ? `${recordUri(act)}: addressees: ${addressingOf(act).join(',') || 'none'}` : 'no Leave naming M in her outbox' };
|
||||
});
|
||||
await m.hard('leave.collection', G, "Alice's member URN disappears from M's attendee list", 'items ∌ her URN within 30 s (read 200)', async () => {
|
||||
need(S.joined.M, 'the join on M landed (join.collection)');
|
||||
|
||||
@@ -32,8 +32,8 @@ Fonctionnalité: Smoke e2e de la pile ActivityPods locale
|
||||
# L'app s'installe sur le Pod (consentement de l'agent d'autorisation, ou pour un
|
||||
# scénario sans navigateur l'endpoint d'enregistrement — voir l'étape
|
||||
# « installée sur le Pod »). Une fois installée, l'activité `as:Join` postée sur
|
||||
# l'outbox de l'utilisateur, sans destinataire, est traitée par le backend à
|
||||
# l'émission : il inscrit dans la collection `apods:attendees` du point de
|
||||
# l'outbox de l'utilisateur, adressée à l'acteur de l'app seul, est livrée dans
|
||||
# l'inbox de l'app et traitée par le backend à sa réception : il inscrit dans la collection `apods:attendees` du point de
|
||||
# rencontre l'URN de membre dérivée du secret de participation du profil
|
||||
# (écrit avant le Join, comme le fait l'app) — jamais le WebID
|
||||
# (decision_2026-10-29_participation-privacy-mechanism). Le point de rencontre est créé dans le
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
participationSecretOf,
|
||||
podLogin,
|
||||
postOutboxActivity,
|
||||
appActorUri,
|
||||
readAttendees,
|
||||
readResource,
|
||||
recordUri,
|
||||
@@ -279,15 +280,16 @@ When('la session rejoint le point de rencontre', async function (this: FestipodW
|
||||
// decision_2026-10-29_participation-privacy-mechanism): the participation
|
||||
// secret on my profile first (the backend refuses a Join whose author's
|
||||
// profile carries none), then an as:Join naming the meeting point, posted to
|
||||
// my OWN outbox with NO addressee — not public, not addressed to the creator.
|
||||
// my OWN outbox and addressed to the app actor alone — not public, not
|
||||
// addressed to the creator (decision_2026-11-03_participation-delivered-to-the-app).
|
||||
await ensureParticipationSecret(pod);
|
||||
await postOutboxActivity(pod, 'Join', mpUri);
|
||||
await postOutboxActivity(pod, 'Join', mpUri, appActorUri(apodsStackConfig()));
|
||||
});
|
||||
|
||||
When('la session quitte le point de rencontre', async function (this: FestipodWorld) {
|
||||
const pod = session(this);
|
||||
const mpUri = requires(this, this.apods.meetingPointUri, 'meeting point URI');
|
||||
await postOutboxActivity(pod, 'Leave', mpUri);
|
||||
await postOutboxActivity(pod, 'Leave', mpUri, appActorUri(apodsStackConfig()));
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -400,7 +400,7 @@ const isSecret = (value: unknown) => typeof value === 'string' && /^[0-9a-f]{64}
|
||||
|
||||
// --- joining: the secret first, then a Join naming the meeting point --------------
|
||||
|
||||
test("the first join: a secret is generated and written to my profile BEFORE the Join is posted; the Join names the meeting point with no addressee; my participations collection, linked once the backend processed it, confirms it", async () => {
|
||||
test("the first join: a secret is generated and written to my profile BEFORE the Join is posted; the Join names the meeting point, addressed to the app actor alone; my participations collection, linked once the backend processed it, confirms it", async () => {
|
||||
await mount();
|
||||
|
||||
const served = server.reads;
|
||||
@@ -412,8 +412,8 @@ test("the first join: a secret is generated and written to my profile BEFORE the
|
||||
expect(server.profileWrites).toHaveLength(1);
|
||||
expect(Object.keys(server.profileWrites[0]!)).toEqual([FP_SECRET]);
|
||||
expect(isSecret(server.profileWrites[0]![FP_SECRET])).toBe(true);
|
||||
// Exactly these keys: no `to`, no `cc`.
|
||||
expect(server.posted).toEqual([{ '@context': 'https://www.w3.org/ns/activitystreams', type: 'Join', actor: ALICE, object: M }]);
|
||||
// Exactly these keys: `to` the app actor (the client id the backend URL derives), no `cc`.
|
||||
expect(server.posted).toEqual([{ '@context': 'https://www.w3.org/ns/activitystreams', type: 'Join', actor: ALICE, object: M, to: `${BACKEND}/as/actor/app` }]);
|
||||
|
||||
// The membership shows at once (overlay) while the backend has processed
|
||||
// nothing: my profile links no collection yet, the list is empty.
|
||||
@@ -493,7 +493,7 @@ test("a leave is never undone by a stale read: the membership is my participatio
|
||||
const served = server.reads;
|
||||
await latest!.leaveMeetingPoint(M);
|
||||
const leftAt = Date.now();
|
||||
expect(server.posted).toEqual([{ '@context': 'https://www.w3.org/ns/activitystreams', type: 'Leave', actor: ALICE, object: M }]);
|
||||
expect(server.posted).toEqual([{ '@context': 'https://www.w3.org/ns/activitystreams', type: 'Leave', actor: ALICE, object: M, to: `${BACKEND}/as/actor/app` }]);
|
||||
expect(server.profileWrites).toEqual([]);
|
||||
// The leave shows at once (overlay). The re-reads now land on STALE data —
|
||||
// the list still holds her URN, her collection still lists M: the count
|
||||
|
||||
@@ -65,12 +65,12 @@
|
||||
*
|
||||
* WHAT LIVES BESIDE REFINE. A participation is not a CRUD write and the
|
||||
* refine-providers package implements no `custom` method: joining or leaving
|
||||
* a meeting point POSTs an `as:Join`/`as:Leave` naming it, with no addressee,
|
||||
* to the user's own outbox (`data/outbox.ts`). The FIRST join ensures the
|
||||
* a meeting point POSTs an `as:Join`/`as:Leave` naming it, addressed to the app
|
||||
* actor alone, to the user's own outbox (`data/outbox.ts`). The FIRST join ensures the
|
||||
* participation secret on my profile beforehand — generated when absent and
|
||||
* written through the data provider's `update` — since the backend refuses a
|
||||
* Join whose author has none. The backend handles the activity on emission
|
||||
* and updates the attendee list and my participations collection
|
||||
* Join whose author has none. The backend handles the activity on its delivery
|
||||
* to the app's inbox and updates the attendee list and my participations collection
|
||||
* asynchronously — so the join/leave reflects in an optimistic overlay here,
|
||||
* confirmed by re-reading my participations collection (see
|
||||
* `settleMembershipActs` in `refineDerive.ts`), with a bounded schedule of
|
||||
@@ -103,7 +103,7 @@ import { fetchCatalogEvents, fetchCatalogMeetingPointIds } from '../data/catalog
|
||||
import { postJoinActivity, postLeaveActivity } from '../data/outbox';
|
||||
import { contactsCollectionOf, fetchCollectionMemberIds, postContactRequest, postEventInvitation } from '../data/contacts';
|
||||
import { useCurrentSession } from '../utils/currentSession';
|
||||
import { getBackendUrl } from '../utils/runtimeConfig';
|
||||
import { getBackendUrl, resolveActivityPodsAuthConfig } from '../utils/runtimeConfig';
|
||||
import { useCurrentPrincipal } from '../utils/currentPrincipal';
|
||||
import type { BootstrapResult } from '../utils/ngBootstrap';
|
||||
import { RefineProvider, useBackend } from '../refine/RefineProvider';
|
||||
@@ -187,6 +187,11 @@ interface MyParticipationsRead {
|
||||
}
|
||||
const NO_PARTICIPATIONS_READ: MyParticipationsRead = { generation: 0, attempted: 0, ids: [] };
|
||||
|
||||
/** The Festipod app actor, the only addressee of a Join or a Leave: the Solid-OIDC client id, which is the actor's URI. */
|
||||
function appActorUri(): string {
|
||||
return resolveActivityPodsAuthConfig(window.location.origin).clientId;
|
||||
}
|
||||
|
||||
/** What a contact's profile gives this session beyond their display fields. */
|
||||
interface ContactKeys {
|
||||
/** Their participation secret — what recognising them in an attendee list takes. */
|
||||
@@ -1178,7 +1183,7 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
|
||||
console.log(`${logPrefix} joinMeetingPoint (refine):`, meetingPointId);
|
||||
// The secret first: without it the backend records nothing.
|
||||
await ensureParticipationSecret();
|
||||
await postJoinActivity({ meetingPointUri: mp.id, session });
|
||||
await postJoinActivity({ meetingPointUri: mp.id, session, appActorUri: appActorUri() });
|
||||
afterMembershipAct(mp.id, 'join');
|
||||
}, [logPrefix, allMeetingPoints, session, webId, ensureParticipationSecret, afterMembershipAct]);
|
||||
|
||||
@@ -1201,7 +1206,7 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
|
||||
throw new Error(msg);
|
||||
}
|
||||
console.log(`${logPrefix} leaveMeetingPoint (refine):`, meetingPointId);
|
||||
await postLeaveActivity({ meetingPointUri: mp.id, session });
|
||||
await postLeaveActivity({ meetingPointUri: mp.id, session, appActorUri: appActorUri() });
|
||||
afterMembershipAct(mp.id, 'leave');
|
||||
}, [logPrefix, allMeetingPoints, myMeetingPointIds, session, webId, afterMembershipAct]);
|
||||
|
||||
|
||||
@@ -1028,7 +1028,7 @@ export const parsedFeatures: ParsedFeature[] = [
|
||||
}
|
||||
],
|
||||
"filePath": "src/modules/event/features/e2e-activitypods.feature",
|
||||
"rawContent": "# language: fr\n@EVENT @priority-1 @e2e-activitypods\nFonctionnalité: Smoke e2e de la pile ActivityPods locale\n En tant que développeur validant le branchement ActivityPods\n Je pilote la pile locale réelle (pod provider + backend Festipod + app servie)\n Afin de prouver que les flux du modèle passent réellement par le Pod.\n\n # PÉRIMÈTRE. Cette couche est DISTINCTE des suites `@data`/`@e2e`/`@multibrowser`\n # suspendues avec le workstream NextGraph : elle ne signe aucun wallet et ne monte\n # aucun broker. Elle n'existe que si la pile locale tourne — son mode d'emploi\n # reproductible est le concept `data-layer`,\n # `cookbook_local-activitypods-platform.md` (pod provider :3000, backend Festipod\n # :3005 par défaut). Le chargement de l'app reprend le motif `@e2e` du\n # repo (serveur réel + Playwright), pas le wallet partagé.\n\n Scénario: La pile locale est joignable et le backend sert son application\n Alors le pod provider local répond sur son endpoint OpenID\n Et le backend Festipod sert la description de son application\n\n @browser\n Scénario: L'application démarre en variante ActivityPods et propose la connexion au Pod\n Quand l'application Festipod est chargée depuis la pile locale\n Alors l'entrée de connexion au Pod est proposée\n Et aucune erreur runtime n'a été émise au chargement\n\n Scénario: Un événement et un point de rencontre rattaché par as:context sont créés sur le Pod\n Étant donné une session Pod locale pour \"alice\" avec le mot de passe \"Test1test\"\n Quand je crée un événement \"Concert smoke\" et un point de rencontre \"Rdv entrée\" rattaché par as:context\n Alors la lecture du point de rencontre expose son lien as:context vers l'événement\n Et l'événement et le point de rencontre partagent le conteneur as:Event\n\n # L'app s'installe sur le Pod (consentement de l'agent d'autorisation, ou pour un\n # scénario sans navigateur l'endpoint d'enregistrement — voir l'étape\n # « installée sur le Pod »). Une fois installée, l'activité `as:Join` postée sur\n # l'outbox de l'utilisateur, sans destinataire, est traitée par le backend à\n # l'émission : il inscrit dans la collection `apods:attendees` du point de\n # rencontre l'URN de membre dérivée du secret de participation du profil\n # (écrit avant le Join, comme le fait l'app) — jamais le WebID\n # (decision_2026-10-29_participation-privacy-mechanism). Le point de rencontre est créé dans le\n # conteneur enregistré (`as/event`) ; sa collection `apods:attendees` est attachée\n # par le backend lui-même, à la création — le scénario la lit, il ne la crée pas.\n Scénario: Rejoindre puis quitter un point de rencontre met à jour la collection apods:attendees\n Étant donné une session Pod locale pour \"alice\" avec le mot de passe \"Test1test\"\n Et l'application Festipod est installée sur le Pod de la session\n Et un point de rencontre \"Rdv entrée\" rattaché à un événement, avec sa collection apods:attendees\n Quand la session rejoint le point de rencontre\n Alors la collection apods:attendees contient la session\n Quand la session quitte le point de rencontre\n Alors la collection apods:attendees ne contient plus la session\n\n # Dépend du scénario précédent pour la mise en place, mais lit la collection\n # elle-même : le compteur est le total de la collection (jamais les profils\n # résolus) et une URN de membre, que rien ne résout, reste comptée et rendue\n # anonyme.\n Scénario: Le compteur et la liste des participants tiennent une référence non résolue\n Étant donné une session Pod locale pour \"alice\" avec le mot de passe \"Test1test\"\n Et l'application Festipod est installée sur le Pod de la session\n Et un point de rencontre \"Rdv entrée\" rattaché à un événement, avec sa collection apods:attendees\n Quand la session rejoint le point de rencontre\n Alors le compteur du point de rencontre compte cette inscription\n Et la liste des participants rend ce participant comme anonyme\n",
|
||||
"rawContent": "# language: fr\n@EVENT @priority-1 @e2e-activitypods\nFonctionnalité: Smoke e2e de la pile ActivityPods locale\n En tant que développeur validant le branchement ActivityPods\n Je pilote la pile locale réelle (pod provider + backend Festipod + app servie)\n Afin de prouver que les flux du modèle passent réellement par le Pod.\n\n # PÉRIMÈTRE. Cette couche est DISTINCTE des suites `@data`/`@e2e`/`@multibrowser`\n # suspendues avec le workstream NextGraph : elle ne signe aucun wallet et ne monte\n # aucun broker. Elle n'existe que si la pile locale tourne — son mode d'emploi\n # reproductible est le concept `data-layer`,\n # `cookbook_local-activitypods-platform.md` (pod provider :3000, backend Festipod\n # :3005 par défaut). Le chargement de l'app reprend le motif `@e2e` du\n # repo (serveur réel + Playwright), pas le wallet partagé.\n\n Scénario: La pile locale est joignable et le backend sert son application\n Alors le pod provider local répond sur son endpoint OpenID\n Et le backend Festipod sert la description de son application\n\n @browser\n Scénario: L'application démarre en variante ActivityPods et propose la connexion au Pod\n Quand l'application Festipod est chargée depuis la pile locale\n Alors l'entrée de connexion au Pod est proposée\n Et aucune erreur runtime n'a été émise au chargement\n\n Scénario: Un événement et un point de rencontre rattaché par as:context sont créés sur le Pod\n Étant donné une session Pod locale pour \"alice\" avec le mot de passe \"Test1test\"\n Quand je crée un événement \"Concert smoke\" et un point de rencontre \"Rdv entrée\" rattaché par as:context\n Alors la lecture du point de rencontre expose son lien as:context vers l'événement\n Et l'événement et le point de rencontre partagent le conteneur as:Event\n\n # L'app s'installe sur le Pod (consentement de l'agent d'autorisation, ou pour un\n # scénario sans navigateur l'endpoint d'enregistrement — voir l'étape\n # « installée sur le Pod »). Une fois installée, l'activité `as:Join` postée sur\n # l'outbox de l'utilisateur, adressée à l'acteur de l'app seul, est livrée dans\n # l'inbox de l'app et traitée par le backend à sa réception : il inscrit dans la collection `apods:attendees` du point de\n # rencontre l'URN de membre dérivée du secret de participation du profil\n # (écrit avant le Join, comme le fait l'app) — jamais le WebID\n # (decision_2026-10-29_participation-privacy-mechanism). Le point de rencontre est créé dans le\n # conteneur enregistré (`as/event`) ; sa collection `apods:attendees` est attachée\n # par le backend lui-même, à la création — le scénario la lit, il ne la crée pas.\n Scénario: Rejoindre puis quitter un point de rencontre met à jour la collection apods:attendees\n Étant donné une session Pod locale pour \"alice\" avec le mot de passe \"Test1test\"\n Et l'application Festipod est installée sur le Pod de la session\n Et un point de rencontre \"Rdv entrée\" rattaché à un événement, avec sa collection apods:attendees\n Quand la session rejoint le point de rencontre\n Alors la collection apods:attendees contient la session\n Quand la session quitte le point de rencontre\n Alors la collection apods:attendees ne contient plus la session\n\n # Dépend du scénario précédent pour la mise en place, mais lit la collection\n # elle-même : le compteur est le total de la collection (jamais les profils\n # résolus) et une URN de membre, que rien ne résout, reste comptée et rendue\n # anonyme.\n Scénario: Le compteur et la liste des participants tiennent une référence non résolue\n Étant donné une session Pod locale pour \"alice\" avec le mot de passe \"Test1test\"\n Et l'application Festipod est installée sur le Pod de la session\n Et un point de rencontre \"Rdv entrée\" rattaché à un événement, avec sa collection apods:attendees\n Quand la session rejoint le point de rencontre\n Alors le compteur du point de rencontre compte cette inscription\n Et la liste des participants rend ce participant comme anonyme\n",
|
||||
"screenIds": []
|
||||
},
|
||||
{
|
||||
|
||||
@@ -5,12 +5,15 @@ import type { OutboxActivityInput } from './outbox';
|
||||
const ACTOR = 'http://localhost:3000/alice';
|
||||
const OUTBOX = 'http://localhost:3000/alice/outbox';
|
||||
// What a Join names (decision_2026-10-29_participation-privacy-mechanism): the
|
||||
// meeting point itself, with no addressee.
|
||||
// meeting point itself; its only addressee is the app actor
|
||||
// (decision_2026-11-03_participation-delivered-to-the-app).
|
||||
const MEETING_POINT = 'http://localhost:3000/bob/data/events/concert-bar';
|
||||
const APP_ACTOR = 'http://localhost:3005/as/actor/app';
|
||||
|
||||
const input: OutboxActivityInput = {
|
||||
meetingPointUri: MEETING_POINT,
|
||||
session: { webId: ACTOR, token: 'tok-1' },
|
||||
appActorUri: APP_ACTOR,
|
||||
};
|
||||
|
||||
/** Installs a scripted fetch double, returning the calls it saw. */
|
||||
@@ -38,7 +41,7 @@ function jsonResponse(body: unknown, status = 200): Response {
|
||||
});
|
||||
}
|
||||
|
||||
test('postJoinActivity discovers the outbox from the actor document and posts a Join naming the meeting point, with no addressee', async () => {
|
||||
test('postJoinActivity discovers the outbox from the actor document and posts a Join naming the meeting point, addressed to the app actor alone', async () => {
|
||||
const double = installFetch((url) => {
|
||||
if (url === ACTOR) return jsonResponse({ id: ACTOR, outbox: OUTBOX, url: `${ACTOR}/as/profile` });
|
||||
return jsonResponse({}, 201);
|
||||
@@ -53,20 +56,21 @@ test('postJoinActivity discovers the outbox from the actor document and posts a
|
||||
const headers = postCall!.init.headers as Record<string, string>;
|
||||
expect(headers['Authorization']).toBe('Bearer tok-1');
|
||||
expect(headers['Content-Type']).toBe('application/ld+json');
|
||||
// Exactly these keys: no `to`, no `cc` — the activity stays readable by its
|
||||
// author and the app only.
|
||||
// Exactly these keys: `to` the app actor, no `cc`, not public — the activity
|
||||
// stays readable by its author and the app only.
|
||||
expect(JSON.parse(String(postCall!.init.body))).toEqual({
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
type: 'Join',
|
||||
actor: ACTOR,
|
||||
object: MEETING_POINT,
|
||||
to: APP_ACTOR,
|
||||
});
|
||||
} finally {
|
||||
double.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('postLeaveActivity posts a Leave naming the meeting point, with no addressee', async () => {
|
||||
test('postLeaveActivity posts a Leave naming the meeting point, addressed to the app actor alone', async () => {
|
||||
const double = installFetch((url) => {
|
||||
if (url === ACTOR) return jsonResponse({ id: ACTOR, outbox: OUTBOX });
|
||||
return jsonResponse({}, 201);
|
||||
@@ -78,6 +82,7 @@ test('postLeaveActivity posts a Leave naming the meeting point, with no addresse
|
||||
type: 'Leave',
|
||||
actor: ACTOR,
|
||||
object: MEETING_POINT,
|
||||
to: APP_ACTOR,
|
||||
});
|
||||
} finally {
|
||||
double.restore();
|
||||
|
||||
@@ -7,13 +7,14 @@
|
||||
* posted to the USER'S OWN outbox with the user's token, a withdrawal an
|
||||
* `as:Leave` on the same path (decision_2026-10-29_participation-privacy-mechanism).
|
||||
*
|
||||
* THE ACTIVITY. Its `object` is the meeting point itself, and it carries no
|
||||
* `to`/`cc`: not public, not addressed to the meeting point's creator. A
|
||||
* non-public activity is readable only by its author and the app (the app reads
|
||||
* the outbox through `apods:ReadOutbox`), so the target stays hidden from
|
||||
* everyone else. The Festipod backend handles it on emission — it records the
|
||||
* author's member URN in the meeting point's attendee list and the meeting
|
||||
* point in the author's participations collection.
|
||||
* THE ACTIVITY. Its `object` is the meeting point itself, and its only
|
||||
* addressee is the Festipod app actor (`to`): not public, not addressed to the
|
||||
* meeting point's creator. A non-public activity is readable only by its author
|
||||
* and its addressees, so the target stays hidden from everyone else. The user's
|
||||
* pod delivers it to the app's inbox, where the Festipod backend handles it —
|
||||
* it records the author's member URN in the meeting point's attendee list and
|
||||
* the meeting point in the author's participations collection
|
||||
* (decision_2026-11-03_participation-delivered-to-the-app).
|
||||
*
|
||||
* THE FLOW. The outbox URI is read off the user's own actor (WebID) document
|
||||
* (`outbox` predicate) at post time — the reference integration resolves it
|
||||
@@ -37,20 +38,23 @@ export interface OutboxActivityInput {
|
||||
meetingPointUri: string;
|
||||
/** The authenticated session — whose outbox receives the activity. */
|
||||
session: CurrentSession;
|
||||
/** URI of the Festipod app actor, the activity's only addressee (the Solid-OIDC client id). */
|
||||
appActorUri: string;
|
||||
}
|
||||
|
||||
async function postActivity(type: 'Join' | 'Leave', input: OutboxActivityInput): Promise<void> {
|
||||
const { meetingPointUri, session } = input;
|
||||
const { meetingPointUri, session, appActorUri } = input;
|
||||
const actor = await fetchActorDocument(session.webId, session.token);
|
||||
if (!actor.outbox) {
|
||||
throw new Error(`actor document for ${session.webId} carries no outbox — cannot post ${type}`);
|
||||
}
|
||||
// No addressee: the activity stays readable by its author and the app only.
|
||||
// The app actor alone: the activity stays readable by its author and the app only.
|
||||
const activity: Record<string, unknown> = {
|
||||
'@context': ACTIVITY_CONTEXT,
|
||||
type,
|
||||
actor: session.webId,
|
||||
object: meetingPointUri,
|
||||
to: appActorUri,
|
||||
};
|
||||
|
||||
const response = await fetch(actor.outbox, {
|
||||
|
||||
@@ -435,77 +435,77 @@ export const stepDefinitions: StepDefinitionInfo[] = [
|
||||
"keyword": "Then",
|
||||
"file": "activitypods.steps.ts",
|
||||
"sourceCode": "Then(\"le pod provider local répond sur son endpoint OpenID\", async function (this: FestipodWorld) {\n const { podProviderUrl } = apodsStackConfig();\n const status = await urlStatus(`${podProviderUrl}/.well-known/openid-configuration`);\n expect(status, `GET ${podProviderUrl}/.well-known/openid-configuration`).to.equal(200);\n});",
|
||||
"lineNumber": 95
|
||||
"lineNumber": 100
|
||||
},
|
||||
{
|
||||
"pattern": "le backend Festipod sert la description de son application",
|
||||
"keyword": "Then",
|
||||
"file": "activitypods.steps.ts",
|
||||
"sourceCode": "Then(\"le backend Festipod sert la description de son application\", async function (this: FestipodWorld) {\n const { backendUrl } = apodsStackConfig();\n const description = await readResource(undefined, `${backendUrl}/app`);\n // The description IS the OIDC client the app registers with: it must carry\n // the interop access-need group the Pod grants, not merely answer 200.\n expect(JSON.stringify(description.type)).to.contain('Application');\n expect(linkId(description['interop:hasAccessNeedGroup']), 'access-need group').to.be.a('string');\n});",
|
||||
"lineNumber": 101
|
||||
"lineNumber": 106
|
||||
},
|
||||
{
|
||||
"pattern": "l'application Festipod est chargée depuis la pile locale",
|
||||
"keyword": "When",
|
||||
"file": "activitypods.steps.ts",
|
||||
"sourceCode": "When(\"l'application Festipod est chargée depuis la pile locale\", async function (this: FestipodWorld) {\n const appUrl = requires(this, this.apods.appUrl, 'app URL (set by the @browser Before hook)');\n expect(this.page, 'a Playwright page (set by the @browser Before hook)').to.not.be.null;\n await this.page!.goto(appUrl, { waitUntil: 'domcontentloaded' });",
|
||||
"lineNumber": 112
|
||||
"lineNumber": 117
|
||||
},
|
||||
{
|
||||
"pattern": "l'entrée de connexion au Pod est proposée",
|
||||
"keyword": "Then",
|
||||
"file": "activitypods.steps.ts",
|
||||
"sourceCode": "Then(\"l'entrée de connexion au Pod est proposée\", async function (this: FestipodWorld) {\n const { podProviderUrl } = apodsStackConfig();\n const host = new URL(podProviderUrl).host;\n await this.page!.waitForSelector(`text=Se connecter avec ${host}`, { timeout: 30000 });",
|
||||
"lineNumber": 120
|
||||
"lineNumber": 125
|
||||
},
|
||||
{
|
||||
"pattern": "aucune erreur runtime n'a été émise au chargement",
|
||||
"keyword": "Then",
|
||||
"file": "activitypods.steps.ts",
|
||||
"sourceCode": "Then(\"aucune erreur runtime n'a été émise au chargement\", function (this: FestipodWorld) {\n expect(\n this.pageErrors,\n `Des erreurs runtime ont été émises au chargement de l'app :\\n` +\n this.pageErrors.map((e, i) => ` [${i + 1}] ${e}`).join('\\n'),\n ).to.be.empty;\n});",
|
||||
"lineNumber": 126
|
||||
"lineNumber": 131
|
||||
},
|
||||
{
|
||||
"pattern": "l'application Festipod est installée sur le Pod de la session",
|
||||
"keyword": "Given",
|
||||
"file": "activitypods.steps.ts",
|
||||
"sourceCode": "Given(\"l'application Festipod est installée sur le Pod de la session\", async function (this: FestipodWorld) {\n const pod = session(this);\n const config = apodsStackConfig();\n // The registered agent IS the Solid-OIDC client_id: the app actor URI the\n // backend serves as both the actor and its Client ID Document. Registering\n // against `/app` (the alias) is refused — the agent must be the actor URI.\n const description = await readResource(undefined, `${config.backendUrl}/app`);\n const appUri = requires(this, recordUri(description), 'app actor URI (the id of GET /app)');\n const groupUri = requires(this, linkId(description['interop:hasAccessNeedGroup']), 'access-need group URI');\n const group = await readResource(undefined, groupUri);\n const acceptedAccessNeeds = arrayOf(group['interop:hasAccessNeed']);\n const acceptedSpecialRights = arrayOf(group['apods:hasSpecialRights']);\n\n const response = await fetch(`${config.podProviderUrl}/.auth-agent/app-registrations/register`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${pod.token}` },\n body: JSON.stringify({ appUri, acceptedAccessNeeds, acceptedSpecialRights }),\n });",
|
||||
"lineNumber": 143
|
||||
"lineNumber": 148
|
||||
},
|
||||
{
|
||||
"pattern": "la session rejoint le point de rencontre",
|
||||
"keyword": "When",
|
||||
"file": "activitypods.steps.ts",
|
||||
"sourceCode": "When('la session rejoint le point de rencontre', async function (this: FestipodWorld) {\n const pod = session(this);\n const mpUri = requires(this, this.apods.meetingPointUri, 'meeting point URI');\n // The app's own path (src/shared/data/outbox.ts): as:Join to the user's own\n // outbox, `object` = the meeting point URI and `to` = the HOST (the record's\n // dc:creator). Without the addressee the backend silently drops the activity;\n // and an activity carrying no routing target never reaches the host.\n const host = linkId((await readResource(pod, mpUri))['dc:creator']) ?? pod.webId;\n await postOutboxActivity(pod, 'Join', mpUri, host);\n});",
|
||||
"lineNumber": 267
|
||||
"sourceCode": "When('la session rejoint le point de rencontre', async function (this: FestipodWorld) {\n const pod = session(this);\n const mpUri = requires(this, this.apods.meetingPointUri, 'meeting point URI');\n // The app's own sequence (refineData.tsx, src/shared/data/outbox.ts —\n // decision_2026-10-29_participation-privacy-mechanism): the participation\n // secret on my profile first (the backend refuses a Join whose author's\n // profile carries none), then an as:Join naming the meeting point, posted to\n // my OWN outbox and addressed to the app actor alone — not public, not\n // addressed to the creator (decision_2026-11-03_participation-delivered-to-the-app).\n await ensureParticipationSecret(pod);\n await postOutboxActivity(pod, 'Join', mpUri, appActorUri(apodsStackConfig()));\n});",
|
||||
"lineNumber": 276
|
||||
},
|
||||
{
|
||||
"pattern": "la session quitte le point de rencontre",
|
||||
"keyword": "When",
|
||||
"file": "activitypods.steps.ts",
|
||||
"sourceCode": "When('la session quitte le point de rencontre', async function (this: FestipodWorld) {\n const pod = session(this);\n const mpUri = requires(this, this.apods.meetingPointUri, 'meeting point URI');\n const host = linkId((await readResource(pod, mpUri))['dc:creator']) ?? pod.webId;\n await postOutboxActivity(pod, 'Leave', mpUri, host);\n});",
|
||||
"lineNumber": 278
|
||||
"sourceCode": "When('la session quitte le point de rencontre', async function (this: FestipodWorld) {\n const pod = session(this);\n const mpUri = requires(this, this.apods.meetingPointUri, 'meeting point URI');\n await postOutboxActivity(pod, 'Leave', mpUri, appActorUri(apodsStackConfig()));\n});",
|
||||
"lineNumber": 289
|
||||
},
|
||||
{
|
||||
"pattern": "la collection apods:attendees contient la session",
|
||||
"keyword": "Then",
|
||||
"file": "activitypods.steps.ts",
|
||||
"sourceCode": "Then('la collection apods:attendees contient la session', async function (this: FestipodWorld) {\n const pod = session(this);\n const collectionUri = requires(this, this.apods.collectionUri, 'attendees collection URI');\n const members = await waitForMembers(pod, collectionUri, found => found.map(linkId).includes(pod.webId));\n expect(members.map(linkId)).to.include(pod.webId);\n});",
|
||||
"lineNumber": 285
|
||||
"sourceCode": "Then('la collection apods:attendees contient la session', async function (this: FestipodWorld) {\n const pod = session(this);\n const collectionUri = requires(this, this.apods.collectionUri, 'attendees collection URI');\n const urn = await ownMemberUrn(this);\n const members = (await waitForMembers(pod, collectionUri, found => found.map(linkId).includes(urn))).map(linkId);\n expect(members, 'the member URN derived from my secret is listed').to.include(urn);\n expect(members, 'the list never holds a WebID').to.not.include(pod.webId);\n});",
|
||||
"lineNumber": 308
|
||||
},
|
||||
{
|
||||
"pattern": "le compteur du point de rencontre compte cette inscription",
|
||||
"keyword": "Then",
|
||||
"file": "activitypods.steps.ts",
|
||||
"sourceCode": "Then('le compteur du point de rencontre compte cette inscription', async function (this: FestipodWorld) {\n const pod = session(this);\n const collectionUri = requires(this, this.apods.collectionUri, 'attendees collection URI');\n const members = await waitForMembers(pod, collectionUri, found => found.map(linkId).includes(pod.webId));\n const collection = await readResource(pod, collectionUri);\n // The count is the collection total — kept as-is, never the number of\n // RESOLVED profiles, so it stays total for a reader who resolves nobody.\n const raw = collection.totalItems ?? collection['as:totalItems'];\n const total = raw === undefined ? members.length : Number(raw);\n expect(total, 'the count reflects the joined session').to.be.greaterThan(0);\n expect(members.map(linkId), 'the counted session is the one listed').to.include(pod.webId);\n});",
|
||||
"lineNumber": 304
|
||||
"sourceCode": "Then('le compteur du point de rencontre compte cette inscription', async function (this: FestipodWorld) {\n const pod = session(this);\n const collectionUri = requires(this, this.apods.collectionUri, 'attendees collection URI');\n const urn = await ownMemberUrn(this);\n const members = await waitForMembers(pod, collectionUri, found => found.map(linkId).includes(urn));\n const collection = await readResource(pod, collectionUri);\n // The count is the collection total — kept as-is, never the number of\n // RESOLVED profiles, so it stays total for a reader who resolves nobody.\n const raw = collection.totalItems ?? collection['as:totalItems'];\n const total = raw === undefined ? members.length : Number(raw);\n expect(total, 'the count reflects the joined session').to.be.greaterThan(0);\n expect(members.map(linkId), 'the counted session is the one listed (its member URN)').to.include(urn);\n});",
|
||||
"lineNumber": 331
|
||||
},
|
||||
{
|
||||
"pattern": "la liste des participants rend ce participant comme anonyme",
|
||||
"keyword": "Then",
|
||||
"file": "activitypods.steps.ts",
|
||||
"sourceCode": "Then('la liste des participants rend ce participant comme anonyme', async function (this: FestipodWorld) {\n const pod = session(this);\n const collectionUri = requires(this, this.apods.collectionUri, 'attendees collection URI');\n const members = await waitForMembers(pod, collectionUri, found => found.map(linkId).includes(pod.webId));\n const joined = members.find(member => linkId(member) === pod.webId);\n expect(joined, 'the joined session is listed').to.not.be.undefined;\n // \"Anonymous\" is the read-side fact: the member travels as a REFERENCE with no\n // profile the reader can resolve — the list has nothing but the URI to render,\n // which is exactly what the app shows for a participant it cannot dereference.\n const profileFields = ['name', 'preferredUsername', 'as:name', 'nickname'];\n const hasProfile =\n typeof joined === 'object' && joined !== null && profileFields.some(field => (joined as Record<string, unknown>)[field] !== undefined);\n expect(hasProfile, 'a listed participant carries no resolved profile (rendered anonymous)').to.be.false;\n});",
|
||||
"lineNumber": 317
|
||||
"sourceCode": "Then('la liste des participants rend ce participant comme anonyme', async function (this: FestipodWorld) {\n const pod = session(this);\n const collectionUri = requires(this, this.apods.collectionUri, 'attendees collection URI');\n const urn = await ownMemberUrn(this);\n const members = await waitForMembers(pod, collectionUri, found => found.map(linkId).includes(urn));\n const joined = members.find(member => linkId(member) === urn);\n expect(joined, 'the joined session is listed').to.not.be.undefined;\n // \"Anonymous\" is the read-side fact: the member is a member URN — a value in\n // no pod, no WebID, nothing to dereference — so a reader who cannot compute\n // it from a contact's secret has nothing but the count to render.\n expect(members.map(linkId).every(id => typeof id === 'string' && id.startsWith(MEMBER_URN_PREFIX)), 'every member is a member URN').to.be.true;\n const profileFields = ['name', 'preferredUsername', 'as:name', 'nickname'];\n const hasProfile =\n typeof joined === 'object' && joined !== null && profileFields.some(field => (joined as Record<string, unknown>)[field] !== undefined);\n expect(hasProfile, 'a listed participant carries no resolved profile (rendered anonymous)').to.be.false;\n});",
|
||||
"lineNumber": 345
|
||||
},
|
||||
{
|
||||
"pattern": "le portefeuille contient des données de test",
|
||||
|
||||
@@ -46,6 +46,11 @@ export function apodsStackConfig(): ApodsStackConfig {
|
||||
};
|
||||
}
|
||||
|
||||
/** The Festipod app actor of a stack: the only addressee of a Join or a Leave. */
|
||||
export function appActorUri(config: ApodsStackConfig): string {
|
||||
return `${config.backendUrl}/as/actor/app`;
|
||||
}
|
||||
|
||||
/** An authenticated Pod session, plus the actor references the flows need. */
|
||||
export interface ApodsSession {
|
||||
webId: string;
|
||||
@@ -203,11 +208,12 @@ export const PARTICIPATIONS = 'http://festipod.org/participations';
|
||||
/**
|
||||
* Post a participation activity (`Join` / `Leave`) to the session's own outbox,
|
||||
* the shape the app posts (`src/shared/data/outbox.ts`): `object` = the meeting
|
||||
* point, NO `to`/`cc` — not public, addressed to no one, so only its author and
|
||||
* the app read it. Returns the stored activity's URI (`Location`).
|
||||
* point, `to` = the app actor alone (`appActorUri`) — not public, not addressed to
|
||||
* the creator, so only its author and the app read it; the pod delivers it to the
|
||||
* app's inbox. Returns the stored activity's URI (`Location`).
|
||||
*/
|
||||
export async function postOutboxActivity(session: ApodsSession, type: 'Join' | 'Leave', object: string): Promise<string | undefined> {
|
||||
const activity: JsonRecord = { '@context': AS_CONTEXT, type, actor: session.webId, object };
|
||||
export async function postOutboxActivity(session: ApodsSession, type: 'Join' | 'Leave', object: string, to: string): Promise<string | undefined> {
|
||||
const activity: JsonRecord = { '@context': AS_CONTEXT, type, actor: session.webId, object, to };
|
||||
const response = await request(session.outbox, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/ld+json' },
|
||||
|
||||
Reference in New Issue
Block a user