Docs/Packages/API Gateway

    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

    FieldTypeDefaultDescription
    s3objectrequiredS3 bucket credentials
    schemaWormSchemaConfigrequiredSchema configuration
    authAuthConfig{}Authentication configuration
    modelsstring[]all modelsModels to expose via the gateway
    basePathstring""URL prefix stripped before routing
    corsCorsConfignoneCORS configuration
    onBeforeOperationfunctionnoneHook called before each operation
    onAfterOperationfunctionnoneHook 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 FieldDescription
    secretHMAC secret for HS256 signature verification
    issuerExpected iss claim
    audienceExpected aud claim
    mapClaimsCustom 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

    MethodPathOperationDescription
    GET/api/{model}listList all entities
    GET/api/{model}/{id}readGet a single entity by ID
    POST/api/{model}writeCreate a new entity
    PUT/api/{model}/{id}writeFull update of an entity
    PATCH/api/{model}/{id}writePartial update of an entity
    DELETE/api/{model}/{id}deleteDelete an entity

    Query Parameters

    ParameterDescriptionExample
    filter[field]=valueFilter by field equality?filter[status]=active
    filter.field=valueAlternative filter syntax?filter.status=active
    sort=field:orderSort by field?sort=createdAt:desc
    limit=NMaximum results?limit=10
    offset=NSkip N results?offset=20
    select=f1,f2Return only these fields?select=name,email
    populate=ref1,ref2Populate 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

    CodeMeaning
    200Success
    204Success (OPTIONS preflight)
    400Bad request (missing ID for update/delete)
    401Authentication required or invalid
    403ACL denied
    404Not found
    405Method not allowed
    409Conflict (unique constraint, append-only violation)
    422Validation failed
    500Internal 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;
    }