/** * 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> { 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) => 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); });