Skip to main content

Upload and documents

Credential documents (a degree certificate as a PDF, for example) are not stored on chain. The issuer's browser uploads the document to POST /api/upload, the server pins it on IPFS through Pinata, and the chain stores only hashes. On v0.3 the browser encrypts the document before it leaves the device, so the server and the pinning service only ever see an encrypted container.

Two more routes help with key replacement: GET /api/documents finds the issuer's pins of one document, and DELETE /api/documents removes the older ones after a new copy is stored.

All three routes need an issuer session; see Sign-in and sessions. For the encryption itself see Encrypted container and Document storage.

Upload​

Order of checks​

src/app/api/upload/route.ts runs these steps and stops at the first failure:

StepCheckFailure
1Auth settings, Pinata credentials and the store are present500 CONFIG_MISSING "Upload service not configured"
2requireIssuerSession with scope upload (IP budget, cookie, role)429, 401, 403 or 503
3Wallet budget: 30 uploads per hour (upload:<address>)429 RATE_LIMITED
4Content-Length is not above 4 MiB plus 64 KiB of form overhead413 PAYLOAD_TOO_LARGE "File exceeds 4 MB"
5The body is multipart form data with exactly one file field holding a file400 "Expected multipart form data" or "Expected exactly one file"
6The file itself is at most 4 MiB (MAX_UPLOAD_BYTES)413 "File exceeds 4 MB"
7Mode-specific checks (below)see below
8Pin on Pinata502 UPSTREAM_ERROR

The 4 MiB cap (ADR-0029) is shared with the browser, which refuses larger files before uploading. It stays under Vercel's 4.5 MB request body limit.

v0.3: encrypted containers only​

When NEXT_PUBLIC_CONTRACT_VERSION is v03, readEncryptionMode returns container and inspectContainerUpload (src/lib/server/upload-container.ts) applies:

CheckFailure
The bytes parse as a container (parseContainer)If they look like a PDF, PNG or JPEG: 400 PLAINTEXT_NOT_ACCEPTED "Documents must be encrypted before upload". Otherwise: 400 INVALID_CONTAINER "Not a valid encrypted document (<reason code>)"
The container header names the signed-in wallet as issuer403 FORBIDDEN "The document was encrypted for a different wallet"
X-Document-SHA256 is present and is 0x plus 64 hex characters400 BAD_REQUEST "X-Document-SHA256 must be a 0x-prefixed 32-byte hex value"
It equals the documentHash in the container header422 DOCUMENT_HASH_MISMATCH "The document hash does not match the encrypted document"

The server cannot decrypt the container, so it cannot recompute the plaintext hash. What it can check is that the header, the header field the browser states, and the session agree. The plaintext hash is what the issuer signs and anchors on chain, and any verifier who opens the document recomputes it.

The container is pinned as application/octet-stream named certichain-<first 8 hex of documentHash>, with Pinata key-values documentHash and issuer. Those key-values are what document lookup filters on later.

Success:

{
"data": {
"cid": "bafkreiexampleexampleexampleexampleexampleexampleexample",
"IpfsHash": "bafkreiexampleexampleexampleexampleexampleexampleexample",
"documentHash": "0x1111111111111111111111111111111111111111111111111111111111111111",
"blobHash": "0x2222222222222222222222222222222222222222222222222222222222222222"
}
}

blobHash is the SHA-256 of the pinned container bytes. IpfsHash repeats cid for older callers.

v0.2: plain PDF, PNG or JPEG​

On v0.2 the server accepts plain files:

CheckFailure
The declared type is PDF, PNG or JPEG and the first bytes match that type's signature (isAllowedFile in src/lib/server/file-type.ts)415 UNSUPPORTED_MEDIA_TYPE "Only PDF, PNG and JPEG files are accepted"
If X-Document-SHA256 is sent, it is well formed400 BAD_REQUEST
The server's own SHA-256 of the file equals it422 DOCUMENT_HASH_MISMATCH "The file does not match the hash the issuer computed"

The file is pinned under its sanitized name. The answer is { cid, IpfsHash, documentHash } and the browser (uploadDocument in src/services/upload.ts) checks the returned hash against its own.

v0.2 documents are not encrypted

A v0.2 document is public to anyone who learns its CID. The v0.2 path is kept for the legacy contracts only. See Legacy v0.2.

Pinata errors​

src/lib/server/pinata.ts posts to https://api.pinata.cloud/pinning/pinFileToIPFS with PINATA_JWT or the key pair PINATA_API_KEY and PINATA_API_SECRET_KEY.

FailureAnswerLog
Pinata answered with an error502 UPSTREAM_ERROR "Storage provider rejected the upload"Pinata's HTTP status and request id
Pinata could not be reached502 UPSTREAM_ERROR "Storage provider unavailable"The error name

Second pin​

A single pinning account is a single point of failure. src/lib/server/pinning/ can copy every new pin to a second service after the response is sent:

  • mirrorAfterPin schedules the copy with Next.js after(), so the issuer does not wait for it.
  • mirrorPin sends the same bytes to the second provider with a 15 second timeout. The second provider's CID must equal the first one, or the copy counts as failed.
  • A failed copy is written to a ledger in the state store. repairPending retries the ledger's entries each time the monitor's pins check runs. mirrorPin never throws; a failure to start is logged as "The second pin could not be started:".
  • The monitor's pins check reads back a sample of 10 recent pins through each configured gateway (NEXT_PUBLIC_PINATA_GATEWAY_URL, default https://ipfs.io, plus the comma list PIN_READ_GATEWAYS).
Off as of 2026-10-24

The provider interface (PinProvider) exists but no adapter is written yet. SECOND_PIN_PROVIDER unset means no second pin. Setting it to a name with no adapter is a configuration error ("SECOND_PIN_PROVIDER names a service with no adapter.").

Document lookup and removal​

When an issuer replaces the key of an encrypted document, a new container is pinned and the old copies, encrypted under the old key, should go. The browser finds and removes them through src/services/document-api.ts.

Both methods need PINATA_JWT (the key pair is not enough) and use the documents scope of the IP budget.

GET /api/documents?documentHash=...&issuer=...​

CheckFailure
Wallet budget: 120 per hour (documents:<address>)429
documentHash is 0x plus 64 hex400 "documentHash must be a 0x-prefixed 32-byte hex value"
issuer is an address400 "issuer must be a wallet address"
issuer is the signed-in wallet403 "The signed-in wallet is not the one asking. Sign in again with the current wallet."
Pinata answers503 UPSTREAM_ERROR "Could not reach document storage. Try again shortly." with Retry-After: 30

findPinsByDocument (src/lib/server/pinata-list.ts) queries Pinata's v3 file list (https://api.pinata.cloud/v3/files/public) filtered by the documentHash and issuer key-values, newest first, at most 50, and filters the result again locally. The JWT needs the org:files:read scope. The answer is { data: { cids } }, newest first, with Cache-Control: no-store.

DELETE /api/documents?documentHash=...&issuer=...&keep=...​

The same checks apply, with a budget of 20 per hour (documents-remove:<address>), plus:

CheckFailure
keep is a CID400 "keep must be the CID of the copy to keep"
keep is one of the issuer's pins of this document409 KEEP_NOT_FOUND "The copy to keep is not stored under this account. Nothing was removed."

removeOtherPins (src/lib/server/pinata-unpin.ts) removes only the issuer's pins of this document that are older than the kept one, never newer ones. The answer is { data: { removed, remaining } }, where remaining counts the issuer's pins of the document still stored. Removal is one Pinata call per file. The list and the deletes are not atomic: if one delete fails, the request fails and can be repeated, and two requests at the same time are best effort, not a lock. The JWT needs file write access.

Removing a pin from Pinata does not erase copies that other IPFS nodes or gateways may have cached. The old copy is encrypted under the replaced key, which is what protects it.

Configuration​

NameMeaning
PINATA_JWTPinata token; needed for lookup and removal
PINATA_API_KEY, PINATA_API_SECRET_KEYOlder key pair, upload only
NEXT_PUBLIC_PINATA_GATEWAY_URLGateway the browser and the pin check read through
PIN_READ_GATEWAYSExtra gateways for the pin check
SECOND_PIN_PROVIDERName of the second pin service (no adapter yet)
NEXT_PUBLIC_CONTRACT_VERSIONv03 selects container mode

Plus everything sign-in needs. See Configuration.

Source files​

  • src/app/api/upload/route.ts, src/app/api/documents/route.ts
  • src/lib/server/upload-container.ts, file-type.ts
  • src/lib/server/pinata.ts, pinata-list.ts, pinata-unpin.ts
  • src/lib/server/pinning/ (after-pin.ts, mirror.ts, ledger.ts, repair.ts, providers.ts, gateway-reader.ts, check.ts)
  • src/services/upload.ts, src/services/document-api.ts

Tests​

  • src/lib/server/upload-container.test.ts, pinata.test.ts, pinata-list.test.ts, pinata-unpin.test.ts
  • src/lib/server/pinning/*.test.ts
  • src/services/upload.test.ts, src/services/document-api.test.ts
  • E2E: e2e/journeys/encrypted-documents.journey.ts, share-and-replace.journey.ts, replace-shared-file.journey.ts (against a mock storage service in e2e/support/mock-storage.ts)