Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
1 change: 0 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,3 @@ VITE_API_BASE_URL=http://localhost:3000
# Example:
# VITE_ALLOWED_CREDENTIAL_OFFER_HOSTS=issuer.example.com,wallet.example.org,localhost:8080
# VITE_ALLOWED_CREDENTIAL_OFFER_HOSTS=

18 changes: 18 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import { PresentationProvider } from './state/presentation.state'
import { CredentialsCacheProvider } from './state/credentialsCache.state'
import { CredentialTypesPage } from './pages/CredentialTypesPage'
import { IssuanceSuccessPage } from './pages/IssuanceSuccessPage'
import { PresentationActivityDetailPage } from './pages/PresentationActivityDetailPage'
import { PresentationActivityPage } from './pages/PresentationActivityPage'
import { RegistrationPage } from './pages/RegistrationPage'
import { PresentationErrorPage } from './pages/presentation/PresentationErrorPage'
import { PresentationProofDetailsPage } from './pages/presentation/PresentationProofDetailsPage'
Expand Down Expand Up @@ -68,6 +70,22 @@ function App() {
</RequireRegistration>
}
/>
<Route
path={routes.presentationActivity}
element={
<RequireRegistration>
<PresentationActivityPage />
</RequireRegistration>
}
/>
<Route
path={routes.presentationActivityDetail}
element={
<RequireRegistration>
<PresentationActivityDetailPage />
</RequireRegistration>
}
/>
<Route
path={routes.presentationError}
element={
Expand Down
90 changes: 90 additions & 0 deletions src/api/presentationActivity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { apiDelete, apiGet } from './client'
import {
validatePresentationActivityListResponse,
validatePresentationActivityRecord,
} from './presentationActivityValidation'
import type {
PresentationActivityListResponse,
PresentationActivityQuery,
PresentationActivityRecord,
} from '../types/presentationActivity'
import { DEFAULT_PRESENTATION_ACTIVITY_PAGE_SIZE } from '../types/presentationActivity'
import {
filterWithinRetentionWindow,
isPresentationActivityId,
sortPresentationActivityNewestFirst,
} from '../utils/presentationActivity'

/**
* Build query string for GET /presentation/activity.
*
* Spec (issue #93): paginated list with optional date range and verifier name search.
* Backend applies auto-expiry (default 30 days); the UI also filters defensively.
*/
function buildActivityQueryString(query: PresentationActivityQuery): string {
const params = new URLSearchParams()
const page = query.page ?? 1
const pageSize = query.page_size ?? DEFAULT_PRESENTATION_ACTIVITY_PAGE_SIZE

params.set('page', String(page))
params.set('page_size', String(pageSize))

if (query.from) params.set('from', query.from)
if (query.to) params.set('to', query.to)
const verifierName = query.verifier_name?.trim()
if (verifierName) params.set('verifier_name', verifierName)

const qs = params.toString()
return qs ? `?${qs}` : ''
}

function normalizeListResponse(
response: PresentationActivityListResponse
): PresentationActivityListResponse {
const items = sortPresentationActivityNewestFirst(
filterWithinRetentionWindow(response.items)
)
return { ...response, items }
}

/**
* Fetch presentation activity records (newest first).
*
* Spec: GET /presentation/activity
*/
export async function getPresentationActivity(
query: PresentationActivityQuery = {}
): Promise<PresentationActivityListResponse> {
const qs = buildActivityQueryString(query)
const raw = await apiGet<unknown>(`/presentation/activity${qs}`)
return normalizeListResponse(validatePresentationActivityListResponse(raw))
}

/**
* Fetch a single presentation activity record by id.
*
* Spec: GET /presentation/activity/{id}
*/
export async function getPresentationActivityById(
id: string
): Promise<PresentationActivityRecord> {
if (!isPresentationActivityId(id)) {
throw new Error('Invalid presentation activity id')
}

const raw = await apiGet<unknown>(`/presentation/activity/${encodeURIComponent(id)}`)
return validatePresentationActivityRecord(raw)
}

/**
* Permanently delete a presentation activity record (privacy / forget).
*
* Spec: DELETE /presentation/activity/{id}
*/
export async function deletePresentationActivity(id: string): Promise<void> {
if (!isPresentationActivityId(id)) {
throw new Error('Invalid presentation activity id')
}

await apiDelete(`/presentation/activity/${encodeURIComponent(id)}`)
}
122 changes: 122 additions & 0 deletions src/api/presentationActivityValidation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { ContractError } from './validation'
import type {
PresentationActivityListResponse,
PresentationActivityRecord,
PresentationActivityVerifier,
} from '../types/presentationActivity'

const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i

function requireObject(
ctx: string,
field: string,
value: unknown
): Record<string, unknown> {
if (value === null || typeof value !== 'object' || Array.isArray(value))
throw new ContractError(ctx, field, value)
return value as Record<string, unknown>
}

function requireString(ctx: string, field: string, value: unknown): string {
if (typeof value !== 'string' || value.length === 0)
throw new ContractError(ctx, field, value)
return value
}

function requireStringOrNull(ctx: string, field: string, value: unknown): string | null {
if (value !== null && typeof value !== 'string')
throw new ContractError(ctx, field, value)
return value as string | null
}

function requireNumber(ctx: string, field: string, value: unknown): number {
if (typeof value !== 'number' || !Number.isFinite(value))
throw new ContractError(ctx, field, value)
return value
}

function requireArray(ctx: string, field: string, value: unknown): unknown[] {
if (!Array.isArray(value)) throw new ContractError(ctx, field, value)
return value
}

function requireDateTimeString(ctx: string, field: string, value: unknown): string {
const dateTime = requireString(ctx, field, value)
if (!Number.isFinite(Date.parse(dateTime))) {
throw new ContractError(ctx, field, value)
}
return dateTime
}

function validatePresentationActivityVerifier(
raw: unknown
): PresentationActivityVerifier {
const ctx = 'PresentationActivityVerifier'
const obj = requireObject(ctx, 'verifier', raw)
const client_id = requireString(ctx, 'client_id', obj.client_id)
const name =
obj.name !== undefined ? requireStringOrNull(ctx, 'name', obj.name) : undefined
const logo_uri =
obj.logo_uri !== undefined
? requireStringOrNull(ctx, 'logo_uri', obj.logo_uri)
: undefined
return { client_id, name, logo_uri }
}

export function validatePresentationActivityRecord(
raw: unknown
): PresentationActivityRecord {
const ctx = 'PresentationActivityRecord'
const obj = requireObject(ctx, 'record', raw)
const id = requireString(ctx, 'id', obj.id)
if (!UUID_RE.test(id)) throw new ContractError(ctx, 'id', id)

const presented_at = requireDateTimeString(ctx, 'presented_at', obj.presented_at)
const verifier = validatePresentationActivityVerifier(obj.verifier)

const rawTypes = requireArray(ctx, 'credential_types', obj.credential_types)
const credential_types = rawTypes.map((t, i) =>
requireString(`${ctx}.credential_types[${i}]`, 'credential_type', t)
)

const disclosed_claim_count = requireNumber(
ctx,
'disclosed_claim_count',
obj.disclosed_claim_count
)
if (disclosed_claim_count < 0 || !Number.isInteger(disclosed_claim_count)) {
throw new ContractError(ctx, 'disclosed_claim_count', disclosed_claim_count)
}

return {
id,
presented_at,
verifier,
credential_types,
disclosed_claim_count,
}
}

export function validatePresentationActivityListResponse(
raw: unknown
): PresentationActivityListResponse {
const ctx = 'PresentationActivityListResponse'
const obj = requireObject(ctx, 'response', raw)
const rawItems = requireArray(ctx, 'items', obj.items)
const items = rawItems.map((item, i) => {
try {
return validatePresentationActivityRecord(item)
} catch (err) {
throw new Error(
`[ContractError] items[${i}]: ${err instanceof Error ? err.message : String(err)}`
)
}
})

return {
items,
total: requireNumber(ctx, 'total', obj.total),
page: requireNumber(ctx, 'page', obj.page),
page_size: requireNumber(ctx, 'page_size', obj.page_size),
}
}
Loading