Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions apps/credit-history/src/app/api/presentations/[ref]/route.ts
Original file line number Diff line number Diff line change
@@ -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<NextResponse> {
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 }
);
}
103 changes: 103 additions & 0 deletions apps/credit-history/src/app/api/presentations/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;

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<NextResponse> {
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 });
}
}
Comment on lines +50 to +103

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== files =="
git ls-files | rg 'apps/credit-history/src/app/api/presentations/route\.ts|apps/credit-history/src|middleware|auth|session|next\.config' | head -200

echo "== target route excerpt =="
if [ -f apps/credit-history/src/app/api/presentations/route.ts ]; then
  nl -ba apps/credit-history/src/app/api/presentations/route.ts | sed -n '1,180p'
fi

echo "== auth/middleware references in credit-history =="
rg -n --no-heading "next-auth|auth|session|jwt|Middleware|credentials|bearer|Authorization|holder|credentialIds|createStoredPresentation" apps/credit-history -S || true

echo "== package scripts/deps relevant =="
if [ -f apps/credit-history/package.json ]; then cat apps/credit-history/package.json; fi

Repository: ACTA-Team/products-acta

Length of output: 2506


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== target route excerpt =="
awk '{ printf "%6d  %s\n", NR, $0 }' apps/credit-history/src/app/api/presentations/route.ts | sed -n '1,180p'

echo "== GET presentation route =="
awk '{ printf "%6d  %s\n", NR, $0 }' "apps/credit-history/src/app/api/presentations/[ref]/route.ts" | sed -n '1,220p'

echo "== auth/session/middleware references in credit-history =="
rg -n --no-heading "next-auth|auth|session|jwt|Middleware|credentials|bearer|Authorization|holder|credentialIds|createStoredPresentation|request\.headers|Authorization" apps/credit-history/src apps/credit-history -S || true

echo "== route middleware files =="
git ls-files apps/credit-history/src apps/credit-history | rg '(^|/)middleware(\.(ts|tsx|js|jsx)|/middleware|\.middleware)\.ts$|middleware'

echo "== package metadata relevant =="
if [ -f apps/credit-history/package.json ]; then awk '{ print NR ": " $0 }' apps/credit-history/package.json | sed -n '1,220p'; fi

echo "== read-only structural verifier for POST route =="
python3 - <<'PY'
from pathlib import Path
p = Path("apps/credit-history/src/app/api/presentations/route.ts")
s = p.read_text()
checks = {
    "imports_auth": any(x in s for x in ["next-auth", ".next/server/auth", "SessionProvider"]),
    "imports_middleware_auth": "middleware" in p.parent.name.lower() or "middleware" in p.name.lower(),
    "uses_request_headers": "request.headers" in s or "headers.get" in s,
    "calls_get_auth": any(s.find(x) != -1 for x in ["getAuth", "auth()"]),
    "validates_holder_source": any(x in s for x in ["userId", "sub", "credentials", "claims", "owner", "request.auth", "request.user", "session"]),
    "validates_credential_ownership": any(x in s for x in ["credentialOwners", "ownerId", "credentialIds", "credentials.filter"]),
}
for k,v in checks.items():
    print(f"{k}={v}")
PY

Repository: ACTA-Team/products-acta

Length of output: 50379


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== session provider =="
awk '{ printf "%6d  %s\n", NR, $0 }' apps/credit-history/src/session/session-provider.tsx | sed -n '1,180p'

echo "== presentation link/create flow =="
awk '{ printf "%6d  %s\n", NR, $0 }' apps/credit-history/src/lib/presentation-link.ts | sed -n '1,180p'

echo "== presentation store =="
awk '{ printf "%6d  %s\n", NR, $0 }' apps/credit-history/src/lib/presentation-store.ts | sed -n '1,180p'

echo "== share submit excerpt =="
awk '{ printf "%6d  %s\n", NR, $0 }' apps/credit-history/src/app/share/page.tsx | sed -n '100,160p'

echo "== middleware/auth presence exact =="
git ls-files apps/credit-history | rg '(^|/)middleware(\.ts|\.tsx|\.js|\.jsx)$|middleware\.ts$' || true
git ls-files apps/credit-history/src/lib apps/credit-history/src/app api | rg 'auth|session|header|bearer' || true

echo "== read-only verifier =="
python3 - <<'PY'
from pathlib import Path
files = {
    "route": Path("apps/credit-history/src/app/api/presentations/route.ts").read_text(),
    "link": Path("apps/credit-history/src/lib/presentation-link.ts").read_text(),
    "store": Path("apps/credit-history/src/lib/presentation-store.ts").read_text(),
    "session": Path("apps/credit-history/src/session/session-provider.tsx").read_text(),
}
print("POST route reads request.headers:", "request.headers" in files["route"])
print("POST route calls auth/session:", any(term in files["route"] for term in ["getAuth", "auth()", "session"]))
print("POST route passes stored presentation.ref:", "'ref' in files['route']" is None and "ref" in files["route"])
print("presentation link creates unverified proof:", "signPresentationProof" in files["link"])
print("store constructor accepts unverified holder:", "holder" in files["store"] and "credentialIds" in files["store"])
PY

Repository: ACTA-Team/products-acta

Length of output: 21304


Bind presentation creation to an authorized caller.

POST /api/presentations currently trusts holder and credentialIds from the JSON body after only type checks. There is no route or middleware auth for this endpoint, and the present session flow stores the connected wallet address in client-side localStorage, so an unauthenticated caller can forge arbitrary holder/credentialIds and get back a live presentation ref. Gate creation behind server-side auth/session enforcement before this ships more broadly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/credit-history/src/app/api/presentations/route.ts` around lines 50 -
103, Update the POST handler to require server-side authentication or session
validation before calling createStoredPresentation, and reject unauthenticated
requests with the established authorization error response. Bind the authorized
caller’s identity to holder and validate that each credentialId is accessible to
that caller instead of trusting these body fields; preserve the existing
validation and presentation-creation flow for authorized requests.

63 changes: 40 additions & 23 deletions apps/credit-history/src/app/share/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<CreditCredential[]>([]);
const [profile, setProfile] = React.useState<CreditProfileSummary | null>(null);
Expand Down Expand Up @@ -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 = () => {
Expand Down Expand Up @@ -386,7 +403,7 @@ export default function SharePage() {
</CardContent>
<CardFooter className="border-t border-border/50 pt-6">
<Button
onClick={handleGenerateLink}
onClick={() => void handleGenerateLink()}
disabled={selectedIds.length === 0 || isGenerating}
className="h-auto min-h-10 w-full whitespace-normal py-2.5 text-center text-sm font-semibold cursor-pointer"
>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, screen } from '@testing-library/react';
import type { CreditCredential, CreditProfileSummary } from '@acta-products/acta/types';
import { buildPresentation } from '@acta-products/acta/presentation';
import { renderWithIntl } from '@/test/render-with-intl';
import { encodePresentationToken } from '@/lib/presentation-token';

const { listCredentials, getProfileSummary } = vi.hoisted(() => ({
const { listCredentials, getProfileSummary, resolvePresentationRef } = vi.hoisted(() => ({
listCredentials: vi.fn(),
getProfileSummary: vi.fn(),
resolvePresentationRef: vi.fn(),
}));

vi.mock('@acta-products/ui', () => import('@/test/ui-mock'));
Expand All @@ -15,6 +16,8 @@ vi.mock('@acta-products/acta', () => ({
getCredentialSource: () => ({ listCredentials, getProfileSummary }),
}));

vi.mock('@/lib/presentation-link', () => ({ resolvePresentationRef }));

import { PublicVerificationView } from './public-verification-view';

const CREDENTIAL: CreditCredential = {
Expand All @@ -36,34 +39,58 @@ const PROFILE: CreditProfileSummary = {
activeCredentialsCount: 1,
};

const REF = 'a'.repeat(43);

beforeEach(() => {
listCredentials.mockReset().mockResolvedValue([CREDENTIAL]);
getProfileSummary.mockReset().mockResolvedValue(PROFILE);
resolvePresentationRef.mockReset();
});

afterEach(() => {
cleanup();
});

describe('PublicVerificationView', () => {
it('renders the invalid state for a malformed token', async () => {
renderWithIntl(<PublicVerificationView token="not-a-valid-token!!!" />);
it('renders the invalid state when the reference does not resolve', async () => {
resolvePresentationRef.mockResolvedValue({ status: 'not_found' });

renderWithIntl(<PublicVerificationView token="not-a-valid-reference!!!" />);

expect(await screen.findByText('Invalid or expired presentation')).toBeInTheDocument();
expect(screen.getByText('Presentation verification failed')).toBeInTheDocument();
});

it('renders the expired state (with the expiry date) for an expired token', async () => {
const token = encodePresentationToken({ ids: ['cred-income'], exp: Date.now() - 60_000 });
renderWithIntl(<PublicVerificationView token={token} />);
it('renders the expired state (with the expiry date) when the resolver reports expiry', async () => {
resolvePresentationRef.mockResolvedValue({
status: 'expired',
expiresAt: Date.now() - 60_000,
});

renderWithIntl(<PublicVerificationView token={REF} />);

expect(await screen.findByText('Invalid or expired presentation')).toBeInTheDocument();
expect(screen.getByText(/expired on/i)).toBeInTheDocument();
});

it('renders the verified report for a valid token', async () => {
const token = encodePresentationToken({ ids: ['cred-income'], exp: Date.now() + 3_600_000 });
renderWithIntl(<PublicVerificationView token={token} />);
it('renders the verified report for a live reference', async () => {
resolvePresentationRef.mockResolvedValue({
status: 'ok',
presentation: buildPresentation({
holder: 'did:stellar:testnet:GHOLDER',
credentialIds: ['cred-income'],
expiresAt: Date.now() + 3_600_000,
}),
});

renderWithIntl(<PublicVerificationView token={REF} />);

expect(await screen.findByText('Verifiable presentation report')).toBeInTheDocument();
expect(screen.getByText('Anchor Payroll Income')).toBeInTheDocument();
expect(screen.getByText('Alex Mercer')).toBeInTheDocument();
});

it('never puts credential data in the shared reference', () => {
expect(REF).not.toContain('cred-income');
});
});
Loading
Loading