@peerpage/gateway-node
v1.8.0
Published
Peerpage resident gateway for Node/TypeScript backends — an outbound WebSocket to the platform, a local allow-list dispatcher, and the same-origin identity-token route. Framework-agnostic core + thin Express and Fetch (Hono/Bun/Deno/Workers/Next) adapters
Downloads
101
Maintainers
Readme
@peerpage/gateway-node
Trust model — read this first. This package runs inside your own Node/TypeScript backend. It opens an outbound WebSocket to the Peerpage platform and authenticates once with your tenant key; nothing executable ever crosses the socket — only invocation envelopes (a function name + params), which are matched against a local allow-list you defined in your own repo (the registrations you pass in). Unknown function → rejected. Bad params → rejected. The only SQL it ever issues is GRANT SELECT (never a data read or write) to the read-only login peerpage_api_read_only, derived solely from the committed peerpage/reads/*.json files in your repo — never from anything on the socket — and run through a DB connection you hand it (createDbConnection); omit that option and it touches no database at all. Its only inbound HTTP surface is the same-origin identity-token route. One runtime dependency (ws). It's a few hundred lines and meant to be read line by line — it is the trust surface a skeptical reviewer should audit. The TypeScript source ships in the package (node_modules/@peerpage/gateway-node/src), so you can read exactly what runs without leaving your own tree.
This is the Node/TypeScript flavor of the Peerpage gateway. It is behaviorally identical to @peerpage/gateway-dotnet — both pass the same language-agnostic conformance suite in packages/protocol/conformance (that's what keeps flavors interchangeable).
Install
npm add @peerpage/gateway-nodePick your adapter
The gateway itself — the outbound socket, the allow-list dispatcher, the read-grant reconciler — has no web framework in it. Only the inbound /peerpage/token route touches your framework, so that part ships as two thin adapters over one shared implementation:
| Your backend | Import | You get |
| --- | --- | --- |
| Express, NestJS | @peerpage/gateway-node/express | { router, gateway } — app.use(router) |
| Hono, Bun, Deno, Cloudflare Workers, Next route handlers, Elysia, SvelteKit | @peerpage/gateway-node/fetch | { handle, tokenPath, gateway } — mount handle yourself |
| Fastify's own API, Koa, hapi, anything else | @peerpage/gateway-node | startGateway(options) + createIdentityRoute(...) — mount the route your way |
express is an optional peer dependency: only the Express adapter needs it. The Fetch adapter adds nothing at all (Node 18+ has Request/Response globally).
Wire it up (Express)
import { peerpageGateway } from "@peerpage/gateway-node/express";
import { registrations } from "./peerpage/index"; // THE MENU — your explicit allow-list
const { router, gateway } = peerpageGateway({
platformUrl: process.env.PEERPAGE_PLATFORM_URL!, // wss://hub.getpeerpage.com
tenantId: process.env.PEERPAGE_TENANT_ID!,
tenantKey: process.env.PEERPAGE_TENANT_KEY!, // from env — never hard-coded
registrations,
getUser: (req) => req.user && { id: req.user.id, roles: req.user.roles }, // your auth → Peerpage user
// Makes approved reads go LIVE on deploy: at boot the gateway reads peerpage/reads/*.json and
// applies the matching GRANT SELECT to peerpage_api_read_only, via a connection to YOUR database.
// Return a client that can GRANT — your app's own pool works. Omit it and approved reads keep
// showing sample data (Peerpage holds no DB connection of its own).
createDbConnection: async () => appPool.connect(),
});
app.use(router); // mounts the same-origin POST/GET /peerpage/token route
// on shutdown: await gateway.stop();createDbConnection returns anything with a query(sql, params) → { rows } method — pg.Pool, pg.Client, or a pooled client from pool.connect() (whichever of .release()/.end() it has is called afterwards, so a pooled client is returned to the pool, not closed). The gateway imports no database driver itself — you own it.
Wire it up (Hono, and everything else that speaks Fetch)
Identical options — only the mounting differs. getUser receives your framework's own handler context, so you read the signed-in user exactly the way the rest of your app does:
import { peerpageGateway } from "@peerpage/gateway-node/fetch";
import type { Context } from "hono";
import { registrations } from "./peerpage/index";
const { handle, tokenPath, gateway } = peerpageGateway({
platformUrl: process.env.PEERPAGE_PLATFORM_URL!,
tenantId: process.env.PEERPAGE_TENANT_ID!,
tenantKey: process.env.PEERPAGE_TENANT_KEY!,
registrations,
getUser: (c: Context) => {
const u = c.get("user"); // however YOUR auth middleware exposes it
return u && { id: u.id, roles: u.roles };
},
createDbConnection: async () => appPool.connect(),
});
app.all(tokenPath, (c) => handle(c, c.req.raw)); // GET + POST + OPTIONS
// on shutdown: await gateway.stop();Mount it with all (or register GET, POST and OPTIONS): the web embed GETs it same-origin, and mobile SSO POSTs the OAuth code cross-origin behind a CORS preflight.
Where the handler context is the request — Bun, Deno, Workers, Next route handlers — the second argument is redundant:
const { handle, tokenPath } = peerpageGateway({ …, getUser: (req: Request) => userFromCookie(req) });
Bun.serve({ fetch: (req) => (new URL(req.url).pathname === tokenPath ? handle(req) : app(req)) });Wire it up (any other framework)
startGateway(options) is the whole framework-agnostic half — the socket plus the read-grant reconciler, started. Pair it with createIdentityRoute(credentials, options), which maps a plain { ctx, method, origin, body } to a plain { status, headers, body }; translate that into your framework's response object and you have exactly what the two adapters do:
import { startGateway, createIdentityRoute } from "@peerpage/gateway-node";
export const gateway = startGateway({ platformUrl, tenantId, tenantKey, registrations, createDbConnection });
const route = createIdentityRoute({ tenantId, tenantKey }, { getUser: (ctx: MyCtx) => … });Use startGateway rather than new PeerpageGateway(options): it always wires the read-grant reconciler, which is what reports grantCapable: false upstream when no DB connection is configured. Without it an approved read sits silently on "Awaiting deploy" with nothing to tell you why.
Registrations — one file = one capability
// peerpage/registrations/orders/flagOrder.ts
import { defineFunction, p } from "@peerpage/gateway-node";
import { OrdersService } from "../../../services/orders.ts"; // call your OWN code, never reimplement
export const flagOrder = defineFunction({
name: "orders.flagOrder", // namespaced <domain>.<verb>
description: "Flag an order for review",
roles: ["admin", "superadmin"], // always explicit
params: p.object({ orderId: p.number({ int: true }), reason: p.string({ max: 500 }) }),
handle: (p, ctx) => new OrdersService().flagOrder(p.orderId, p.reason, ctx.user.id),
});params is the built-in dependency-free validator (p.object), but any zod-compatible { parse } works, so it fits your house style. peerpage/index.ts is the menu — an explicit array of every registration (no scanning).
Reads (the peerpage/reads/ folder). Each approved read is one peerpage/reads/<table>.json file — the folder IS the access-control list. When you wire createDbConnection, the gateway reconciles those files against the DB at boot: it GRANT SELECTs the listed columns to peerpage_api_read_only and revokes any column no longer listed (a deleted file revokes its whole table). Idempotent, derived only from the committed files, and it's the gateway's only DDL. If your app's DB principal can't GRANT, it logs the honest waiting state and a DBA applies the grants instead.
Deploying behind a reverse proxy
The gateway mounts /peerpage/token on your own backend, same origin as your app. If a reverse
proxy (nginx, Traefik, an ALB) fronts your app, add an explicit rule forwarding /peerpage/ to the
service that runs the gateway — otherwise the request falls through to your static/SPA handler and
returns 404/405, and the embed can never obtain a token. nginx:
location /peerpage/ {
proxy_pass http://your-api-upstream; # the service running the gateway
proxy_set_header Host $host;
proxy_set_header Authorization $http_authorization; # forward the caller's bearer token, if any
}Not for you if…
…your backend is .NET/C# — use @peerpage/gateway-dotnet. SQL Server behind a Node host isn't implemented yet (dialect: "postgres" is the supported engine); MSSQL customers run the .NET gateway.
