Skip to content
Merged
Show file tree
Hide file tree
Changes from 57 commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
6e76da2
Save design notes
chrisbendel Jun 3, 2026
b39ec87
Allow for researcher to get keys now too
chrisbendel Jun 3, 2026
a5f6a60
Merge branch 'main' into researcher-encrypted-results
chrisbendel Jun 3, 2026
863078e
Fix tests + ci lint
chrisbendel Jun 3, 2026
ff8fd26
Fix test
chrisbendel Jun 3, 2026
014af9f
Add keys for test researchers
chrisbendel Jun 4, 2026
1b112c8
Update and compress notes
chrisbendel Jun 4, 2026
f4ded75
Test CI
chrisbendel Jun 4, 2026
89296d7
Merge branch 'main' into researcher-encrypted-results
chrisbendel Jun 4, 2026
1df75c0
Lint
chrisbendel Jun 4, 2026
a15d60a
Local dev working again
chrisbendel Jun 8, 2026
918bfb6
Lint
chrisbendel Jun 8, 2026
38b7fa2
Temp testing results file
chrisbendel Jun 8, 2026
1088d74
Merge branch 'main' into researcher-encrypted-results
chrisbendel Jun 8, 2026
5f7c5be
Lint
chrisbendel Jun 8, 2026
c0a8969
Fix up UI, full e2e testing flow
chrisbendel Jun 8, 2026
d038ed0
Tests
chrisbendel Jun 8, 2026
396652f
Tests
chrisbendel Jun 8, 2026
f1a11a6
Merge branch 'main' into researcher-encrypted-results
chrisbendel Jun 9, 2026
65a0466
Self review, add more comments/followup notes
chrisbendel Jun 9, 2026
15c9aee
Merge branch 'main' into researcher-encrypted-results
chrisbendel Jun 9, 2026
1c8d07d
Merge branch 'main' into researcher-encrypted-results
chrisbendel Jun 10, 2026
783695f
Build?
chrisbendel Jun 10, 2026
7b650b3
Fix test
chrisbendel Jun 10, 2026
076fe6d
Remove design file
chrisbendel Jun 10, 2026
d8feec2
Update comments/verbiage to be more consistent
chrisbendel Jun 11, 2026
f6ac10e
Merge branch 'main' into researcher-encrypted-results
chrisbendel Jun 11, 2026
b8c0092
Update verbiage to reflect results key instead of reviewer key
chrisbendel Jun 11, 2026
6f408af
Fix key
chrisbendel Jun 12, 2026
7dd8aff
Fix keys
chrisbendel Jun 12, 2026
3eddc0c
Merge branch 'main' into researcher-encrypted-results
chrisbendel Jun 15, 2026
2c46b59
Update to pnpm 11.7
chrisbendel Jun 15, 2026
8c3ffd0
Merge branch 'main' into researcher-encrypted-results
chrisbendel Jun 15, 2026
7f906bd
Update approach for all/nothing
chrisbendel Jun 16, 2026
7a1889a
Merge branch 'main' into researcher-encrypted-results
chrisbendel Jun 16, 2026
6cd77e2
Review comment
chrisbendel Jun 16, 2026
4ee4a74
Small fixes
chrisbendel Jun 16, 2026
48bc93d
Tests and cleanup
chrisbendel Jun 16, 2026
b5b5915
Allow researcher to see logs
chrisbendel Jun 16, 2026
9c4e090
Package fileg
chrisbendel Jun 16, 2026
99a6349
Vulnerability
chrisbendel Jun 16, 2026
8430816
Update tests
chrisbendel Jun 16, 2026
475d768
Merge branch 'main' into researcher-encrypted-results
chrisbendel Jun 16, 2026
cd1387d
Prune comments, commit to all or nothing log + results
chrisbendel Jun 17, 2026
96eb694
More clear naming for researcher keys
chrisbendel Jun 17, 2026
f93ec3d
Revalidate
chrisbendel Jun 17, 2026
025834b
Use consistent UI
chrisbendel Jun 17, 2026
dfc56c4
Fix test
chrisbendel Jun 17, 2026
336b1c0
Rename table to be more clear, address PR comments
chrisbendel Jun 18, 2026
530882b
Add followup comment for future work
chrisbendel Jun 18, 2026
64ce4be
Merge branch 'main' into researcher-encrypted-results
chrisbendel Jun 18, 2026
e321501
Fix CI
chrisbendel Jun 18, 2026
76a8d9d
Fix?
chrisbendel Jun 18, 2026
f26659a
Merge branch 'main' into researcher-encrypted-results
chrisbendel Jun 22, 2026
ac41221
Fix local docker dev
chrisbendel Jun 22, 2026
ee1ac99
Merge branch 'main' into researcher-encrypted-results
chrisbendel Jun 23, 2026
6f10717
Merge branch 'main' into researcher-encrypted-results
chrisbendel Jun 25, 2026
b1db329
Test
chrisbendel Jun 25, 2026
eb3bff9
Merge branch 'main' into researcher-encrypted-results
chrisbendel Jun 25, 2026
c7d3587
Fix tests
chrisbendel Jun 25, 2026
e75cb76
Pin to new encryption version
chrisbendel Jun 25, 2026
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 .npmrc
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
package-lock=false
confirm-modules-purge=false
4 changes: 4 additions & 0 deletions Dockerfile.dev
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ RUN pnpm install --frozen-lockfile
# Uncomment the following line to disable telemetry at run time
ENV NEXT_TELEMETRY_DISABLED=1

# Pre-create .next so the anonymous volume masking it (see docker-compose.yml)
# initializes owned by node, not root — otherwise next dev hits EACCES on mkdir.
RUN mkdir -p /app/.next

# do not run as root user
RUN chown -R node:node /app
USER node
Expand Down
139 changes: 139 additions & 0 deletions bin/inject-encrypted-results.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/* eslint-disable no-console */
/**
* DEV-ONLY: inject encrypted results into a job, mimicking what the Trusted Output App
* POSTs to /api/job/[jobId]/results — without the org-API auth dance. Lets you exercise
* the reviewer-decrypt → approve(re-wrap) → researcher-decrypt flow locally.
*
* Prereq: the job's reviewer(s) (enclave org users) must already have a key — generate it
* in the UI at /account/keys first, so getOrgPublicKeys returns a recipient to encrypt for.
*
* Run inside the container:
* docker exec mgmnt-app pnpm exec tsx bin/inject-encrypted-results.ts <jobId>
*/
import { db } from '@/database'
import { storeStudyEncryptedLogFile, storeStudyEncryptedResultsFile } from '@/server/storage'
import { ResultsWriter } from 'si-encryption/job-results/writer'

// Inline (not @/server/db/queries) — that module pulls in the Action framework → Clerk,
// which tsx can't import standalone. Mirrors getOrgPublicKeys.
async function orgPublicKeys(orgId: string): Promise<{ publicKey: ArrayBuffer; fingerprint: string }[]> {
const rows = await db
.selectFrom('orgUser')
.innerJoin('userPublicKey', 'userPublicKey.userId', 'orgUser.userId')
.select(['userPublicKey.publicKey', 'userPublicKey.fingerprint'])
.where('orgUser.orgId', '=', orgId)
.execute()
const keys = rows.map(({ publicKey, fingerprint }) => {
const ab = new ArrayBuffer(publicKey.byteLength)
new Uint8Array(ab).set(publicKey)
return { publicKey: ab, fingerprint }
})
// Seed data has placeholder keys (literal text bytes, not SPKI DER) that throw when wrapped.
// Pre-validate with si-encryption's import params and skip the duds.
const valid: typeof keys = []
for (const k of keys) {
try {
await crypto.subtle.importKey('spki', k.publicKey, { name: 'RSA-OAEP', hash: 'SHA-256' }, false, [
'encrypt',
])
valid.push(k)
} catch {
console.warn(
` skipping invalid public key (fingerprint ${k.fingerprint}) — not SPKI DER, likely seed placeholder`,
)
}
}
return valid
}

const toArrayBuffer = (s: string): ArrayBuffer => {
const buf = Buffer.from(s, 'utf-8')
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength)
}

async function buildZip(
files: Array<{ name: string; content: string }>,
keys: { publicKey: ArrayBuffer; fingerprint: string }[],
): Promise<File> {
const writer = new ResultsWriter(keys)
for (const f of files) await writer.addFile(f.name, toArrayBuffer(f.content))
const zip = await writer.generate()
return new File([zip], 'encrypted.zip', { type: 'application/zip' })
}

async function main() {
const jobId = process.argv[2]
if (!jobId) throw new Error('usage: tsx bin/inject-encrypted-results.ts <jobId>')

const info = await db
.selectFrom('studyJob')
.innerJoin('study', 'study.id', 'studyJob.studyId')
.innerJoin('org', 'org.id', 'study.orgId')
.select(['studyJob.id as studyJobId', 'study.id as studyId', 'study.orgId as orgId', 'org.slug as orgSlug'])
.where('studyJob.id', '=', jobId)
.executeTakeFirstOrThrow(() => new Error(`job ${jobId} not found`))

// Encrypt for the enclave org's reviewer keys — exactly what TOA does. The reviewer
// decrypts with their private key, then re-wraps for researchers at approve.
const reviewerKeys = await orgPublicKeys(info.orgId)
if (!reviewerKeys.length) {
throw new Error(
`org ${info.orgSlug} has no public keys — log in as the reviewer and generate one at /account/keys first`,
)
}
console.info(`Encrypting for ${reviewerKeys.length} reviewer key(s) in org ${info.orgSlug}`)

// Idempotent: don't stack duplicate result rows if re-run against the same job.
const alreadyHasResults = await db
.selectFrom('studyJobFile')
.select('id')
.where('studyJobId', '=', jobId)
.where('fileType', '=', 'ENCRYPTED-RESULT')
.executeTakeFirst()

if (alreadyHasResults) {
console.info(`Job already has encrypted results — skipping store (delete study_job_file rows to re-inject).`)
} else {
// One results file + one logs file, matching the real enclave helper (one file per upload).
const resultsZip = await buildZip([{ name: 'results.csv', content: 'group,count\nA,42\nB,17\n' }], reviewerKeys)
const logZip = await buildZip([{ name: 'run.log', content: 'job started\njob finished ok\n' }], reviewerKeys)
await storeStudyEncryptedResultsFile(info, resultsZip)
await storeStudyEncryptedLogFile(info, logZip, 'ENCRYPTED-CODE-RUN-LOG')
}

// A results-stage job needs code approved first, else the proposal review buttons stay visible.
// Inject CODE-APPROVED before RUN-COMPLETE so the latest status stays RUN-COMPLETE.
const statuses = await db
.selectFrom('jobStatusChange')
.select(['status', 'createdAt'])
.where('studyJobId', '=', jobId)
.execute()
const has = (s: string) => statuses.some((r) => r.status === s)
const runComplete = statuses.find((r) => r.status === 'RUN-COMPLETE')

if (!has('CODE-APPROVED')) {
// 1s before an existing RUN-COMPLETE, or 2s in the past on a fresh job so it
// reliably precedes the RUN-COMPLETE inserted just below.
const ts = runComplete
? new Date(new Date(runComplete.createdAt).getTime() - 1000)
: new Date(Date.now() - 2000)
await db
.insertInto('jobStatusChange')
.values({ status: 'CODE-APPROVED', studyJobId: jobId, createdAt: ts })
.execute()
}
if (!runComplete) {
await db.insertInto('jobStatusChange').values({ status: 'RUN-COMPLETE', studyJobId: jobId }).execute()
}
// Code approved ⇒ proposal-level review is done; study is back to APPROVED.
await db.updateTable('study').set({ status: 'APPROVED' }).where('id', '=', info.studyId).execute()

console.info(`✓ injected encrypted results + logs for job ${jobId} (CODE-APPROVED → RUN-COMPLETE, study APPROVED)`)
console.info(` Now: review as the reviewer, decrypt with the reviewer PEM, Approve in the Study Status card.`)
await db.destroy()
}

main().catch((err) => {
console.error(err)
process.exit(1)
})
5 changes: 4 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,10 @@ services:
volumes:
- ./:/app/
- node_modules:/app/node_modules
restart: unless-stopped
# Mask the host's .next so the container owns its own Turbopack cache.
# Sharing it over the ./ bind mount poisons the cache across host/container.
- /app/.next
restart: 'no'
networks:
- mgmnt-app
ports:
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"author": "Nathan Stitt",
"main": "index.js",
"type": "module",
"packageManager": "pnpm@11.3.0",
"packageManager": "pnpm@11.7.0+sha512.19cc852c120c7125760f2443ee6be0ca5b40f9f50598de1a09a1f177503e010e57c23c77646e01e761de59bf874fb22a3398c33ab9691fc13eb946b6f0f4d620",
"engines": {
"node": ">=22",
"pnpm": ">=11"
Expand Down Expand Up @@ -113,7 +113,7 @@
"react-phone-number-input": "^3.4.16",
"remeda": "^2.33.6",
"server-only": "^0.0.1",
"si-encryption": "github:safeinsights/encryption#2e35d3bb56f02bfc0e6d5cd9d83c54637ed0ac20",
"si-encryption": "github:safeinsights/encryption#4f31a899ba1e0ade8ebbbbb59609a7529343c2d4",
"uuid": "^14.0.0",
"validator": "^13.15.26",
"yjs": "^13.6.30",
Expand Down
15 changes: 8 additions & 7 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ overrides:
'@types/react': 19.2.14
esbuild: ^0.28.1
form-data: ^4.0.6
# hono <4.12.25: GHSA-88fw-hqm2-52qc (CVE-2026-54290, HIGH) — CORS middleware reflects any
# Origin with credentials when origin defaults to wildcard. Dev-only transitive (@pandacss/dev
# → @pandacss/mcp → @modelcontextprotocol/sdk → @hono/node-server), but Trivy scans dev deps.
hono: ^4.12.25
minimatch: ^10.2.1
picomatch: ^4.0.4
postcss: ^8.5.10
Expand Down
1 change: 0 additions & 1 deletion src/app/[orgSlug]/dashboard/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import OrgDashboardPage from './page'
vi.mock('@/server/actions/org.actions', async () => {
return {
getOrgFromSlugAction: vi.fn(),
getReviewerPublicKeyAction: vi.fn(),
}
})

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it, Mock, vi } from 'vitest'
import { insertTestStudyJobData, mockSessionWithTestData, renderWithProviders } from '@/tests/unit.helpers'
import { db, insertTestStudyJobData, mockSessionWithTestData, renderWithProviders } from '@/tests/unit.helpers'
import { JobReviewButtons } from './job-review-buttons'
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import { latestJobForStudy } from '@/server/db/queries'
Expand All @@ -10,7 +10,7 @@ vi.spyOn(actions, 'approveStudyJobFilesAction')
vi.spyOn(actions, 'rejectStudyJobFilesAction')

vi.mock('@/server/storage', () => ({
storeApprovedJobFile: vi.fn(),
fetchFileContents: vi.fn(),
}))

describe('Study Results Approve/Reject buttons', async () => {
Expand Down Expand Up @@ -73,7 +73,58 @@ describe('Study Results Approve/Reject buttons', async () => {
})

it('can approve results', async () => {
await clickNTest('Approve', actions.approveStudyJobFilesAction as Mock, 'FILES-APPROVED')
// Approve re-wraps each file's AES key for the lab researchers. Use real keys (so
// wrapAesKey accepts the SPKI) and reuse the keyed session user as researcher so no
// fake-key user pollutes the recipient set. The selected file must be a real row.
const { org, user } = await mockSessionWithTestData({ orgType: 'enclave', useRealKeys: true })
const { latestJobWithStatus: job } = await insertTestStudyJobData({
org,
studyStatus: 'PENDING-REVIEW',
researcherId: user.id,
})

const row = await db
.insertInto('studyJobFile')
.values({
studyJobId: job.id,
name: 'test.csv',
path: `results/encrypted/test.csv`,
fileType: 'ENCRYPTED-RESULT',
})
.returning('id')
.executeTakeFirstOrThrow()

const decryptedResults = [
{
path: 'test.csv',
contents: new TextEncoder().encode('test123').buffer,
sourceId: row.id,
fileType: 'APPROVED-RESULT' as FileType,
rawAesKey: new ArrayBuffer(32),
},
]

await act(async () => {
renderWithProviders(<JobReviewButtons job={job} decryptedResults={decryptedResults} />)
})

await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Approve' }))
})

await waitFor(async () => {
expect(actions.approveStudyJobFilesAction).toHaveBeenCalled()
const latestJob = await latestJobForStudy(job.studyId)
expect(latestJob.statusChanges.find((sc) => sc.status === 'FILES-APPROVED')).not.toBeUndefined()
})

// The re-wrapped researcher key was persisted against the real file row.
const wrappedKeys = await db
.selectFrom('studyJobFileRecipientKey')
.select('fingerprint')
.where('studyJobFileId', '=', row.id)
.execute()
expect(wrappedKeys.length).toBeGreaterThan(0)
})

it('can reject results', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Routes } from '@/lib/routes'
import { JobFileInfo, MinimalJobInfo } from '@/lib/types'
import { approveStudyJobFilesAction, rejectStudyJobFilesAction } from '@/server/actions/study-job.actions'
import type { LatestJobForStudy } from '@/server/db/queries'
import { buildSharedFiles } from '@/lib/re-wrap-results'
import { Button, Group, Text, useMantineTheme } from '@mantine/core'
import { CheckCircleIcon, XCircleIcon } from '@phosphor-icons/react/dist/ssr'
import dayjs from 'dayjs'
Expand Down Expand Up @@ -42,7 +43,10 @@ export const JobReviewButtons = ({
}

if (status === 'FILES-APPROVED') {
await approveStudyJobFilesAction({ orgSlug, jobInfo, jobFiles: decryptedResults })
// Re-wrap each approved file's AES key for the lab researchers, client-side.
// Only the wrapped keys are sent — never the raw key or plaintext.
const sharedFiles = await buildSharedFiles(job.studyId, decryptedResults)
await approveStudyJobFilesAction({ orgSlug, jobInfo, sharedFiles })
}

if (status === 'FILES-REJECTED') {
Expand Down
Loading
Loading