Skip to content

Route Scopes

The scope() function groups routes under a shared path prefix, prepends shared policies, and merges metadata — eliminating repetition when multiple routes share the same base path or middleware stack. It returns a flat RouteConfig[] that spreads directly into GatewayConfig.routes.

import { scope } from "@homegrower-club/stoma";
FieldTypeDescription
prefixstringPath prefix prepended to every child route (e.g. "/api/v1"). Normalized automatically — leading / is added if missing, trailing / is stripped.
policiesPolicy[]Policies prepended to every child route’s pipeline policies. Optional.
routesRouteConfig[]Child routes to scope.
metadataRecord<string, unknown>Metadata merged into every child route. Child values win on conflict. Optional.

scope() accepts a TBindings generic that propagates to child routes:

scope<MyEnv>({ prefix: "/api", routes: [...] })

Group versioned API routes under a shared prefix with JWT authentication:

import { createGateway, scope, jwtAuth, rateLimit } from "@homegrower-club/stoma";
const gateway = createGateway({
routes: [
...scope({
prefix: "/api/v1",
policies: [
jwtAuth({ jwksUrl: "https://auth.example.com/.well-known/jwks.json" }),
rateLimit({ max: 200, windowSeconds: 60 }),
],
routes: [
{
path: "/users",
pipeline: {
upstream: { type: "url", target: "https://users.internal" },
},
},
{
path: "/orders",
pipeline: {
upstream: { type: "url", target: "https://orders.internal" },
},
},
],
}),
],
});

This produces two routes: /api/v1/users and /api/v1/orders, both with jwtAuth and rateLimit in their pipelines.

Scope policies are prepended to each child route’s existing policies. They are not deduplicated by name — both the scope policy and any route-level policy with the same name will appear in the final pipeline array.

const routes = scope({
prefix: "/api",
policies: [cors(), rateLimit({ max: 100 })],
routes: [
{
path: "/users",
pipeline: {
policies: [requestValidation({ validate: myValidator })],
upstream: { type: "url", target: "https://users.internal" },
},
},
],
});
// routes[0].pipeline.policies is:
// [cors(), rateLimit({ max: 100 }), requestValidation({ ... })]

The resulting array order is: scope policies first, then route policies. When this array reaches createGateway(), the gateway merges it with global policies using the standard deduplicate-by-name, sort-by-priority algorithm.

Scope metadata is shallow-merged into each child route. When both the scope and a child route define the same key, the child value wins:

const routes = scope({
prefix: "/api",
metadata: { team: "platform", version: "v1" },
routes: [
{
path: "/users",
metadata: { team: "users", owner: "alice" },
pipeline: {
upstream: { type: "url", target: "https://users.internal" },
},
},
],
});
// routes[0].metadata is:
// { team: "users", version: "v1", owner: "alice" }

Scopes nest naturally — pass the output of an inner scope() as the routes of an outer scope(). Prefixes accumulate, policies accumulate (outer prepends before inner), and metadata accumulates (innermost wins):

const v1Routes = scope({
prefix: "/v1",
policies: [jwtAuth({ secret: env.JWT_SECRET })],
metadata: { version: "v1" },
routes: [
{
path: "/users",
pipeline: {
upstream: { type: "url", target: "https://users.internal" },
},
},
{
path: "/orders",
pipeline: {
upstream: { type: "url", target: "https://orders.internal" },
},
},
],
});
const apiRoutes = scope({
prefix: "/api",
policies: [cors(), requestLog()],
metadata: { team: "platform" },
routes: v1Routes,
});
// apiRoutes[0].path === "/api/v1/users"
// apiRoutes[0].policies === [cors(), requestLog(), jwtAuth(...)]
// apiRoutes[0].metadata === { team: "platform", version: "v1" }

Three or more levels work the same way — each scope() call produces a flat RouteConfig[] that the next level consumes.

Scoped routes spread alongside non-scoped routes in the routes array:

// Route scopes: group routes under shared path prefixes and policies
// using scope(). Eliminates repetition when multiple routes share
// the same base path or middleware stack.
// Demo API: https://stoma.opensource.homegrower.club/demo-api

import { createGateway, scope, health, jwtAuth, cors } from "@homegrower-club/stoma";

// Scoped routes share a prefix and JWT auth policy
const apiRoutes = scope({
  prefix: "/api/v1",
  policies: [jwtAuth({ secret: "my-jwt-secret" })],
  routes: [
    {
      path: "/users/*",
      pipeline: {
        upstream: { type: "url", target: "https://stoma.opensource.homegrower.club/demo-api" },
      },
    },
    {
      path: "/projects/*",
      pipeline: {
        upstream: { type: "url", target: "https://stoma.opensource.homegrower.club/demo-api" },
      },
    },
  ],
});

const gateway = createGateway({
  name: "my-api",
  policies: [cors()],
  routes: [
    // Health check lives outside any scope
    health({ path: "/health" }),
    // Scoped routes at /api/v1/users/* and /api/v1/projects/*
    ...apiRoutes,
  ],
});

export default gateway;
Open in Editor

The health check lives at /health outside any scope. The API routes live at /api/v1/users/* and /api/v1/projects/* with JWT auth from the scope and CORS from the global policies.