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:
| Step | Check | Failure |
|---|---|---|
| 1 | Auth settings, Pinata credentials and the store are present | 500 CONFIG_MISSING "Upload service not configured" |
| 2 | requireIssuerSession with scope upload (IP budget, cookie, role) | 429, 401, 403 or 503 |
| 3 | Wallet budget: 30 uploads per hour (upload:<address>) | 429 RATE_LIMITED |
| 4 | Content-Length is not above 4 MiB plus 64 KiB of form overhead | 413 PAYLOAD_TOO_LARGE "File exceeds 4 MB" |
| 5 | The body is multipart form data with exactly one file field holding a file | 400 "Expected multipart form data" or "Expected exactly one file" |
| 6 | The file itself is at most 4 MiB (MAX_UPLOAD_BYTES) | 413 "File exceeds 4 MB" |
| 7 | Mode-specific checks (below) | see below |
| 8 | Pin on Pinata | 502 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:
| Check | Failure |
|---|---|
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 issuer | 403 FORBIDDEN "The document was encrypted for a different wallet" |
X-Document-SHA256 is present and is 0x plus 64 hex characters | 400 BAD_REQUEST "X-Document-SHA256 must be a 0x-prefixed 32-byte hex value" |
It equals the documentHash in the container header | 422 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:
| Check | Failure |
|---|---|
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 formed | 400 BAD_REQUEST |
| The server's own SHA-256 of the file equals it | 422 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.
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.
| Failure | Answer | Log |
|---|---|---|
| Pinata answered with an error | 502 UPSTREAM_ERROR "Storage provider rejected the upload" | Pinata's HTTP status and request id |
| Pinata could not be reached | 502 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:
mirrorAfterPinschedules the copy with Next.jsafter(), so the issuer does not wait for it.mirrorPinsends 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.
repairPendingretries the ledger's entries each time the monitor'spinscheck runs.mirrorPinnever throws; a failure to start is logged as "The second pin could not be started:". - The monitor's
pinscheck reads back a sample of 10 recent pins through each configured gateway (NEXT_PUBLIC_PINATA_GATEWAY_URL, defaulthttps://ipfs.io, plus the comma listPIN_READ_GATEWAYS).
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=...
| Check | Failure |
|---|---|
Wallet budget: 120 per hour (documents:<address>) | 429 |
documentHash is 0x plus 64 hex | 400 "documentHash must be a 0x-prefixed 32-byte hex value" |
issuer is an address | 400 "issuer must be a wallet address" |
issuer is the signed-in wallet | 403 "The signed-in wallet is not the one asking. Sign in again with the current wallet." |
| Pinata answers | 503 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:
| Check | Failure |
|---|---|
keep is a CID | 400 "keep must be the CID of the copy to keep" |
keep is one of the issuer's pins of this document | 409 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
| Name | Meaning |
|---|---|
PINATA_JWT | Pinata token; needed for lookup and removal |
PINATA_API_KEY, PINATA_API_SECRET_KEY | Older key pair, upload only |
NEXT_PUBLIC_PINATA_GATEWAY_URL | Gateway the browser and the pin check read through |
PIN_READ_GATEWAYS | Extra gateways for the pin check |
SECOND_PIN_PROVIDER | Name of the second pin service (no adapter yet) |
NEXT_PUBLIC_CONTRACT_VERSION | v03 selects container mode |
Plus everything sign-in needs. See Configuration.
Source files
src/app/api/upload/route.ts,src/app/api/documents/route.tssrc/lib/server/upload-container.ts,file-type.tssrc/lib/server/pinata.ts,pinata-list.ts,pinata-unpin.tssrc/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.tssrc/lib/server/pinning/*.test.tssrc/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 ine2e/support/mock-storage.ts)