From d3c390f5751ba5c1af38495f2aa41580551f78bc Mon Sep 17 00:00:00 2001 From: Spbd1 <148923621+Spbd1@users.noreply.github.com> Date: Fri, 8 May 2026 03:36:50 +0000 Subject: [PATCH] Add protected admin submission exports --- README.md | 27 +++ app/api/admin/submissions.csv/route.ts | 29 +++ app/api/admin/submissions/route.ts | 24 ++ lib/adminAuth.server.ts | 52 +++++ lib/adminSubmissions.ts | 290 +++++++++++++++++++++++++ package.json | 3 +- scripts/export-submissions.ts | 75 +++++++ 7 files changed, 499 insertions(+), 1 deletion(-) create mode 100644 app/api/admin/submissions.csv/route.ts create mode 100644 app/api/admin/submissions/route.ts create mode 100644 lib/adminAuth.server.ts create mode 100644 lib/adminSubmissions.ts create mode 100644 scripts/export-submissions.ts diff --git a/README.md b/README.md index 8534c61..8b1edcf 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/app/api/admin/submissions.csv/route.ts b/app/api/admin/submissions.csv/route.ts new file mode 100644 index 0000000..1e9fd7a --- /dev/null +++ b/app/api/admin/submissions.csv/route.ts @@ -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 }); + } +} diff --git a/app/api/admin/submissions/route.ts b/app/api/admin/submissions/route.ts new file mode 100644 index 0000000..6b3070d --- /dev/null +++ b/app/api/admin/submissions/route.ts @@ -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 }); + } +} diff --git a/lib/adminAuth.server.ts b/lib/adminAuth.server.ts new file mode 100644 index 0000000..34094f4 --- /dev/null +++ b/lib/adminAuth.server.ts @@ -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 }); +} diff --git a/lib/adminSubmissions.ts b/lib/adminSubmissions.ts new file mode 100644 index 0000000..6fa8ded --- /dev/null +++ b/lib/adminSubmissions.ts @@ -0,0 +1,290 @@ +import type { Prisma } from "@prisma/client"; +import { getPrismaClient } from "@/lib/prisma"; +import { isDatabaseConfigured } from "@/lib/serverConfig"; + +export type AdminSubmissionItem = { + id: string; + sessionId: string; + submittedAt: string; + schemaVersion: string; + exportVersion: string; + consentVersion: string | null; + assignedDisplayedProfile: string | null; + assignedHiddenProfile: string | null; + completedGameRounds: number; + payload: Prisma.JsonValue; +}; + +export type AdminSubmissionPage = { + items: AdminSubmissionItem[]; + nextCursor?: string; +}; + +type CursorPayload = { + submittedAt: string; + id: string; +}; + +const DEFAULT_LIMIT = 100; +const MAX_LIMIT = 500; + +export class AdminSubmissionError extends Error { + constructor( + message: string, + readonly status: number, + ) { + super(message); + this.name = "AdminSubmissionError"; + } +} + +export async function listAdminSubmissions(searchParams: URLSearchParams): Promise { + assertDatabaseConfigured(); + + const limit = parseLimit(searchParams.get("limit")); + const cursor = parseCursor(searchParams.get("cursor")); + const prisma = getPrismaClient(); + const where: Prisma.ResearchSubmissionWhereInput | undefined = cursor + ? { + OR: [ + { submittedAt: { lt: cursor.submittedAt } }, + { submittedAt: { equals: cursor.submittedAt }, id: { lt: cursor.id } }, + ], + } + : undefined; + + const rows = await prisma.researchSubmission.findMany({ + where, + orderBy: [{ submittedAt: "desc" }, { id: "desc" }], + take: limit + 1, + select: submissionSelect, + }); + + const pageRows = rows.slice(0, limit); + const nextCursor = rows.length > limit ? encodeCursor(pageRows[pageRows.length - 1]) : undefined; + + return { + items: normalizeRows(pageRows), + nextCursor, + }; +} + +export async function listAllAdminSubmissions(): Promise { + assertDatabaseConfigured(); + + const prisma = getPrismaClient(); + const rows = await prisma.researchSubmission.findMany({ + orderBy: [{ submittedAt: "desc" }, { id: "desc" }], + select: submissionSelect, + }); + + return normalizeRows(rows); +} + +function assertDatabaseConfigured() { + if (!isDatabaseConfigured()) { + throw new AdminSubmissionError("Database is not configured.", 500); + } +} + +export function adminSubmissionPageJson(page: AdminSubmissionPage) { + return { + ok: true, + items: page.items, + nextCursor: page.nextCursor, + }; +} + +export function submissionsToCsv(items: AdminSubmissionItem[]): string { + const lines = [CSV_COLUMNS.map((column) => csvEscape(column.header)).join(",")]; + + for (const item of items) { + lines.push(CSV_COLUMNS.map((column) => csvEscape(column.read(item))).join(",")); + } + + return `${lines.join("\n")}\n`; +} + +function parseLimit(rawLimit: string | null): number { + if (!rawLimit) { + return DEFAULT_LIMIT; + } + + const parsed = Number.parseInt(rawLimit, 10); + if (!Number.isFinite(parsed) || parsed < 1) { + throw new AdminSubmissionError("limit must be a positive integer.", 400); + } + + return Math.min(parsed, MAX_LIMIT); +} + +function parseCursor(rawCursor: string | null): { submittedAt: Date; id: string } | undefined { + if (!rawCursor) { + return undefined; + } + + try { + const decoded = JSON.parse(Buffer.from(rawCursor, "base64url").toString("utf8")) as Partial; + if (!decoded.submittedAt || !decoded.id) { + throw new Error("Incomplete cursor."); + } + + const submittedAt = new Date(decoded.submittedAt); + if (Number.isNaN(submittedAt.getTime())) { + throw new Error("Invalid cursor date."); + } + + return { submittedAt, id: decoded.id }; + } catch { + throw new AdminSubmissionError("cursor is invalid.", 400); + } +} + +function encodeCursor(row: { submittedAt: string | Date; id: string } | undefined): string | undefined { + if (!row) { + return undefined; + } + + const submittedAt = row.submittedAt instanceof Date ? row.submittedAt.toISOString() : row.submittedAt; + return Buffer.from(JSON.stringify({ submittedAt, id: row.id }), "utf8").toString("base64url"); +} + +const submissionSelect = { + id: true, + sessionId: true, + submittedAt: true, + schemaVersion: true, + exportVersion: true, + consentVersion: true, + assignedDisplayedProfile: true, + assignedHiddenProfile: true, + completedGameRounds: true, + payload: true, +} satisfies Prisma.ResearchSubmissionSelect; + +type AdminSubmissionRow = { + id: string; + sessionId: string; + submittedAt: Date; + schemaVersion: string; + exportVersion: string; + consentVersion: string | null; + assignedDisplayedProfile: string | null; + assignedHiddenProfile: string | null; + completedGameRounds: number; + payload: Prisma.JsonValue; +}; + +function normalizeRows(rows: AdminSubmissionRow[]): AdminSubmissionItem[] { + return rows.map((row) => ({ + ...row, + submittedAt: row.submittedAt.toISOString(), + })); +} + +type CsvColumn = { + header: string; + read: (item: AdminSubmissionItem) => unknown; +}; + +const CSV_COLUMNS: CsvColumn[] = [ + dbColumn("submission_id", "id"), + dbColumn("session_id", "sessionId"), + dbColumn("submitted_at", "submittedAt"), + dbColumn("schema_version", "schemaVersion"), + dbColumn("export_version", "exportVersion"), + dbColumn("consent_version", "consentVersion"), + dbColumn("assigned_displayed_profile", "assignedDisplayedProfile"), + dbColumn("assigned_hidden_profile", "assignedHiddenProfile"), + dbColumn("completed_game_rounds", "completedGameRounds"), + payloadColumn("final_financial_score", ["gameSummary", "finalFinancialScore"]), + payloadColumn("final_health_score", ["gameSummary", "finalHealthScore"]), + payloadColumn("total_treatment_cost_paid", ["gameSummary", "totalTreatmentCostPaid"]), + payloadColumn("total_income", ["gameSummary", "totalIncome"]), + payloadColumn("full_treatment_choices", ["gameSummary", "fullTreatmentChoices"]), + payloadColumn("partial_treatment_choices", ["gameSummary", "partialTreatmentChoices"]), + payloadColumn("skipped_treatment_choices", ["gameSummary", "skippedTreatmentChoices"]), + payloadColumn("burden", ["computedMetrics", "burden"]), + payloadColumn("care_avoidance", ["computedMetrics", "careAvoidance"]), + payloadColumn("responsibility_shift", ["computedMetrics", "responsibilityShift"]), + payloadColumn("constraint_recognition_shift", ["computedMetrics", "constraintRecognitionShift"]), + payloadColumn("protest_legitimacy_shift", ["computedMetrics", "protestLegitimacyShift"]), + payloadColumn("rule_correction_support_shift", ["computedMetrics", "ruleCorrectionSupportShift"]), + payloadColumn("redistribution_support_shift", ["computedMetrics", "redistributionSupportShift"]), + payloadColumn("certainty_correction", ["computedMetrics", "certaintyCorrection"]), + payloadColumn("information_caution", ["computedMetrics", "informationCaution"]), + payloadColumn("perspective_change", ["computedMetrics", "perspectiveChange"]), + payloadColumn("pre_primary_attribution", ["preRevealSurvey", "primaryAttribution"]), + payloadColumn("post_revised_primary_attribution", ["postRevealSurvey", "revisedPrimaryAttribution"]), + payloadColumn("pre_individual_responsibility", ["preRevealSurvey", "individualResponsibility"]), + payloadColumn("post_revised_individual_responsibility", ["postRevealSurvey", "revisedIndividualResponsibility"]), + payloadColumn("pre_constraint_suspicion", ["preRevealSurvey", "constraintSuspicion"]), + payloadColumn("post_perceived_structural_impact", ["postRevealSurvey", "perceivedStructuralImpact"]), + payloadColumn("pre_protest_legitimacy", ["preRevealSurvey", "protestLegitimacy"]), + payloadColumn("post_protest_legitimacy", ["postRevealSurvey", "postProtestLegitimacy"]), + payloadColumn("pre_rule_correction_support", ["preRevealSurvey", "ruleCorrectionSupport"]), + payloadColumn("post_rule_correction_support", ["postRevealSurvey", "postRuleCorrectionSupport"]), + payloadColumn("pre_redistribution_support", ["preRevealSurvey", "redistributionSupport"]), + payloadColumn("post_redistribution_support", ["postRevealSurvey", "postRedistributionSupport"]), + payloadColumn("pre_confidence", ["preRevealSurvey", "confidence"]), + payloadColumn("post_initial_judgment_accuracy", ["postRevealSurvey", "initialJudgmentAccuracy"]), + payloadColumn("pre_information_sufficiency", ["preRevealSurvey", "informationSufficiency"]), + payloadColumn("post_perspective_change", ["postRevealSurvey", "perspectiveChange"]), + payloadColumn("pre_open_explanation", ["preRevealSurvey", "openExplanation"]), + payloadColumn("post_open_revision", ["postRevealSurvey", "openRevision"]), + payloadColumn("background_age_group", ["participantProfile", "ageGroup"]), + payloadColumn("background_gender", ["participantProfile", "gender"]), + payloadColumn("background_subjective_economic_status", ["participantProfile", "subjectiveEconomicStatus"]), + payloadColumn("background_medical_cost_pressure", ["participantProfile", "medicalCostPressure"]), + payloadColumn("background_healthcare_coverage", ["participantProfile", "healthcareCoverage"]), + payloadColumn("background_special_organizational_coverage", ["participantProfile", "specialOrganizationalCoverage"]), + payloadColumn("background_prior_exposure_to_unequal_systems", ["participantProfile", "priorExposureToUnequalSystems"]), + payloadColumn("background_policy_preference_baseline", ["participantProfile", "policyPreferenceBaseline"]), + payloadColumn("background_inequality_orientation", ["participantProfile", "inequalityOrientation"]), + payloadColumn("background_institutional_trust", ["participantProfile", "institutionalTrust"]), +]; + +function dbColumn(header: string, key: keyof AdminSubmissionItem): CsvColumn { + return { + header, + read: (item) => item[key], + }; +} + +function payloadColumn(header: string, path: string[]): CsvColumn { + return { + header, + read: (item) => getPath(item.payload, path), + }; +} + +function getPath(value: Prisma.JsonValue, path: string[]): unknown { + let current: unknown = value; + + for (const segment of path) { + if (!isJsonObject(current) || !(segment in current)) { + return ""; + } + + current = current[segment]; + } + + return current ?? ""; +} + +function isJsonObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function csvEscape(value: unknown): string { + if (value === null || value === undefined) { + return ""; + } + + const stringValue = typeof value === "object" ? JSON.stringify(value) : String(value); + if (!/[",\n\r]/.test(stringValue)) { + return stringValue; + } + + return `"${stringValue.replaceAll('"', '""')}"`; +} diff --git a/package.json b/package.json index e1bd8d1..1f0793b 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "db:dev": "prisma migrate dev", "db:migrate": "prisma migrate deploy", "db:studio": "prisma studio", - "validate:sample": "tsx scripts/validate-sample-export.ts" + "validate:sample": "tsx scripts/validate-sample-export.ts", + "export:submissions": "tsx scripts/export-submissions.ts" }, "dependencies": { "next": "^14.2.23", diff --git a/scripts/export-submissions.ts b/scripts/export-submissions.ts new file mode 100644 index 0000000..69f57f6 --- /dev/null +++ b/scripts/export-submissions.ts @@ -0,0 +1,75 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; + +type AdminSubmissionsResponse = { + ok: boolean; + items?: unknown[]; + nextCursor?: string; + error?: string; +}; + +async function main() { + const appBaseUrl = process.env.APP_BASE_URL; + const adminExportToken = process.env.ADMIN_EXPORT_TOKEN; + + if (!appBaseUrl) { + throw new Error("APP_BASE_URL is required."); + } + + if (!adminExportToken) { + throw new Error("ADMIN_EXPORT_TOKEN is required."); + } + + const limit = readLimit(); + const url = new URL("/api/admin/submissions", appBaseUrl); + if (limit) { + url.searchParams.set("limit", String(limit)); + } + + const response = await fetch(url, { + headers: { + authorization: `Bearer ${adminExportToken}`, + }, + }); + + const body = (await response.json().catch(() => null)) as AdminSubmissionsResponse | null; + if (!response.ok || !body?.ok) { + throw new Error(body?.error ?? `Export request failed with HTTP ${response.status}.`); + } + + const exportsDir = path.join(process.cwd(), "exports"); + await mkdir(exportsDir, { recursive: true }); + + const filePath = path.join(exportsDir, `submissions-${timestampForFileName(new Date())}.json`); + await writeFile(filePath, `${JSON.stringify(body, null, 2)}\n`, "utf8"); + console.log(`Saved ${body.items?.length ?? 0} submissions to ${filePath}`); +} + +function readLimit(): number | undefined { + const limitFlagIndex = process.argv.indexOf("--limit"); + const limitEqualsArg = process.argv.find((arg) => arg.startsWith("--limit=")); + const rawLimit = + limitEqualsArg?.slice("--limit=".length) ?? + (limitFlagIndex >= 0 ? process.argv[limitFlagIndex + 1] : undefined) ?? + process.env.EXPORT_SUBMISSIONS_LIMIT; + + if (!rawLimit) { + return undefined; + } + + const parsed = Number.parseInt(rawLimit, 10); + if (!Number.isFinite(parsed) || parsed < 1) { + throw new Error("Limit must be a positive integer."); + } + + return parsed; +} + +function timestampForFileName(date: Date): string { + return date.toISOString().replaceAll(":", "-").replaceAll(".", "-"); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; +});