API Gateway
@decoperations/s3worm-gateway is a framework-agnostic CRUD gateway for S3WORM. It sits between HTTP clients and S3 buckets, resolves authentication, enforces ACLs, and proxies typed CRUD operations as REST endpoints.
Install
pnpm add @decoperations/s3worm-gateway
Gateway Configuration
import { Gateway } from "@decoperations/s3worm-gateway";
import type { GatewayConfig } from "@decoperations/s3worm-gateway";
const config: GatewayConfig = {
s3: {
bucket: "my-bucket",
region: "us-east-1",
endpoint: "https://gateway.storjshare.io",
accessKeyId: process.env.S3_ACCESS_KEY!,
secretAccessKey: process.env.S3_SECRET_KEY!,
},
schema: mySchema, // WormSchemaConfig object
auth: { /* ... */ }, // Auth configuration
models: ["Customer", "Invoice"], // Expose specific models (default: all)
basePath: "/api", // URL prefix (stripped before routing)
cors: {
origin: "https://myapp.com",
credentials: true,
},
onBeforeOperation: async (ctx) => {
console.log(`${ctx.method} ${ctx.model} by ${ctx.principal.id}`);
},
onAfterOperation: async (ctx, result) => {
return result; // Transform or log
},
};
const gateway = new Gateway(config);
GatewayConfig Reference
| Field | Type | Default | Description |
|---|---|---|---|
s3 | object | required | S3 bucket credentials |
schema | WormSchemaConfig | required | Schema configuration |
auth | AuthConfig | {} | Authentication configuration |
models | string[] | all models | Models to expose via the gateway |
basePath | string | "" | URL prefix stripped before routing |
cors | CorsConfig | none | CORS configuration |
onBeforeOperation | function | none | Hook called before each operation |
onAfterOperation | function | none | Hook called after each operation |
Authentication
The gateway supports multiple auth strategies. They are tried in order: JWT, API key, custom, then anonymous fallback.
JWT Authentication
const config: GatewayConfig = {
// ...
auth: {
jwt: {
secret: process.env.JWT_SECRET!,
issuer: "https://auth.myapp.com",
audience: "my-api",
mapClaims: (claims) => ({
id: `user:${claims.sub}`,
type: "user",
name: claims.name,
roles: claims.roles ?? [],
identity: { method: "email", email: claims.email! },
}),
},
},
};
JWT tokens are passed via the Authorization: Bearer <token> header. The gateway decodes the payload, validates expiration/issuer/audience, and optionally verifies the HS256 signature.
| JWT Config Field | Description |
|---|---|
secret | HMAC secret for HS256 signature verification |
issuer | Expected iss claim |
audience | Expected aud claim |
mapClaims | Custom function to map JWT claims to a Principal |
API Key Authentication
const config: GatewayConfig = {
// ...
auth: {
apiKey: {
keys: {
"sk_prod_abc123": {
id: "service:my-backend",
type: "service",
keyId: "sk_prod_abc123",
roles: ["service"],
},
"sk_prod_def456": {
id: "service:worker",
type: "service",
keyId: "sk_prod_def456",
roles: ["worker"],
},
},
},
},
};
API keys are passed via the X-API-Key header. The gateway looks up the key in the static map or calls the dynamic resolver.
For dynamic key resolution:
auth: {
apiKey: {
resolve: async (apiKey) => {
const record = await db.apiKeys.findOne({ key: apiKey });
if (!record) return null;
return {
id: `service:${record.name}`,
type: "service",
keyId: apiKey,
roles: record.roles,
};
},
},
},
Custom Authentication
auth: {
custom: async (headers) => {
const sessionId = headers["x-session-id"];
if (!sessionId) return null;
const session = await sessionStore.get(sessionId);
if (!session) return null;
return {
id: `user:${session.userId}`,
type: "user",
roles: session.roles,
identity: { method: "email", email: session.email },
};
},
},
Anonymous Access
By default, unauthenticated requests fall through as anonymous. Disable with:
auth: {
allowAnonymous: false, // Returns 401 when no auth header is present
},
Principal Interface
All auth strategies resolve to a Principal:
interface Principal {
id: string;
name?: string;
type: "user" | "service" | "anonymous";
roles?: string[];
}
REST Endpoints
The gateway auto-generates REST endpoints for every exposed model.
Standard CRUD
| Method | Path | Operation | Description |
|---|---|---|---|
GET | /api/{model} | list | List all entities |
GET | /api/{model}/{id} | read | Get a single entity by ID |
POST | /api/{model} | write | Create a new entity |
PUT | /api/{model}/{id} | write | Full update of an entity |
PATCH | /api/{model}/{id} | write | Partial update of an entity |
DELETE | /api/{model}/{id} | delete | Delete an entity |
Query Parameters
| Parameter | Description | Example |
|---|---|---|
filter[field]=value | Filter by field equality | ?filter[status]=active |
filter.field=value | Alternative filter syntax | ?filter.status=active |
sort=field:order | Sort by field | ?sort=createdAt:desc |
limit=N | Maximum results | ?limit=10 |
offset=N | Skip N results | ?offset=20 |
select=f1,f2 | Return only these fields | ?select=name,email |
populate=ref1,ref2 | Populate ref fields | ?populate=customerId |
Examples
# List active customers, sorted by name
GET /api/Customer?filter[status]=active&sort=name:asc&limit=50
# Get a single customer
GET /api/Customer/abc-123
# Create a customer
POST /api/Customer
Content-Type: application/json
{"name": "Acme Corp", "email": "hello@acme.com"}
# Update a customer
PUT /api/Customer/abc-123
Content-Type: application/json
{"name": "Acme Inc", "status": "inactive"}
# Partial update
PATCH /api/Customer/abc-123
Content-Type: application/json
{"status": "active"}
# Delete
DELETE /api/Customer/abc-123
Response Format
All responses use a consistent envelope:
// Success -- single entity
{ "data": { "id": "abc-123", "name": "Acme Corp", ... } }
// Success -- list
{
"data": [
{ "id": "abc-123", "name": "Acme Corp" },
{ "id": "def-456", "name": "Globex" }
]
}
// Error
{
"error": "Forbidden",
"message": "Access denied: model \"Customer\" does not allow \"write\" for principal \"anonymous\"",
"reason": { "layer": "model", ... }
}
HTTP Status Codes
| Code | Meaning |
|---|---|
200 | Success |
204 | Success (OPTIONS preflight) |
400 | Bad request (missing ID for update/delete) |
401 | Authentication required or invalid |
403 | ACL denied |
404 | Not found |
405 | Method not allowed |
409 | Conflict (unique constraint, append-only violation) |
422 | Validation failed |
500 | Internal server error |
Hooks
onBeforeOperation
Called before every CRUD operation. Use it for logging, rate limiting, or custom authorization:
onBeforeOperation: async (ctx) => {
// ctx.method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"
// ctx.model: "Customer"
// ctx.entityId: "abc-123" | undefined
// ctx.principal: { id: "user:...", type: "user", roles: [...] }
// ctx.authResult: { principal, method: "jwt", claims: {...} }
// ctx.query: { filter, sort, limit, offset, select, populate }
// ctx.body: { name: "Acme", ... } | undefined
if (ctx.principal.type === "anonymous" && ctx.method !== "GET") {
throw new Error("Anonymous write access is disabled");
}
},
onAfterOperation
Called after every successful operation. Use it for response transformation or audit logging:
onAfterOperation: async (ctx, result) => {
await auditLog.write({
principal: ctx.principal.id,
model: ctx.model,
operation: ctx.method,
entityId: ctx.entityId,
});
return result; // Return modified or original result
},
CORS Configuration
cors: {
origin: "https://myapp.com", // string or string[]
methods: ["GET", "POST", "PUT", "DELETE"],
headers: ["Content-Type", "Authorization", "X-API-Key"],
credentials: true,
maxAge: 86400, // Cache preflight for 24h
},
Default CORS headers (when cors is set):
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization, X-API-Key
Next.js App Router Adapter
The gateway ships with a first-class Next.js adapter for App Router catch-all routes.
Catch-All Route
// app/api/worm/[...path]/route.ts
import { createWormHandler } from "@decoperations/s3worm-gateway/nextjs";
const handler = createWormHandler({
s3: {
bucket: process.env.S3_BUCKET!,
accessKeyId: process.env.S3_ACCESS_KEY!,
secretAccessKey: process.env.S3_SECRET_KEY!,
endpoint: process.env.S3_ENDPOINT!,
},
schema: mySchema,
auth: {
jwt: { secret: process.env.JWT_SECRET! },
},
basePath: "/api/worm",
cors: {
origin: process.env.NEXT_PUBLIC_APP_URL!,
credentials: true,
},
});
export const GET = handler;
export const POST = handler;
export const PUT = handler;
export const PATCH = handler;
export const DELETE = handler;
export const OPTIONS = handler;
This maps requests like GET /api/worm/Customer/abc-123 to the gateway's findById("Customer", "abc-123").
Per-Model Routes
For separate route files per model:
// app/api/customers/route.ts
import { createModelHandlers } from "@decoperations/s3worm-gateway/nextjs";
const { GET, POST } = createModelHandlers("Customer", config);
export { GET, POST };
// app/api/customers/[id]/route.ts
import { createEntityHandlers } from "@decoperations/s3worm-gateway/nextjs";
const { GET, PUT, PATCH, DELETE } = createEntityHandlers("Customer", config);
export { GET, PUT, PATCH, DELETE };
Framework-Agnostic Usage
The gateway works with any framework via the handleRequest method:
import { Gateway, type GatewayRequest } from "@decoperations/s3worm-gateway";
const gateway = new Gateway(config);
// Adapt to your framework's request/response model
const req: GatewayRequest = {
method: "GET",
path: "/Customer",
headers: { authorization: "Bearer eyJ..." },
query: { "filter[status]": "active" },
};
const res = await gateway.handleRequest(req);
// res.status: 200
// res.headers: { "Content-Type": "application/json", ... }
// res.body: { data: [...] }
GatewayRequest / GatewayResponse
interface GatewayRequest {
method: string;
path: string;
headers: Record<string, string>;
query?: Record<string, string | string[]>;
body?: unknown;
}
interface GatewayResponse {
status: number;
headers: Record<string, string>;
body: unknown;
}