Guards & Pipes

Pipes run before your handler. They can validate, authenticate, log, transform, or abort requests. Guards are a type of pipe focused on access control.

How Pipes Work

Each pipe is a pure function with the same signature as a handler:

// A pipe can:
(request, services) => undefined       // continue (no-op)
(request, services) => ({ user })      // merge data into request
(request, services) => ({ after: fn }) // register a post-handler hook
(request, services) => { throw ... }   // abort with error
No next() — Unlike Express, pipes don't call next(). They return data to merge or throw to abort. Simpler, testable, composable.

Built-in Guards

auth

Extracts Bearer token from Authorization header, calls your auth verifier (defineAuth() or app.setAuth()), merges { user } into request.

routes: [
  ['GET', '/me', 'getProfile', ['auth']],
]

// Handler gets { user } from auth pipe:
export function getProfile(request) {
  const { user, send } = request
  send(user)
}

role:ROLE

Checks request.user.role against allowed roles. Requires auth pipe first.

['DELETE', '/:id', 'delete', ['auth', 'role:admin']]
['POST',   '/',    'create', ['auth', 'role:seller,admin']]  // multiple roles

rateLimit:N

Sliding-window rate limiter per IP. N = max requests per minute. Uses in-memory store by default.

['POST', '/login', 'login', ['rateLimit:10', 'dto:loginDto']]  // 10 req/min

Response headers — the rate limiter sets these on every response:

HeaderDescription
X-RateLimit-LimitConfigured max requests per window
X-RateLimit-RemainingHow many requests the client has left
Retry-AfterSeconds until the window resets (only on 429 responses)

For distributed deployments, plug in a custom store (e.g. Redis):

app.setRateLimitStore({
  async hit(key, windowMs) { /* increment and return count */ },
  async check(key) { /* return current count */ },
})

cors / cors:ORIGIN

Sets CORS headers. Handles OPTIONS preflight automatically.

// Allow all
pipe: ['cors']

// Specific origin
pipe: ['cors:https://mysite.com']

Headers set by CORS:

HeaderValue
Access-Control-Allow-OriginReflected origin, specific origin, or *
Access-Control-Allow-MethodsGET,POST,PUT,PATCH,DELETE,OPTIONS
Access-Control-Allow-HeadersMirrored from client's Access-Control-Request-Headers, or Content-Type,Authorization
Access-Control-Allow-Credentialstrue — allows cookies and auth headers cross-origin
Access-Control-Max-Age86400 (24 hours) — browsers cache the preflight result
VaryOrigin (when reflecting origin, for proper caching)

logger

Logs request method, path, status code and timing. Uses after-hooks for accurate timing.

pipe: ['logger']
// Output: 14:30:05 GET /users → 200 (3ms)

compress / compress:ENCODING

Response compression via gzip, brotli, or deflate. Auto-selects best encoding from Accept-Encoding. Responses smaller than 150 bytes are not compressed (overhead would exceed savings).

// Auto-select best encoding
pipe: ['compress']

// Force brotli
pipe: ['compress:br']

// Force gzip
pipe: ['compress:gzip']

security / security:strict

Sets common security headers. In strict mode, adds a restrictive Content-Security-Policy and Permissions-Policy.

// Basic security headers
pipe: ['security']

// Strict mode (adds CSP, Permissions-Policy, COOP, CORP)
pipe: ['security:strict']

Headers set by security:

Additional headers in strict mode:

dto:NAME

Validates request.body against a named DTO schema defined in a *.dto.js file. If validation fails, throws a ValidationError (400). See DTO Validation for schema syntax.

routes: [
  ['POST', '/register', 'register', ['dto:registerDto']],
  ['POST', '/login',    'login',    ['dto:loginDto']],
]

Custom Guards

Global Custom Guard (defineGuard)

import { defineGuard } from 'SpaceNode'

defineGuard('premium', () => (request) => {
  if (!request.user?.isPremium) {
    throw new HttpError(403, 'Premium subscription required')
  }
})

// Use: ['GET', '/data', 'getData', ['auth', 'premium']]

Per-App Custom Guard (app.addGuard)

Register guards scoped to a specific app instance:

app.addGuard('tenant', (param) => (request) => {
  if (request.headers['x-tenant-id'] !== param) {
    throw new HttpError(403, 'Invalid tenant')
  }
})

// Use: ['GET', '/data', 'getData', ['tenant:acme']]

Guard Resolution Order

When resolving a guard name, SpaceNode checks in order:

  1. Built-in guards (auth, role, rateLimit, cors, logger, compress, security)
  2. Per-app guards (registered via app.addGuard())
  3. Global custom guards (registered via defineGuard())

Pipe Execution Order

// Global pipes (createApp config.pipe)
//   ↓
// Module pipes (module.js pipe: [])
//   ↓
// Route pipes (per-route array)
//   ↓
// Handler
//   ↓
// After-hooks (reverse order, LIFO)