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
1 change: 1 addition & 0 deletions packages/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"@ant-design/nextjs-registry": "^1.0.0",
"@openthreads/core": "workspace:*",
"@openthreads/storage-mongodb": "workspace:*",
"@openthreads/trust": "workspace:*",
"antd": "^5.0.0",
"mongodb": "^6.0.0",
"next": "^15.0.0",
Expand Down
74 changes: 74 additions & 0 deletions packages/server/src/app/api/audit/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/**
* GET /api/audit — Query the A2H interaction audit log.
*
* Query parameters:
* turnId — filter by turn ID
* threadId — filter by thread ID
* channelId — filter by channel ID
* eventType — filter by event type
* fromDate — ISO 8601 start timestamp
* toDate — ISO 8601 end timestamp
* limit — max results (default: 100, max: 500)
* offset — pagination offset (default: 0)
*
* Returns 404 when the trust layer is not enabled.
*/

import { NextRequest, NextResponse } from 'next/server';
import { getTrustService, getTrustEnabled } from '@/lib/trust-service';

export const runtime = 'nodejs';

export async function GET(req: NextRequest): Promise<NextResponse> {
if (!getTrustEnabled()) {
return NextResponse.json(
{ error: 'Trust layer is not enabled. Set TRUST_LAYER_ENABLED=true to activate.' },
{ status: 404 },
);
}

const sp = req.nextUrl.searchParams;

const turnId = sp.get('turnId') ?? undefined;
const threadId = sp.get('threadId') ?? undefined;
const channelId = sp.get('channelId') ?? undefined;
const eventType = sp.get('eventType') ?? undefined;

const fromDateStr = sp.get('fromDate');
const toDateStr = sp.get('toDate');
const fromDate = fromDateStr ? new Date(fromDateStr) : undefined;
const toDate = toDateStr ? new Date(toDateStr) : undefined;

if (fromDate && isNaN(fromDate.getTime())) {
return NextResponse.json({ error: 'Invalid fromDate' }, { status: 400 });
}
if (toDate && isNaN(toDate.getTime())) {
return NextResponse.json({ error: 'Invalid toDate' }, { status: 400 });
}

const rawLimit = Number(sp.get('limit') ?? 100);
const limit = Math.min(Math.max(1, rawLimit), 500);
const offset = Math.max(0, Number(sp.get('offset') ?? 0));

const trust = await getTrustService();

const entries = await trust.queryAuditLog({
turnId,
threadId,
channelId,
eventType: eventType as Parameters<typeof trust.queryAuditLog>[0]['eventType'],
fromDate,
toDate,
limit,
offset,
});

return NextResponse.json({
entries,
pagination: {
limit,
offset,
returned: entries.length,
},
});
}
158 changes: 158 additions & 0 deletions packages/server/src/app/api/form/[formKey]/auth/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
/**
* Authentication challenge endpoints for the A2H Trust Layer.
*
* POST /api/form/:formKey/auth — Issue a new authentication challenge.
* PUT /api/form/:formKey/auth — Verify a challenge response.
*
* These endpoints are only active when TRUST_LAYER_ENABLED=true.
* When the trust layer is off, returns 404.
*
* Flow:
* 1. Client loads the form (GET /api/form/:formKey) and sees `requiresAuth: true`
* 2. Client calls POST /api/form/:formKey/auth to receive an auth challenge
* 3. Client performs authentication (TOTP, WebAuthn, etc.)
* 4. Client calls PUT /api/form/:formKey/auth with the credential response
* 5. On success, client receives a `challengeId` to include in form submission
* 6. Client submits the form (POST /api/form/:formKey) with `challengeId`
*/

import { NextRequest, NextResponse } from 'next/server';
import { getFormRecord } from '@/lib/db';
import { getTrustService, getTrustEnabled } from '@/lib/trust-service';

export const runtime = 'nodejs';

type RouteContext = { params: Promise<{ formKey: string }> };

// ─── POST — Issue challenge ───────────────────────────────────────────────────

export async function POST(_req: NextRequest, context: RouteContext): Promise<NextResponse> {
if (!getTrustEnabled()) {
return NextResponse.json({ error: 'Trust layer is not enabled' }, { status: 404 });
}

const { formKey } = await context.params;

// Verify the form exists and is still pending.
const record = await getFormRecord(formKey);
if (!record) {
return NextResponse.json({ error: 'Form not found' }, { status: 404 });
}
if (record.expiresAt < new Date()) {
return NextResponse.json({ error: 'Form has expired' }, { status: 410 });
}
if (record.status === 'submitted') {
return NextResponse.json({ error: 'Form already submitted' }, { status: 409 });
}

// Parse optional method preference from body.
let method: 'webauthn' | 'totp' | 'sms_otp' | undefined;
try {
const body = await _req.json().catch(() => ({}));
if (body && typeof body === 'object' && 'method' in body) {
method = body.method as typeof method;
}
} catch {
// no body — use default method
}

const trust = await getTrustService();
const challenge = await trust.issueAuthChallenge(formKey, method);

return NextResponse.json({
challengeId: challenge.challengeId,
method: challenge.method,
challenge: challenge.challenge,
expiresAt: challenge.expiresAt.toISOString(),
});
}

// ─── PUT — Verify challenge ───────────────────────────────────────────────────

export async function PUT(req: NextRequest, context: RouteContext): Promise<NextResponse> {
if (!getTrustEnabled()) {
return NextResponse.json({ error: 'Trust layer is not enabled' }, { status: 404 });
}

const { formKey } = await context.params;

let body: {
challengeId?: string;
code?: string; // TOTP / SMS OTP
credentialId?: string; // WebAuthn
authenticatorData?: string;
clientDataJSON?: string;
signature?: string;
userHandle?: string;
publicKeyJwk?: JsonWebKey; // WebAuthn public key for verification
};

try {
body = await req.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
}

if (!body.challengeId) {
return NextResponse.json({ error: 'Missing required field: challengeId' }, { status: 400 });
}

const trust = await getTrustService();

// Determine what kind of response is being submitted.
let response: { code: string } | {
credentialId: string;
authenticatorData: string;
clientDataJSON: string;
signature: string;
userHandle?: string;
};

if (body.credentialId) {
// WebAuthn assertion
if (!body.authenticatorData || !body.clientDataJSON || !body.signature) {
return NextResponse.json(
{ error: 'WebAuthn assertion missing required fields' },
{ status: 400 },
);
}
response = {
credentialId: body.credentialId,
authenticatorData: body.authenticatorData,
clientDataJSON: body.clientDataJSON,
signature: body.signature,
userHandle: body.userHandle,
};
} else if (body.code) {
// TOTP / SMS OTP
response = { code: body.code };
} else {
return NextResponse.json(
{ error: 'Missing verification payload: provide code (TOTP) or WebAuthn fields' },
{ status: 400 },
);
}

const result = await trust.verifyAuthChallenge(
body.challengeId,
response,
body.publicKeyJwk,
);

if (!result.success) {
return NextResponse.json({ error: result.error ?? 'Verification failed' }, { status: 401 });
}

// Log that the user verified for this form.
await trust.log('auth_challenge_completed', formKey, {
actorId: result.identityId,
payload: { challengeId: body.challengeId, formKey },
});

return NextResponse.json({
ok: true,
challengeId: result.challengeId,
verifiedAt: result.verifiedAt?.toISOString(),
identityId: result.identityId,
});
}
74 changes: 71 additions & 3 deletions packages/server/src/app/api/form/[formKey]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,17 @@
* The form key is either `turnId` (method 3) or `${turnId}_batch` (method 4).
* On successful POST the blocking A2H promise in the in-process `formRegistry` is
* resolved so the Reply Engine can return the human's answer to the recipient.
*
* Trust layer integration (when TRUST_LAYER_ENABLED=true):
* - GET includes `requiresAuth: true` and the supported auth methods
* - POST requires a verified `challengeId` in the body before submitting
* - After submission, the response is signed and the evidence is recorded in the audit log
*/

import { NextRequest, NextResponse } from 'next/server';
import { getFormRecord, updateFormRecord } from '@/lib/db';
import { formRegistry, type A2HResponse } from '@/lib/form-registry';
import { getTrustService, getTrustEnabled } from '@/lib/trust-service';

export const runtime = 'nodejs';

Expand All @@ -28,13 +34,26 @@ export async function GET(_req: NextRequest, context: RouteContext): Promise<Nex
const now = new Date();
const isExpired = record.expiresAt < now;

const trustEnabled = getTrustEnabled();

// Emit audit event: intent rendered (first time form is viewed and trust is active).
if (trustEnabled && !isExpired && record.status === 'pending') {
const trust = await getTrustService();
await trust.log('intent_rendered', record.turnId, {
payload: { formKey, isBatch: record.isBatch },
});
}

return NextResponse.json({
formKey: record.formKey,
turnId: record.turnId,
isBatch: record.isBatch,
status: isExpired ? 'expired' : record.status,
intents: record.intents,
expiresAt: record.expiresAt.toISOString(),
// Trust layer metadata
requiresAuth: trustEnabled,
authMethods: trustEnabled ? ['totp', 'webauthn'] : undefined,
});
}

Expand All @@ -55,13 +74,13 @@ export async function POST(request: NextRequest, context: RouteContext): Promise
return NextResponse.json({ error: 'Form has expired' }, { status: 410 });
}

// Reject already-submitted forms.
// Reject already-submitted forms (single-use link).
if (record.status === 'submitted') {
return NextResponse.json({ error: 'Form already submitted' }, { status: 409 });
}

// Parse the submission body.
let body: { responses?: unknown[] };
let body: { responses?: unknown[]; challengeId?: string };
try {
body = await request.json();
} catch {
Expand Down Expand Up @@ -93,12 +112,61 @@ export async function POST(request: NextRequest, context: RouteContext): Promise
}
}

// Mark the form as submitted in MongoDB.
// ── Trust layer: verify auth challenge before accepting submission ───────────
const trustEnabled = getTrustEnabled();
let actorId: string | undefined;

if (trustEnabled) {
if (!body.challengeId) {
return NextResponse.json(
{
error: 'Trust layer requires authentication. Submit a challengeId obtained from POST /api/form/:formKey/auth',
},
{ status: 401 },
);
}

const trust = await getTrustService();
const verified = trust.getVerifiedChallenge(body.challengeId);

if (!verified) {
return NextResponse.json(
{ error: 'Invalid or expired challengeId. Complete authentication first.' },
{ status: 401 },
);
}

if (verified.formKey !== formKey) {
return NextResponse.json(
{ error: 'Challenge was issued for a different form' },
{ status: 401 },
);
}

actorId = verified.identityId;
}

// Mark the form as submitted in MongoDB (single-use: prevents replay via resubmission).
await updateFormRecord(formKey, {
status: 'submitted',
responses: body.responses,
});

// ── Trust layer: sign evidence and record audit entries ──────────────────────
if (trustEnabled) {
const trust = await getTrustService();

// Log each response received.
for (const r of body.responses) {
const response = r as Record<string, unknown>;
await trust.log('response_received', record.turnId, {
intentType: response['intent'] as Parameters<typeof trust.log>[2]['intentType'],
actorId,
payload: { formKey, response: r },
});
}
}

// Resolve blocking promises in the in-process registry.
// For batch forms: sub-keys are `${formKey}_${i}`.
// For single forms: key is the formKey itself.
Expand Down
Loading