diff --git a/apps/credit-history/src/app/api/presentations/[ref]/route.ts b/apps/credit-history/src/app/api/presentations/[ref]/route.ts new file mode 100644 index 0000000..c1849f6 --- /dev/null +++ b/apps/credit-history/src/app/api/presentations/[ref]/route.ts @@ -0,0 +1,38 @@ +/** + * GET /api/presentations/[ref] — resolve a share reference. + * + * Expiration is enforced here, not by the caller: an expired reference returns + * 410 and is dropped from the store, so editing the URL cannot extend a link. + */ + +import { NextResponse } from 'next/server'; +import { resolveStoredPresentation } from '@/lib/presentation-store'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +const NO_STORE = { 'Cache-Control': 'no-store' } as const; + +export async function GET( + _request: Request, + { params }: { params: Promise<{ ref: string }> } +): Promise { + const { ref } = await params; + const resolution = await resolveStoredPresentation(ref); + + if (resolution.status === 'not_found') { + return NextResponse.json({ reason: 'not_found' }, { status: 404, headers: NO_STORE }); + } + + if (resolution.status === 'expired') { + return NextResponse.json( + { reason: 'expired', expiresAt: resolution.expiresAt }, + { status: 410, headers: NO_STORE } + ); + } + + return NextResponse.json( + { presentation: resolution.presentation }, + { status: 200, headers: NO_STORE } + ); +} diff --git a/apps/credit-history/src/app/api/presentations/route.ts b/apps/credit-history/src/app/api/presentations/route.ts new file mode 100644 index 0000000..58d12ff --- /dev/null +++ b/apps/credit-history/src/app/api/presentations/route.ts @@ -0,0 +1,103 @@ +/** + * POST /api/presentations — create a shareable presentation. + * + * The holder posts the credential ids they picked, the expiration they chose + * and (when the wallet could produce one) a proof over the presentation + * digest. The response only contains the opaque reference; the payload itself + * never leaves the server. + */ + +import { NextResponse } from 'next/server'; +import { + InvalidPresentationError, + presentationExpiresAt, + type PresentationProof, +} from '@acta-products/acta/presentation'; +import { createStoredPresentation } from '@/lib/presentation-store'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +interface CreateRequestBody { + holder?: unknown; + credentialIds?: unknown; + expiresAt?: unknown; + createdAt?: unknown; + proof?: unknown; +} + +function parseProof(raw: unknown): PresentationProof | undefined { + if (typeof raw !== 'object' || raw === null) return undefined; + const proof = raw as Record; + + if ( + typeof proof.digest !== 'string' || + typeof proof.signature !== 'string' || + typeof proof.verificationMethod !== 'string' + ) { + return undefined; + } + + return { + type: 'StellarWalletSignature2026', + created: typeof proof.created === 'string' ? proof.created : new Date().toISOString(), + verificationMethod: proof.verificationMethod, + digest: proof.digest, + signature: proof.signature, + }; +} + +export async function POST(request: Request): Promise { + let body: CreateRequestBody; + + try { + body = (await request.json()) as CreateRequestBody; + } catch { + return NextResponse.json({ error: 'Malformed JSON body.' }, { status: 400 }); + } + + const { holder, credentialIds, expiresAt, createdAt } = body; + + if (typeof holder !== 'string' || holder.length === 0) { + return NextResponse.json({ error: 'A holder DID is required.' }, { status: 400 }); + } + + if (!Array.isArray(credentialIds)) { + return NextResponse.json({ error: 'credentialIds must be an array.' }, { status: 400 }); + } + + if (expiresAt !== null && typeof expiresAt !== 'number') { + return NextResponse.json( + { error: 'expiresAt must be a timestamp in milliseconds or null.' }, + { status: 400 } + ); + } + + if (createdAt !== undefined && typeof createdAt !== 'number') { + return NextResponse.json( + { error: 'createdAt must be a timestamp in milliseconds.' }, + { status: 400 } + ); + } + + try { + const { ref, presentation } = await createStoredPresentation({ + holder, + credentialIds: credentialIds as string[], + expiresAt, + createdAt, + proof: parseProof(body.proof), + }); + + return NextResponse.json( + { ref, expiresAt: presentationExpiresAt(presentation) }, + { status: 201, headers: { 'Cache-Control': 'no-store' } } + ); + } catch (err) { + if (err instanceof InvalidPresentationError) { + return NextResponse.json({ error: err.message }, { status: 400 }); + } + console.error('Failed to create presentation', err); + return NextResponse.json({ error: 'Could not create the presentation.' }, { status: 500 }); + } +} diff --git a/apps/credit-history/src/app/share/page.tsx b/apps/credit-history/src/app/share/page.tsx index 373f60c..6304351 100644 --- a/apps/credit-history/src/app/share/page.tsx +++ b/apps/credit-history/src/app/share/page.tsx @@ -27,9 +27,17 @@ import { FileText, CheckCircle2, } from 'lucide-react'; +import { + createPresentationLink, + PresentationLinkError, + signPresentation, +} from '@/lib/presentation-link'; +import { useSession } from '@/session/session-provider'; +import { getWalletConnector, resolveNetworkPassphrase } from '@/session/wallet-connector'; export default function SharePage() { const t = useTranslations('share'); + const { address, did } = useSession(); const format = useFormatter(); const [credentials, setCredentials] = React.useState([]); const [profile, setProfile] = React.useState(null); @@ -101,40 +109,49 @@ export default function SharePage() { } }; - const handleGenerateLink = () => { + const handleGenerateLink = async () => { if (selectedIds.length === 0) { alert(t('noSelection')); return; } - setIsGenerating(true); + const holder = did ?? profile?.holderDid ?? ''; + if (!holder) { + alert(t('noHolder')); + return; + } - // Simulate link generation delay - setTimeout(() => { - const now = Date.now(); - const expirationTime = getExpirationTimestamp(now); - setGeneratedExpirationTime(expirationTime); + setIsGenerating(true); - // SEAM: the real ACTA sharing will be wired here (encrypted off-chain payload / ZK). - // Today we only generate a mock token. Missing: encryption, persistence, real expiration. - const payload = { - ids: selectedIds, - exp: expirationTime, + try { + const createdAt = Date.now(); + const expirationTime = getExpirationTimestamp(createdAt); + const input = { + holder, + credentialIds: selectedIds, + expiresAt: expirationTime, + createdAt, }; - const jsonStr = JSON.stringify(payload); - - // Safe base64url encoding client-side - const utf8Bytes = new TextEncoder().encode(jsonStr); - const base64 = btoa(String.fromCharCode(...utf8Bytes)); - const token = base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + // Holder proof over the presentation digest. Best-effort: a wallet that + // declines to sign a bare digest must not block the share flow — the + // opaque server-side reference is what makes the link tamper-evident. + const proof = + (await signPresentation(getWalletConnector(), input, { + networkPassphrase: resolveNetworkPassphrase(), + address: address ?? undefined, + })) ?? undefined; - const origin = typeof window !== 'undefined' ? window.location.origin : ''; - const shareUrl = `${origin}/verify/${token}`; + const { url, expiresAt } = await createPresentationLink({ ...input, proof }); - setGeneratedLink(shareUrl); + setGeneratedExpirationTime(expiresAt); + setGeneratedLink(url); + } catch (err) { + console.error('Failed to create the shareable presentation', err); + alert(err instanceof PresentationLinkError ? err.message : t('generateError')); + } finally { setIsGenerating(false); - }, 800); + } }; const resetForm = () => { @@ -386,7 +403,7 @@ export default function SharePage() {