Skip to main content

API Server

The API server is a NestJS application that acts as a relay between the desktop app and the blockchain. It also stores encrypted payloads and serves verification queries.

Why a Relay?​

The desktop app does not interact with the blockchain directly because:

  1. Private key management -- The relay holds the wallet private key for submitting transactions. Users don't need their own blockchain wallet.
  2. Gas management -- The relay pays gas fees, abstracting blockchain costs from end users.
  3. Rate limiting and quotas -- The API can enforce usage limits per user/plan.

Authentication​

The API uses self-hosted authentication with three providers:

  • Magic link -- 6-digit code sent via email (SMTP/nodemailer)
  • Google OAuth -- via Passport.js strategy
  • Microsoft OAuth -- via Passport.js strategy

All auth endpoints live under /api/auth/. OAuth callbacks redirect to veylent://auth/callback?token=<jwt> for deep link capture by the desktop app. JWTs are signed with HS256 and expire after 24 hours.

JWT claims include a trust field (the auth provider: "email", "google", "microsoft") and verified: true. These flow into the signature payload, allowing verifiers to see how the signer's identity was confirmed.

Endpoints​

POST /api/relay​

Submits a document anchor to the blockchain. Requires JWT authentication.

Request:

{
"anchorId": "0x0123...cdef",
"compositeHash": "0xabc...def",
"previousAnchorId": "0x00000000000000000000000000000000",
"encryptedPayload": "<base64url-encoded ciphertext>"
}

anchorId is the random 16-byte ID the desktop app printed in the QR code. previousAnchorId is the previous signer's anchor ID, or all zeros for the first signer.

Process:

  1. Validates the JWT and extracts user ID
  2. Validates the request
  3. Reads anchorBlock on the contract to reject a reused anchor ID or an unknown previous anchor ID early
  4. Reserves a slot in the user's anchor quota
  5. Calls anchorDocument(anchorId, compositeHash, previousAnchorId) on the Veylent smart contract
  6. Waits for the transaction receipt
  7. Stores the anchor record in the database (anchor ID, tx hash, composite hash, encrypted payload, user ID)
  8. Returns the anchor ID, transaction hash, and block number

Response:

{
"anchorId": "0x0123...cdef",
"txHash": "0x123...789",
"blockNumber": 42
}

Errors:

  • 409 Conflict -- the anchor ID is already in use
  • 400 Bad Request -- the previous anchor ID is not on the blockchain, the anchor ID is zero, or the quota is exceeded
  • 401 Unauthorized -- missing or invalid JWT

The contract enforces the same rules, so two racing requests cannot both succeed.

GET /api/verify/:anchorId​

Looks up a signature by the anchor ID printed in its QR code. anchorId is 0x followed by 32 hex characters. Rate limited to 60 requests per minute per client IP.

Process:

  1. Checks the in-memory cache (60-second TTL)
  2. Reads anchorBlock(anchorId) from the contract to find the block it was anchored in (404 if zero)
  3. Queries the DocumentAnchored event for that anchor ID in that single block
  4. Walks the signature chain backwards by following previousAnchorId (at most 500 signatures)
  5. Looks up the encrypted payload from the database by anchor ID
  6. Returns the full verification result

Steps 2 to 4 use only the blockchain. Anyone can repeat them against the contract without the server's database. The database only supplies the encrypted payload, and clients check it against the on-chain composite hash.

Response:

{
"anchorId": "0x0123...cdef",
"txHash": "0x123...789",
"blockNumber": 42,
"compositeHash": "0xabc...def",
"signer": "0xWalletAddress",
"previousAnchorId": "0x00000000000000000000000000000000",
"timestamp": 1711094400,
"chain": [...],
"encryptedPayload": "<base64url-encoded ciphertext>"
}

chain lists this signature first, then each earlier one on the same document. A 404 means no signature with this anchor ID exists on the blockchain.

Database Schema​

model User {
id String @id @default(uuid())
email String @unique
name String?
authProvider String @default("email")
providerId String?
walletAddress String? @unique
anchorCount Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sessions Session[]
anchors Anchor[]
}

model Session {
id String @id @default(uuid())
userId String
token String @unique
expiresAt DateTime
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}

model MagicLink {
id String @id @default(uuid())
email String
code String @unique
expiresAt DateTime
used Boolean @default(false)
createdAt DateTime @default(now())
}

model Anchor {
id String @id @default(uuid())
/// 0x-prefixed 16-byte ID printed in the signature's QR code
anchorId String? @unique
txHash String @unique
compositeHash String
encryptedPayload String
userId String?
user User? @relation(fields: [userId], references: [id])
createdAt DateTime @default(now())

@@index([userId])
}

The encryptedPayload field stores the AES-128-GCM ciphertext as a base64url string. The server cannot decrypt this data -- it simply stores and returns it. Records are looked up by anchorId. The column was added in migration 20261103000000_anchor_id and is nullable so rows from older formats stay valid.

Cloud Library (Opt-In Sync)​

model CloudSignature {
id String @id // client-generated UUID
userId String
label String
base64Png String // base64 PNG stored as TEXT
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
user User @relation(...)
@@index([userId])
}

model CloudTextSnippet {
id String @id
userId String
label String
text String
fontSize Float
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
user User @relation(...)
@@index([userId])
}

Library Endpoints​

The LibraryModule provides opt-in cloud sync for saved signatures and text snippets. All endpoints require JWT authentication. Data is never uploaded without explicit user consent.

MethodPathDescription
GET/api/libraryReturn user's cloud signatures + text snippets
PUT/api/library/syncBulk upsert + delete in a Prisma transaction (last-write-wins via updatedAt)
DELETE/api/libraryWipe all cloud library data (when disabling sync)
GET/api/library/existsCheck if cloud data exists (for new-device prompt)

Signatures are stored as base64 in Postgres TEXT columns (no S3 -- signatures are typically 5-50 KB each).

Security Considerations​

  • CORS enabled -- Required for the verification web app to call the API
  • Validation -- All incoming DTOs are validated with class-validator
  • No authentication on verify -- Verification is intentionally public
  • Relay authentication -- JWT required; unauthenticated requests receive 401