@leumas/security
How every Leumas backend defends itself, declared once — the programmable security policy (session · cors · rate limits · body limits · http) plus the middleware that enforces it, mounted in the one...
@leumas/security
How every Leumas backend defends itself, declared once: one policy file, one applySecurity() call.
Before this package the same stack was hand-rolled per service and the copies had drifted — http://localhost:5173 was the CORS default in five separate files, express.json({limit:'2mb'}) appeared in two servers, and the two backends that terminate HTTP alongside the API (the Imperium host and the compute worker) shipped no security headers at all. Meanwhile the API constructed a fresh body parser on every request and never swept a single rate-limit bucket.
Use it
import express from 'express';
import { applySecurity, applyServerTimeouts, staticCacheOptions } from '@leumas/security';
const app = express();
const security = applySecurity(app, { json: express.json, servesSpa: Boolean(dist) });
mountYourAuth(app); // between the stack and the backstop — see below
app.get('/health', …); // before the backstop, so a probe is never throttled
security.mountBackstop();
app.use('/leviathan', security.lanes.llm, leviathanRouter); // a lane is an array; express takes it
applyServerTimeouts(server); // on the http.Server, not the app
app.use(express.static(dist, { index: false, ...staticCacheOptions() }));
json is injected rather than imported, so this package never depends on express — and injecting it is also what lets the body parsers be built once, at boot, instead of per request.
Order is the point
Every service that hand-rolled this got a different order, and the order is where the bugs are, not the individual options. applySecurity mounts:
x-powered-byoff- helmet — headers must be set before anything can answer, including a 429
- compression — must wrap
res.writebefore a route writes, and must skip SSE - CORS, public lane then credentialed lane
- body parsers — after CORS, so a rejected preflight never reads a body
- (the caller mounts auth here)
- the per-IP backstop — last, after auth, and after
/health
The caller mounts auth between 5 and 7 because only the caller knows what auth means for it: the API has sessions, the compute worker has a shared key, the Imperium host has neither.
The three traps this encodes
Each of these has already cost this repo a live defect. They are handled here so no service has to rediscover them.
CORS cannot be one layer. cors() emits Access-Control-Allow-Credentials: true unconditionally — even for an origin it does not allow. So the public-catalog paths cannot simply join the allowlist; they need their own un-credentialed wildcard layer mounted first, and the credentialed layer has to skip those same paths. When both ran, responses carried Allow-Origin: and Allow-Credentials: true — a pair browsers reject — and the catalog silently stopped being readable cross-origin, which is the one thing the wildcard existed for. '' inside origins is handled as the literal wildcard, because cors() otherwise compares it for exact equality and matches nothing.
Compression buffers SSE. compression's default filter asks the compressible package, which answers true for text/event-stream because it matches text/*. Compressing a live stream buffers it: every feed in the product — automation, surveillance detections, release build logs, the serial monitor — appears to hang and then arrive all at once. It looks like a network fault, not a setting. Excluded unconditionally, along with bodies that already set their own Content-Encoding.
Some routes must not be parsed at all. A null in body.byPrefix means skip — Stripe verifies its signature over the unmodified bytes, and a parser anywhere above the webhook breaks every one of them with an error that reads like a key mismatch.
The policy
Defaults → repo ops/config/security.json → <dataRoot>/security.json → LEUMAS_SECURITY_CONFIG → env. Cached ~5s, so an edit applies with no restart; a parse error keeps the last good value rather than failing open. Five sections: session, cors, limits, body, http.
Nothing in it is a credential. Cookie name, cookie domain and JWT_SECRET stay in env, because they are deployment identity rather than policy — and this file is checked into the repo and editable from Studio. smoke-security-stack.mjs asserts that boundary rather than trusting it.
Verify
pnpm smoke:security # access + integrity + stack
pnpm smoke:security-stack # headers, the CORS pair, SSE-safe compression, per-prefix bodies
pnpm smoke:session # the session half of the same policy file
pnpm check:routes # the mounted layer list, per roleWhat it exports
100 exports: 15 components, 0 hooks, 85 helpers. Import from @leumas/security.
BODY_DEFAULTSBODY_DEFAULTSCOMPACT_AFTER_MSCORS_DEFAULTSCORS_DEFAULTSCSRF_DEFAULTSCSRF_DEFAULTSDEFAULT_DAY_DAYSDEFAULT_HOUR_DAYSHTTP_DEFAULTSHTTP_DEFAULTSLIMIT_DEFAULTSLIMIT_DEFAULTSSESSION_DEFAULTSSESSION_DEFAULTSapplySecurityapplyServerTimeoutsbodyParserbodyParserbodyPolicybodyPolicybyUserThenIpbyUserThenIpclearRateLimitclearRateLimitclientIpclientIpcompactRequestRollupscompressionLayercompressionLayercorsLayerscorsLayerscorsPolicycorsPolicycreateRequestRollupcsrfOriginGuardcsrfOriginGuardcsrfPolicycsrfPolicydrainRollupenvPinnedFieldsenvPinnedFieldsenvPinnedSessionFieldsenvPinnedSessionFieldsfoldTailhistogramEdgeshttpPolicyhttpPolicyjsonCacheHeaderslimitsPolicylimitsPolicymemoryStorememoryStorenormalizeBodyPolicynormalizeBodyPolicynormalizeCorsPolicynormalizeCorsPolicynormalizeCsrfPolicynormalizeCsrfPolicynormalizeHttpPolicynormalizeHttpPolicynormalizeLimitsPolicynormalizeLimitsPolicynormalizeSessionPolicynormalizeSessionPolicynormalizeTrustProxynormalizeTrustProxyrateLanesrateLanesrateLimitrateLimitrecordRequestredepositRollupreloadSecurityConfigreloadSecurityConfigrequestStatsrequestTimingresetRequestStatsrunRequestRetentionsaveSecurityPolicysaveSecurityPolicysaveSessionPolicysaveSessionPolicysecurityConfigsecurityConfigsecurityConfigPathssecurityConfigPathssecurityHeaderssecurityHeaderssessionPolicysessionPolicysharedStoresharedStorestartSweeperstartSweeperstaticCacheOptionsstopPolicyRefreshstopPolicyRefreshstopSweeperstopSweeper