Skip to content
Open
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
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,33 @@ The metrics are simple derived values for prototype analysis. They should not be
- **Care avoidance index**: skipped treatments plus half of partial treatments.
- **Attribution Category Shift**: pre-reveal primary attribution compared with post-reveal revised primary attribution.


## Researcher admin export

Protected admin export endpoints let a researcher retrieve server-submitted study data when `ENABLE_SERVER_SUBMISSION=true`, `DATABASE_URL`, and `ADMIN_EXPORT_TOKEN` are configured on the server. Keep `ADMIN_EXPORT_TOKEN` secret; requests must send it as a bearer token. These endpoints are intended for researcher use only and are not linked from the participant UI.

JSON export example:

```bash
curl -H "Authorization: Bearer YOUR_ADMIN_EXPORT_TOKEN" \
"https://your-domain.com/api/admin/submissions?limit=100" \
-o submissions.json
```

CSV export example:

```bash
curl -H "Authorization: Bearer YOUR_ADMIN_EXPORT_TOKEN" \
"https://your-domain.com/api/admin/submissions.csv" \
-o submissions.csv
```

You can also save the JSON response from a configured deployment with:

```bash
APP_BASE_URL="https://your-domain.com" ADMIN_EXPORT_TOKEN="YOUR_ADMIN_EXPORT_TOKEN" npm run export:submissions
```

## Ethical and research limitations

This project is a prototype and should be reviewed before use with real participants.
Expand Down
29 changes: 29 additions & 0 deletions app/api/admin/submissions.csv/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { validateAdminRequest } from "@/lib/adminAuth.server";
import { AdminSubmissionError, listAllAdminSubmissions, submissionsToCsv } from "@/lib/adminSubmissions";

export const runtime = "nodejs";

export async function GET(request: NextRequest) {
const auth = validateAdminRequest(request);
if (!auth.ok) {
return auth.response;
}

try {
const items = await listAllAdminSubmissions();
return new NextResponse(submissionsToCsv(items), {
headers: {
"content-disposition": "attachment; filename=\"submissions.csv\"",
"content-type": "text/csv; charset=utf-8",
},
});
} catch (error) {
if (error instanceof AdminSubmissionError) {
return NextResponse.json({ ok: false, error: error.message }, { status: error.status });
}

return NextResponse.json({ ok: false, error: "Unable to retrieve submissions." }, { status: 500 });
}
}
24 changes: 24 additions & 0 deletions app/api/admin/submissions/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { validateAdminRequest } from "@/lib/adminAuth.server";
import { AdminSubmissionError, adminSubmissionPageJson, listAdminSubmissions } from "@/lib/adminSubmissions";

export const runtime = "nodejs";

export async function GET(request: NextRequest) {
const auth = validateAdminRequest(request);
if (!auth.ok) {
return auth.response;
}

try {
const page = await listAdminSubmissions(request.nextUrl.searchParams);
return NextResponse.json(adminSubmissionPageJson(page));
} catch (error) {
if (error instanceof AdminSubmissionError) {
return NextResponse.json({ ok: false, error: error.message }, { status: error.status });
}

return NextResponse.json({ ok: false, error: "Unable to retrieve submissions." }, { status: 500 });
}
}
52 changes: 52 additions & 0 deletions lib/adminAuth.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { createHash, timingSafeEqual } from "node:crypto";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";

type AdminAuthResult =
| { ok: true }
| {
ok: false;
response: NextResponse;
};

const BEARER_PREFIX = "Bearer ";

export function validateAdminRequest(request: NextRequest): AdminAuthResult {
const configuredToken = process.env.ADMIN_EXPORT_TOKEN;

if (!configuredToken) {
return {
ok: false,
response: adminJsonError("Admin export is not configured.", 500),
};
}

const authorization = request.headers.get("authorization");
if (!authorization?.startsWith(BEARER_PREFIX)) {
return {
ok: false,
response: adminJsonError("Unauthorized.", 401),
};
}

const suppliedToken = authorization.slice(BEARER_PREFIX.length);
if (!suppliedToken || !constantTimeTokenEquals(suppliedToken, configuredToken)) {
return {
ok: false,
response: adminJsonError("Unauthorized.", 401),
};
}

return { ok: true };
}

function constantTimeTokenEquals(a: string, b: string): boolean {
const aHash = createHash("sha256").update(a).digest();
const bHash = createHash("sha256").update(b).digest();

return timingSafeEqual(aHash, bHash);
}

function adminJsonError(error: string, status: number): NextResponse {
return NextResponse.json({ ok: false, error }, { status });
}
Loading