Files
Sylvain Duchesne 5e3e821e2a Run locally like the sibling apps: fixed ports, the pod provider in Docker, the app outside
Festipod now follows the local convention of welcometomyplace, mutual-aid.app
and portejunes: one fixed port pair per app (Festipod defaults: backend 3005,
app 4005; pod provider 3000/5000), overridable in backend/.env and .env.
`make start` runs docker-compose-dev.yml (the local pod provider from images,
Fuseki, Redis, mailcatcher, Arena); the backend and the app run outside Docker
with `cd backend && pnpm dev` and `pnpm dev`. pnpm stays the package manager.

The old docker-compose.yml, which containerized Festipod's own frontend and
backend and could not reach the host's Fuseki and Redis, is gone; portless is
dropped from the dev script, since the backend declares a single OIDC redirect
URI. The local backend runs without a Redis cache, as the sibling apps do. The
e2e suites and validate read the app, backend and pod provider URLs from the
env files instead of hardcoding them.

The deployment and local cookbook leaves record the traps met while resetting
datasets: a dataset re-created under the same name needs a Fuseki restart
(ClosedException otherwise), the anonymous WebACL probe at boot is expected,
and a change of the backend's URL leaves persistent accounts with a
participations collection the new app identity cannot control.
2026-11-01 11:40:27 +01:00

161 lines
6.8 KiB
TypeScript

/**
* DIAGNOSTIC (not a gate): reproduce the invite-link contact flow end to end
* with two FRESH accounts and inspect what lands in each pod's apods:contacts —
* then run the app's own fetchCollectionMemberIds against the real collections.
*
* Usage: bun scripts/diag-invite.ts
*/
import {
loginWithRetry,
http,
poll,
parseJson,
itemUris,
need,
sleep,
} from './e2e-rights-matrix-support';
import type { ApodsSession } from '../src/shared/support/activitypodsStack';
// The app's real reader — tested here against a REAL collection document.
import { fetchCollectionMemberIds } from '../src/shared/data/contacts';
const TS = Date.now();
const PASS = 'Test1test';
const POD = process.env.VITE_POD_PROVIDER_URL || 'http://localhost:3000';
const A_NAME = `diaginv${TS}a`;
const B_NAME = `diaginv${TS}b`;
const config = { podProviderUrl: POD, backendUrl: process.env.BACKEND_URL || process.env.VITE_BACKEND_URL || 'http://localhost:3005' };
const CRED_CTX = [
'https://www.w3.org/ns/credentials/v2',
{ as: 'https://www.w3.org/ns/activitystreams#', apods: 'http://activitypods.org/ns/core#', acl: 'http://www.w3.org/ns/auth/acl#' },
];
async function actorDoc(session: ApodsSession): Promise<Record<string, any>> {
const r = await http('GET', session.webId, { token: session.token });
return need(parseJson(r.text), `actor doc for ${session.webId}`);
}
const linkOf = (v: any): string | undefined =>
typeof v === 'string' ? v : v?.id ?? v?.['@id'];
async function main() {
console.log('1) Signup of two fresh accounts (browser-driven, slow)...');
const a = await loginWithRetry(config, A_NAME, PASS);
const b = await loginWithRetry(config, B_NAME, PASS);
console.log(' A (inviter):', a.webId);
console.log(' B (invitee):', b.webId);
const aDoc = await actorDoc(a);
const bDoc = await actorDoc(b);
const aProfile = need(linkOf(aDoc.url), 'A profile URI');
const bProfile = need(linkOf(bDoc.url), 'B profile URI');
const bOutbox = need(linkOf(bDoc.outbox), 'B outbox');
console.log('2) A creates the invite capability...');
const issue = async () =>
http('POST', `${a.webId}/vc/v0.3/credentials/issue`, {
token: a.token,
contentType: 'application/json',
accept: 'application/ld+json',
timeoutMs: 30000,
body: JSON.stringify({
credential: {
'@context': CRED_CTX,
type: 'VerifiableCredential',
name: 'Invite Link',
credentialSubject: {
'apods:hasActivityGrant': {
type: 'as:Offer',
'as:to': { '@id': a.webId },
'as:target': { '@id': a.webId },
'as:object': { type: 'as:Add', 'as:object': { type: 'as:Profile' } },
},
'apods:hasAuthorization': {
type: 'acl:Authorization',
'acl:mode': 'acl:Read',
'acl:accessTo': { '@id': aProfile },
},
},
},
}),
});
let capRes = await issue();
for (let attempt = 0; capRes.status === 0 && attempt < 10; attempt++) {
console.log(' VC issue unreachable (', capRes.error ?? 'timeout', ') — retrying in 3 s...');
await sleep(3000);
capRes = await issue();
}
const capJson = parseJson(capRes.text);
const capUri = typeof capJson?.id === 'string' ? capJson.id : undefined;
console.log(' VC issue:', capRes.status, capUri ?? `${capRes.error ?? ''} ${capRes.text.slice(0, 160)}`);
if (!capUri) throw new Error('capability creation failed');
console.log('3) B reads the capability (proof the grant works)...');
const capDoc = await http('GET', capUri, { token: b.token, accept: 'application/ld+json' });
console.log(' capability fetch:', capDoc.status, capDoc.text.slice(0, 120).replace(/\s+/g, ' '));
console.log('4) B builds the presentation (challenge from A, sign at B)...');
const challengeRes = await http('POST', `${a.webId}/vc/v0.3/challenges`, { token: b.token, accept: 'application/ld+json' });
const challenge = need(parseJson(challengeRes.text)?.challenge, 'challenge');
const presRes = await http('POST', `${b.webId}/vc/v0.3/presentations/`, {
token: b.token,
contentType: 'application/json',
accept: 'application/ld+json',
body: JSON.stringify({
presentation: {
'@context': CRED_CTX,
type: 'VerifiablePresentation',
verifiableCredential: [need(parseJson(capDoc.text), 'VC doc')],
},
options: { challenge, persist: false },
}),
});
const presentation = parseJson(presRes.text);
console.log(' presentation:', presRes.status, presentation ? 'ok' : presRes.text.slice(0, 160));
console.log('5) B posts Offer{Add{profile}} + capability to B\'s outbox...');
const offer = await http('POST', bOutbox, {
token: b.token,
contentType: 'application/ld+json',
accept: 'application/ld+json',
body: JSON.stringify({
'@context': 'https://www.w3.org/ns/activitystreams',
type: 'Offer',
actor: b.webId,
object: { type: 'Add', object: { id: bProfile, type: 'Profile' } },
target: a.webId,
to: a.webId,
capability: presentation,
}),
});
console.log(' outbox post:', offer.status, offer.text.slice(0, 120).replace(/\s+/g, ' '));
console.log('6) Polling the apods:contacts collections (up to 60 s)...');
const contactsUriOf = (doc: Record<string, any>) => linkOf(doc['apods:contacts']);
const aContacts = need(contactsUriOf(aDoc), 'A contacts URI');
const bContacts = need(contactsUriOf(bDoc), 'B contacts URI');
const readItems = async (uri: string, token: string) =>
itemUris(parseJson((await http('GET', uri, { token })).text));
const aPoll = await poll(() => readItems(aContacts, a.token), v => v.length > 0, 60000, 2000);
await sleep(3000);
const bPoll = await readItems(bContacts, b.token);
console.log(' A contacts items:', JSON.stringify(aPoll));
console.log(' B contacts items:', JSON.stringify(bPoll));
console.log('7) The APP reader against the REAL collection documents...');
const aDocRaw = (await http('GET', aContacts, { token: a.token })).text;
const bDocRaw = (await http('GET', bContacts, { token: b.token })).text;
console.log(' A collection doc (first 400):', aDocRaw.replace(/\s+/g, ' ').slice(0, 400));
console.log(' B collection doc (first 400):', bDocRaw.replace(/\s+/g, ' ').slice(0, 400));
const appReadsA = await fetchCollectionMemberIds(aContacts, a.token, a.webId);
const appReadsB = await fetchCollectionMemberIds(bContacts, b.token, b.webId);
console.log(' app fetchCollectionMemberIds(A):', JSON.stringify(appReadsA));
console.log(' app fetchCollectionMemberIds(B):', JSON.stringify(appReadsB));
const ok = appReadsA.includes(b.webId) && appReadsB.includes(a.webId);
console.log(ok ? 'VERDICT: platform flow + app reader BOTH OK' : 'VERDICT: MISMATCH — see above');
process.exit(ok ? 0 : 1);
}
main().catch((e) => { console.error('DIAG FAILED:', e); process.exit(2); });