Skip to main content

API Layer Developer Guide

Version: 1.0.0 Phase: 9 - API Layer Last Updated: December 2025


Overview

Chive's API layer provides two primary interfaces for accessing preprint data:

  1. XRPC Endpoints (/xrpc/pub.chive.*) - ATProto-native procedure calls
  2. REST Endpoints (/api/v1/*) - Traditional REST API for broader compatibility

Both interfaces share the same middleware stack and return equivalent data with ATProto compliance guarantees.


Architecture

Technology Stack

  • Framework: Hono, a fast, lightweight web framework
  • Validation: Zod, TypeScript-first schema validation
  • Documentation: @hono/zod-openapi, OpenAPI 3.1 generation

Directory Structure

src/api/
├── server.ts # Application factory
├── routes.ts # Route registration
├── config.ts # API configuration
├── middleware/
│ ├── auth.ts # DID-based authentication
│ ├── error-handler.ts # ChiveError → HTTP mapping
│ ├── rate-limit.ts # 4-tier Redis rate limiting
│ ├── request-context.ts # Request ID, timing, logging
│ └── validation.ts # Zod schema validation
├── handlers/
│ ├── xrpc/
│ │ ├── preprint/ # Preprint XRPC handlers
│ │ ├── graph/ # Knowledge graph handlers
│ │ └── metrics/ # Metrics handlers
│ └── rest/
│ ├── health.ts # Health check endpoints
│ └── v1/ # REST v1 endpoints
├── schemas/
│ ├── common.ts # Shared schemas
│ ├── error.ts # Error response schemas
│ ├── preprint.ts # Preprint schemas
│ └── graph.ts # Graph schemas
└── types/
├── context.ts # Hono context extensions
└── handlers.ts # Handler type definitions

Quick Start

Creating the Server

import { createServer } from '@/api/server.js';

const app = createServer({
preprintService,
searchService,
metricsService,
graphService,
blobProxyService,
redis,
logger,
});

// Node.js
import { serve } from '@hono/node-server';
serve({ fetch: app.fetch, port: 3000 });

// Bun
export default { port: 3000, fetch: app.fetch };

Making Requests

# XRPC: Get a preprint
curl "https://api.chive.pub/xrpc/pub.chive.preprint.getSubmission?uri=at://did:plc:abc/pub.chive.preprint.submission/xyz"

# REST: Get a preprint
curl "https://api.chive.pub/api/v1/preprints/at%3A%2F%2Fdid%3Aplc%3Aabc%2Fpub.chive.preprint.submission%2Fxyz"

# Search preprints
curl -X POST "https://api.chive.pub/xrpc/pub.chive.preprint.searchSubmissions" \
-H "Content-Type: application/json" \
-d '{"q": "quantum computing", "limit": 20}'

XRPC Endpoints

Preprint Endpoints

pub.chive.preprint.getSubmission

Retrieves a single preprint by AT URI.

Request:

GET /xrpc/pub.chive.preprint.getSubmission?uri={atUri}

Parameters:

NameTypeRequiredDescription
uristringYesAT Protocol URI (at://did/collection/rkey)

Response:

{
"uri": "at://did:plc:abc/pub.chive.preprint.submission/xyz",
"cid": "bafyreiabc123",
"title": "Quantum Computing Advances",
"abstract": "This paper presents...",
"author": "did:plc:abc",
"license": "CC-BY-4.0",
"document": {
"$type": "blob",
"ref": "bafkreipdf123",
"mimeType": "application/pdf",
"size": 1024000
},
"source": {
"pdsEndpoint": "https://bsky.social",
"recordUrl": "https://bsky.social/xrpc/com.atproto.repo.getRecord?...",
"blobUrl": "https://bsky.social/xrpc/com.atproto.sync.getBlob?...",
"lastVerifiedAt": "2024-01-15T10:05:00Z",
"stale": false
},
"versions": [
{
"version": 1,
"cid": "bafyreiabc123",
"createdAt": "2024-01-15T10:00:00Z",
"changelog": "Initial submission"
}
],
"metrics": {
"views": 150,
"downloads": 42,
"endorsements": 5
}
}

pub.chive.preprint.listByAuthor

Lists preprints by author DID.

Request:

GET /xrpc/pub.chive.preprint.listByAuthor?did={did}&limit={n}&cursor={c}&sort={sort}

Parameters:

NameTypeRequiredDescription
didstringYesAuthor's DID
limitnumberNoMax results (default: 20, max: 100)
cursorstringNoPagination cursor
sortstringNoSort order: "date" or "relevance"

pub.chive.preprint.searchSubmissions

Full-text search across preprints.

Request:

POST /xrpc/pub.chive.preprint.searchSubmissions
Content-Type: application/json

{
"q": "quantum computing",
"limit": 20,
"cursor": "abc123",
"facets": {
"subject": "physics"
}
}

Graph Endpoints

pub.chive.graph.getField

Retrieves a knowledge graph field by ID.

Request:

GET /xrpc/pub.chive.graph.getField?id={fieldId}

pub.chive.graph.searchAuthorities

Searches authority records (controlled vocabulary).

Request:

GET /xrpc/pub.chive.graph.searchAuthorities?query={q}&type={type}&status={status}

pub.chive.graph.browseFaceted

Browse preprints using PMEST faceted classification.

Request:

GET /xrpc/pub.chive.graph.browseFaceted?facets.matter=physics&facets.time=2024

Metrics Endpoints

pub.chive.metrics.getTrending

Retrieves trending preprints.

Request:

GET /xrpc/pub.chive.metrics.getTrending?window={24h|7d|30d}&limit={n}

REST Endpoints

Preprints

MethodPathDescription
GET/api/v1/preprintsList preprints
GET/api/v1/preprints/:uriGet preprint by URI
MethodPathDescription
GET/api/v1/search?q={query}Search preprints

Health

MethodPathDescription
GET/healthLiveness probe
GET/readyReadiness probe

ATProto Compliance

Critical Requirements

Every API response MUST include:

  1. source.pdsEndpoint: URL of the user's PDS
  2. source.recordUrl: Direct URL to fetch record from PDS
  3. source.lastVerifiedAt: When data was last synced from PDS
  4. source.stale: Boolean indicating if data may be outdated (>7 days)

BlobRef Only

Documents are returned as BlobRefs, never inline data:

{
"document": {
"$type": "blob",
"ref": "bafkreipdf123",
"mimeType": "application/pdf",
"size": 1024000
}
}

Never:

{
"document": {
"data": "base64...", // FORBIDDEN
"content": "..." // FORBIDDEN
}
}

No Write Operations

The API is read-only. These endpoints do NOT exist:

  • pub.chive.preprint.create
  • pub.chive.preprint.update
  • pub.chive.preprint.delete
  • com.atproto.repo.uploadBlob

Users create content in their PDSes directly. Chive indexes via firehose.


Rate Limiting

Tiers

TierLimitKey
Anonymous60 req/minIP address
Authenticated300 req/minUser DID
Premium1000 req/minUser DID
Admin5000 req/minUser DID

Response Headers

X-RateLimit-Limit: 60
X-RateLimit-Remaining: 42
X-RateLimit-Reset: 1700000060

Rate Limited Response

HTTP/1.1 429 Too Many Requests
Retry-After: 30

{
"error": {
"code": "RATE_LIMIT",
"message": "Rate limit exceeded for anonymous tier",
"requestId": "req_abc123",
"retryAfter": 30
}
}

Bypassed Endpoints

Health endpoints (/health, /ready) are not rate limited.


Error Handling

Error Response Format

All errors follow Stripe/GitHub pattern:

{
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid email format",
"field": "email",
"requestId": "req_abc123"
}
}

Error Codes

CodeHTTP StatusDescription
VALIDATION_ERROR400Invalid input
AUTHENTICATION_ERROR401Invalid/missing token
AUTHORIZATION_ERROR403Insufficient permissions
NOT_FOUND404Resource not found
RATE_LIMIT429Rate limit exceeded
DATABASE_ERROR500Database operation failed
COMPLIANCE_ERROR500ATProto compliance violation
INTERNAL_ERROR500Unexpected error

Middleware Stack

The middleware executes in order:

  1. Security Headers: X-Content-Type-Options, X-Frame-Options, etc.
  2. CORS: Cross-origin resource sharing
  3. Service Injection: Injects services into context
  4. Request Context: Generates request ID, timing, logging
  5. Authentication: Optional DID-based auth
  6. Rate Limiting: 4-tier Redis sliding window
  7. Error Handling: ChiveError → HTTP response

Adding New Handlers

1. Create Schema

// src/api/schemas/my-feature.ts
import { z } from 'zod';

export const myFeatureQuerySchema = z.object({
id: z.string().min(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
});

export type MyFeatureQuery = z.infer<typeof myFeatureQuerySchema>;

2. Create Handler

// src/api/handlers/xrpc/my-feature/getItem.ts
import type { Context } from 'hono';
import { NotFoundError } from '@/types/errors.js';
import type { ChiveEnv } from '@/api/types/context.js';
import type { MyFeatureQuery } from '@/api/schemas/my-feature.js';

export async function getItemHandler(
c: Context<ChiveEnv>,
params: MyFeatureQuery
): Promise<MyItemResponse> {
const services = c.get('services');
const logger = c.get('logger');

logger.debug('Getting item', { id: params.id });

const item = await services.myFeature.getItem(params.id);

if (!item) {
throw new NotFoundError('Item', params.id);
}

// Always include source for ATProto compliance
return {
id: item.id,
data: item.data,
source: {
pdsEndpoint: item.pdsUrl,
recordUrl: buildRecordUrl(item),
lastVerifiedAt: item.indexedAt.toISOString(),
stale: isStale(item.indexedAt),
},
};
}

3. Register Route

// src/api/routes.ts
import { validateQuery } from './middleware/validation.js';
import { myFeatureQuerySchema } from './schemas/my-feature.js';
import { getItemHandler } from './handlers/xrpc/my-feature/getItem.js';

// In registerXRPCRoutes():
app.get('/xrpc/pub.chive.myFeature.getItem', validateQuery(myFeatureQuerySchema), async (c) => {
const params = c.get('validatedInput') as MyFeatureQuery;
const result = await getItemHandler(c, params);
return c.json(result);
});

Testing

Unit Tests

npm run test:unit -- tests/unit/api/

Test files:

  • tests/unit/api/middleware/*.test.ts
  • tests/unit/api/handlers/**/*.test.ts

Integration Tests

# Start test infrastructure
./scripts/start-test-stack.sh

# Run integration tests
npm run test:integration -- tests/integration/api/

Test files:

  • tests/integration/api/xrpc/*.test.ts
  • tests/integration/api/rest/**/*.test.ts
  • tests/integration/api/rate-limiting.test.ts

Compliance Tests

npm run test:compliance

Test file: tests/compliance/api-layer-compliance.test.ts

Compliance tests MUST pass at 100% for CI/CD.


Common Issues

1. "source field missing in response"

Ensure handler returns source object:

return {
...data,
source: {
pdsEndpoint: preprint.pdsUrl,
recordUrl: buildRecordUrl(preprint),
lastVerifiedAt: preprint.indexedAt.toISOString(),
stale: isStale(preprint.indexedAt),
},
};

2. "Rate limit headers not appearing"

Check middleware order in server.ts. Rate limiting must come after request context.

3. "Validation errors not formatted correctly"

Ensure validateQuery/validateBody middleware is applied to route.

4. "Context type errors"

Use proper ChiveEnv type:

import type { Context } from 'hono';
import type { ChiveEnv } from '@/api/types/context.js';

export async function myHandler(c: Context<ChiveEnv>) {
const services = c.get('services'); // Typed correctly
}

Configuration

Environment Variables

VariableDescriptionDefault
PORTServer port3000
CORS_ORIGINSAllowed CORS originshttp://localhost:*
RATE_LIMIT_REDIS_URLRedis URL for rate limitingredis://localhost:6379
RATE_LIMIT_FAIL_MODERate limiter behavior when Redis is down (open or closed)closed
STALENESS_THRESHOLD_MSThreshold for marking records as stale (milliseconds)604800000 (7 days)
LOG_LEVELLogging levelinfo

Rate Limit Configuration

Edit src/api/config.ts:

export const RATE_LIMITS = {
anonymous: { maxRequests: 60, windowMs: 60_000 },
authenticated: { maxRequests: 300, windowMs: 60_000 },
premium: { maxRequests: 1000, windowMs: 60_000 },
admin: { maxRequests: 5000, windowMs: 60_000 },
} as const;

OpenAPI Documentation

Access interactive documentation at:

  • /docs - Swagger UI
  • /openapi.json - OpenAPI 3.1 spec

Security Notice

Security Placeholder Implementations

The following security features are placeholder implementations pending full authentication integration:

  • Token Verification (src/api/middleware/auth.ts): Currently only validates token structure and expiration, not cryptographic signatures. See @todo comments in code.
  • Scope Authorization (src/api/middleware/auth.ts): Only admin scope is enforced; other scopes pass through.
  • Rate Limiter Fail Mode: Configurable via RATE_LIMIT_FAIL_MODE env var. Default is closed (reject requests when Redis is down) for Zero Trust compliance.

For production deployments, ensure authentication and authorization features are completed.