@b1-road/node-core
v0.1.0-alpha.1
Published
Framework-agnostic core for the Eduzz Plat Node BFF — OIDC login, session + token store, streaming proxy, permission primitives. Install a driver (@b1-road/express, @b1-road/nestjs) instead of this.
Readme
@b1-road/node-core
The framework-agnostic Eduzz Plat BFF. You probably want a driver, not this package.
| If your server is… | Install |
| --- | --- |
| Express | @b1-road/express |
| NestJS | @b1-road/nestjs |
| something else | this package — see Writing a driver |
This package exists so those drivers cannot drift. PKCE, the state
round-trip, refresh-with-margin, logout revocation, the proxy's decision chain,
the signed CSRF double-submit, the webhook HMAC and the permission algebra are
implemented once, here. A fix lands in one place and every driver inherits
it; a new framework becomes an adapter rather than a fork.
Status: alpha pre-release, versioned with the drivers that consume it.
The shape of it
┌────────────────────────┐
@b1-road/express │ │ @b1-road/nestjs
─────────────────┤ @b1-road/node-core ├─────────────────
routing only │ │ DI + decorators
│ OIDC · session · proxy│ only
│ CSRF · webhooks · IAM │
└───────────┬────────────┘
│
@b1-road/types
(the wire contract)A driver does two things: turn its framework's request/response into the structural shapes below, and decide which of its routes are protected. It does not re-implement any part of the BFF.
The framework boundary
Everything here is written against two structural types, not against any framework's:
interface RoadRequest {
method: string;
url?: string;
originalUrl?: string; // set by frameworks that keep a pre-mount copy
headers: Record<string, string | string[] | undefined>;
params?: Record<string, string | undefined>;
query?: Record<string, unknown>;
body?: unknown;
rawBody?: Buffer | string;
readableEnded?: boolean;
}
interface RoadResponse {
statusCode: number;
headersSent: boolean;
setHeader(name, value): unknown;
getHeader(name): unknown;
write(chunk): boolean;
end(chunk?): unknown;
on(event, listener): unknown;
}They are deliberately the intersection of Node's http.IncomingMessage /
http.ServerResponse and the few extras every Node web framework populates. An
Express Request is one of these with fields added; Fastify's
request.raw / reply.raw are the same objects underneath. So an adapter is a
cast, not a conversion.
Two consequences worth knowing:
- Nothing here calls
res.status(...).json(...). That is Express sugar; the core writes throughwriteHead/end, which every Node server speaks. UsesendJson/sendHtml/sendEmpty/redirectfrom this package. - If a type would force a framework import, it belongs in
http/http.tsas a structural shape instead.
Writing a driver
import {
createRoadRuntime,
authenticate,
authorizeRequest,
handleProxy,
runWithAuthContext,
requestIdOf,
splitTarget,
stripPrefix,
AUTH_ROUTE_PREFIX,
} from '@b1-road/node-core';
// 1. Wire it once, at mount time. This is also where the boot-time safety
// guards fire, so a misconfigured app fails here rather than on a user's
// first login.
const runtime = createRoadRuntime(options, {
context: {
label: 'myDriver()', // prefixes every boot failure
dotenvHint: 'Load your env before …', // framework-specific next step
},
logger,
});runtime carries { options, store, oidc, pkce, sessions, flow, client,
health, logger, fetcher, warm(), close() }.
// 2. Mount the four auth routes. Their paths are a COMPATIBILITY CONTRACT:
// the redirect URI registered with the Auth Server names the callback
// literally, so renaming them breaks login in a way that looks like a Plat
// outage and isn't. `AUTH_ROUTE_PREFIX` is 'auth/road'.
// GET {prefix}/login -> runtime.flow.login(req, res)
// GET {prefix}/callback -> runtime.flow.callback(req, res)
// POST {prefix}/logout -> runtime.flow.logout(req, res)
// GET {prefix}/logout -> runtime.flow.logoutRedirect(req, res)
//
// `callback` throws on a genuine dead end. Render it with
// `renderRoadError(req, res, err, { retryHref: runtime.flow.loginUrl() })`
// so a browser gets a page and a fetch client gets the JSON contract.
// 3. Mount the proxy.
const [path, queryString] = splitTarget(req.url ?? '/');
const subPath = stripPrefix(path, runtime.options.proxy.prefix);
if (subPath !== null) {
const outcome = await handleProxy(
{ options: runtime.options, sessions: runtime.sessions, fetcher: runtime.fetcher },
{ req, res, subPath, queryString, requestId: requestIdOf(req) },
);
if (outcome === 'handled') return; // 'passthrough' = the proxy is disabled
}
// 4. Authenticate a protected route, and ENTER THE ASYNC CONTEXT around
// whatever runs next. This is what lets `auth()` work with no argument in
// the integrator's handler — the property that makes handler code
// identical across drivers.
const ctx = await authenticate(
{ sessions: runtime.sessions, client: runtime.client, logger: runtime.logger },
req, res,
);
runWithAuthContext(ctx, () => next());
// 5. Authorize, when the route declares a permission.
await authorizeRequest(
{
client: runtime.client,
scopeResolvers: runtime.options.scopeResolvers,
logger,
// Required for `{ from: 'platform' }`. Without it the check throws
// scope_resolution_failed even when ROAD_PLATFORM_ID is set, because
// authorizeRequest reads this field, not the runtime options.
platformId: runtime.options.platformId,
},
ctx, req,
{ action: 'read', subject: 'Member', in: 'buId' },
);in accepts a route-param name, a structured { from: 'body' | 'header' |
'query' | 'param' } source, a resolver function, or
{ from: 'platform', buParam } — which resolves the platform subscription's
scope rather than the business unit's. That last one matters: a platform's
permission template and roles are registered on the subscription scope, and
grant collection walks up the scope tree and never down, so authorizing one of
your own subjects against the business unit denies every caller who is not a BU
owner. Pass platformId in the options (or ROAD_PLATFORM_ID) and the checks
never repeat it.
Things a driver must get right, because the core cannot do them for you:
- Enter the ALS frame. Call
next()from insiderunWithAuthContext, orauth()is empty in the handler. - Answer, don't rethrow, an auth failure — or at least make sure a
RoadAuthnErrorbecomes a401carryingWWW-Authenticate: Session. The@b1-road/reactclient keys itsonUnauthenticatedcallback off that header. - Gate the decision trace on both halves.
includeTracemust beruntime.options.debugHeader && isDebugRequested(req). Passing only the first leaks the full authorization decision on every 403 in any non-prod deployment. - Await async middleware. Express 4 ignores a returned rejected promise; wrap and forward it yourself.
- Call
runtime.close()on shutdown so the token store lets go.
Test mode is shared too — @b1-road/node-core/testing exports roadScenario()
plus the fakes (buildTestNormalizedOptions, seedTokenStoreFromScenario,
makeFakeOidcHandler) a driver's own roadTest() composes. A scenario written
against one driver is the same object in another.
What lives here
| Area | Exports |
| --- | --- |
| Assembly | createRoadRuntime, RoadRuntime |
| Config + boot guards | normalize, NormalizeContext, RoadCoreOptions, DEFAULT_ALLOW |
| HTTP boundary | RoadRequest, RoadResponse, sendJson, redirect, header, query, isDebugRequested |
| OIDC | OidcFlow, OidcClient, PkceCookie, safeReturnTo, AUTH_ROUTE_PREFIX |
| Session | SessionService, sealSessionId, MemoryTokenStore, RedisTokenStore, RoadTokenStore |
| Proxy | handleProxy, stripPrefix, isAllowed, isCsrfValid, isCsrfTokenBoundToSession, deriveCsrfToken |
| Authn / authz | authenticate, authorizeRequest, auth, can, runWithAuthContext |
| Client | RoadClient and every resource shape it returns |
| Errors | the Road*Error hierarchy, renderRoadError, errorBody |
| Webhooks | verifyWebhook, webhookVerdictStatus |
| Health | RoadHealthChecker, roadHealthUrl |
| Bridge | bridgeEnforce (provider side, fail-closed), ROAD_BRIDGE_CONTEXT (the verified grant on the request), and road.bridge.tokenExchange (consumer side, RFC 8693) |
Security properties this package owns
Reviewers and driver authors should know which invariants live here, because a driver cannot weaken them and must not duplicate them:
returnTocannot leave the origin. Validated when sealed and again when used, rejecting//,\, and every control character.- The proxy allowlist is a boundary. A path containing a
.or..segment — in any encoding a URL parser would decode — is rejected before any session lookup, so it cannot normalise out of/api/<version>/. - CSRF is signed double-submit. The token is an HMAC of the session id under the session secret, and the proxy verifies it was issued for this session — not merely that the cookie and header agree.
- Webhooks are fail-closed and verified over the raw bytes, in constant time, inside a 5-minute replay window.
- Refresh is single-flight per session, so concurrent requests never spend a rotating refresh token twice.
- The proxy never forwards the browser's cookies upstream, and never
returns the API's
Set-Cookiedownstream.
License
MIT
