# Composition & network

> Use `ipRestriction` for network policy, `rateLimit({ groupId })` for a shared quota, and `internal: true` for routes reachable only through `app.inject()`.

Daloy ships the composition & network slice of the secure-by-default initiative: four primitives that compose the security stack you already have. Every item is opt-in; no existing behaviour changes unless you call the new helper.

## 1. `rateLimit({ groupId })` shared buckets

Every `rateLimit()` call that declares the same `groupId` shares one in-memory bucket. Use it to enforce a combined limit across related routes (e.g. login, OTP, password reset) without juggling a shared store yourself.

```ts
import { App, rateLimit } from "@daloyjs/core";

const app = new App({ env: "production" });
const authLimit = () =>
  rateLimit({ windowMs: 60_000, max: 10, groupId: "auth" });

app.post("/login",          { hooks: authLimit(), ... });
app.post("/login/otp",      { hooks: authLimit(), ... });
app.post("/password-reset", { hooks: authLimit(), ... });
// All three endpoints spend from the same bucket per IP.
```

When you supply a custom `store`, Daloy still prefixes the derived key with ``${groupId}:`` so two groups cannot collide in a shared Redis backend either.

## 2. `combine` primitives, `every` / `some` / `except`

Declarative composition for your `Hooks` bundles. Use them to package curated security stacks as a single value and drop the fragile `if (...) await next()` chains.

```ts
import {
  App,
  every, some, except,
  requestId, bearerAuth, rateLimit,
} from "@daloyjs/core";

const adminStack = every(
  requestId(),
  bearerAuth({ validate: (t) => t === process.env.ADMIN_TOKEN }),
  rateLimit({ windowMs: 60_000, max: 30, groupId: "admin" }),
);

const app = new App();

// Mount one curated bundle:
app.use(adminStack);

// "Auth except the public endpoints":
app.use(except(
  ["/health", "/openapi.json", "/docs/**"],
  bearerAuth({ validate: (t) => t === process.env.API_TOKEN }),
));

// "Any one of these proofs of identity is enough":
app.use(some(
  bearerAuth({ validate: (t) => t === process.env.PUBLIC_API_TOKEN }),
  // session-cookie middleware, API-key middleware, ...
));
```

- `every(...layers)` runs every bundle in order across every lifecycle phase. Forwards CORS / CSRF / session security markers so boot-time guards still see them on the composed bundle.
- `some(...layers)` runs each layer's auth gate in order until one passes. When every candidate uses `preBody` (as the built-in `bearerAuth()`, `basicAuth()`, `jwk()`, and `clientCertAuth()` now do), selection happens before body I/O; mixed stacks defer to `beforeHandle`. A returned `Response` is treated as a denial, the next layer gets a turn. The first failure wins when every layer rejects, so place the auth scheme whose `WWW-Authenticate` challenge you want clients to see first.
- `except(when, hooks)` skips the wrapped bundle's `preBody` and `beforeHandle` gates for matching paths (`/health`, `/public/**`, `/v1/*/meta`) or for any request where the supplied predicate returns `true`. Its `onRequest`, `afterHandle`, `onSend`, and `onResponse` phases still run, so shared concerns wired through those phases keep working.

> **Perimeter guards run on unmatched requests too.** `beforeHandle` guards registered with `app.use(...)`, such as `rateLimit()`, `ipRestriction()`, `csrf()`, and your own WAF bundles, also run on the cold dispatch path: a request that matches no route (`404`), hits a registered path with the wrong method and gets `405`, or arrives as an `OPTIONS` preflight. Without this, an attacker could flood random paths to slip past a `rateLimit()` that an operator reasonably expects to cover every request. Two consequences worth knowing:

- A flood of `404`s is now throttled / IP-fenced just like traffic to a real route.
- When auth is installed globally (e.g. `app.use(except(exempt, bearerAuth(...)))`), an unauthenticated request to an *unmatched* path is rejected with `401` rather than `404`. This is intentional route-enumeration resistance: the perimeter answers the same way whether or not the route exists. Per-route hooks are unaffected, and no handler is ever reached. Need public unknown paths to stay plain `404`s instead? Avoid global `app.use()` auth for that surface; use route/group-scoped hooks for auth and reserve `App({ hooks })` `onRequest` for checks that should truly run before routing.

## 3. `ipRestriction()`: CIDR allow / deny

Block or allow requests by source IP or CIDR range. Pairs naturally with `trustProxyHeaders: true` behind a trusted proxy so the matched address is the real client, not your load balancer. Supports IPv4, IPv6, and IPv4-mapped IPv6 (`::ffff:a.b.c.d`). `deny` always wins over `allow`.

```ts
import { App, ipRestriction } from "@daloyjs/core";

const app = new App({ env: "production", trustProxy: true });

app.use(ipRestriction({
  allow: ["10.0.0.0/8", "192.168.1.0/24", "::1"],
  deny:  ["10.6.6.0/24"],
  trustProxyHeaders: true,
}));

// Rejected requests:
//   HTTP/1.1 403 Forbidden
//   content-type: application/problem+json
//   { "title": "Forbidden", "detail": "IP address not permitted" }
```

Invalid IP literals, invalid CIDR prefixes, and calls with neither an `allow` nor `deny` list throw at construction time, catching bugs that would otherwise hide until production traffic hits. By default the helper fails closed because Web-standard requests do not expose the peer address. Supply `resolveIp` if your adapter exposes connection metadata or if you sit behind a CDN that sends the real client through a custom header such as `cf-connecting-ip` or `true-client-ip`.

## 4. `internal: true` + `app.inject()`

Mark a route as `internal: true` and the public `app.fetch(...)` entry point returns `404`: existence cannot be probed. The same route runs normally through `app.inject(request)`, which is meant for cron jobs, admin scripts, and integration tests. Internal routes are also excluded from generated OpenAPI by default; pass `includeInternal: true` to `generateOpenAPI()` (imported from the `@daloyjs/core/openapi` subpath) for private admin SDK generation. The framework also filters `Allow` headers so a probe with a different method stays a clean `404` rather than a leaky `405`.

**Diagram: Two entry points, one internal route**

- **internal: true route** (source, route) - /__admin/reindex
- **app.fetch()** (public) - 404, existence cannot be probed
- **app.inject()** (in-process) - runs normally, 204

The public adapter returns 404 for an internal route so its existence stays hidden, while cron jobs, admin scripts, and tests reach it through app.inject(). Internal routes are also excluded from generated OpenAPI by default.

```ts
import { App } from "@daloyjs/core";
import { generateOpenAPI } from "@daloyjs/core/openapi";

const app = new App();

app.post(
  "/__admin/reindex",
  {
    internal: true,
    responses: { 204: { description: "Started" } },
  },
  () => ({ status: 204 }),
);

// Public adapter - 404
await app.fetch(new Request("http://app/__admin/reindex", { method: "POST" }));

// In-process / cron / tests - 204
await app.inject(new Request("http://app/__admin/reindex", { method: "POST" }));

// Public spec - internal routes omitted
const publicSpec = generateOpenAPI(app, {
  info: { title: "My API", version: "1.0.0" },
});

// Private admin SDK generation - internal routes included
const adminSpec = generateOpenAPI(app, {
  info: { title: "My API", version: "1.0.0" },
  includeInternal: true,
});
```

## Opt-out

Every primitive in this slice is additive; nothing changes unless you call the helper. The earlier secure-defaults master opt-out flag still applies if you ever need to disable secure defaults in a development sandbox:

```ts
const app = new App({ env: "development", secureDefaults: false });
```

---

Source: https://daloyjs.dev/docs/security/composition-network