@wip/proxy
@wip/proxy
What it is. @wip/proxy is an Express middleware library for server app devs that proxies calls from a browser-facing app to a WIP instance's REST APIs. It injects a server-held API key into every upstream request (so the key never reaches the browser), streams file-content downloads without exposing internal MinIO URLs to the client, and forwards WIP's own responses — including errors — verbatim. It also ships a small, separate runtime app-config endpoint helper for serving client-visible per-deployment values.
Install & import. Package @wip/proxy, version 0.4.2 (package.json). The package is "private": true — it is not published to a registry; it is consumed as a workspace dependency inside the WIP monorepo (libs/wip-proxy). Primary imports:
import { wipProxy } from '@wip/proxy'
import { appConfigHandler } from '@wip/proxy'
Requires express as a peer dependency (^4.17.0 || ^5.0.0) and Node >=18 (engines.node).
Core API
wipProxy(options: WipProxyOptions): Router
- Purpose. Creates an Express
Routerthat proxies WIP API calls (GET|POST|PUT|DELETE /api/{service}/*) and file-content downloads (GET /files/:fileId/content) to a WIP instance, injecting a server-held API key so the browser never sees it. - Parameters.
options: WipProxyOptions:baseUrl: string(required) — WIP instance base URL, e.g.https://localhost:8443.apiKey?: string— literal API key; provide this orapiKeyFile.apiKeyFile?: string— path to a file holding the key (typically the live wip-deploy secrets file); read once at construction; takes precedence overapiKeywhen both are set.bodyLimit?: string— max request body size; default'100mb'.extraHeaders?: Record<string, string>— additional headers forwarded upstream on every API call.forwardIdentity?: boolean— forwardX-WIP-User,X-WIP-Groups,X-WIP-Auth-Methodfrom the incoming request; defaultfalse.defaultNamespace?: string— namespace appended to the documents-query endpoint when the caller hasn't scoped it (see Gotchas).
- Returns. An Express
Router, synchronously. - Throws / errors. Synchronously, at construction — before any request is handled — via the internal key resolution:
Error('wipProxy: one of apiKey or apiKeyFile is required')when neither option is set;Error("wipProxy: apiKeyFile '<path>' is empty")when the file resolves to blank/whitespace; the raw Nodefserror ifapiKeyFiledoesn't exist or can't be read. - Example.
import express from 'express'
import { wipProxy } from '@wip/proxy'
const app = express()
app.use('/wip', wipProxy({
baseUrl: process.env.WIP_BASE_URL || 'https://localhost:8443',
apiKey: process.env.WIP_API_KEY,
}))
resolveApiKey(options: WipProxyOptions): string
- Purpose. Resolves the upstream API key from
apiKeyFileorapiKey— the same resolutionwipProxy()runs internally, exported for callers that need it standalone. - Parameters.
options: WipProxyOptions(onlyapiKey/apiKeyFileare read). - Returns. The resolved key as a
string, synchronously; when read fromapiKeyFile, the file content is trimmed. - Throws / errors.
Errorif neitherapiKeynorapiKeyFileis set;ErrorifapiKeyFileresolves to an empty/whitespace-only file; propagates the rawfserror if the file doesn't exist. - Example.
resolveApiKey({ baseUrl: 'x', apiKeyFile: '/run/secrets/api-key' })
// -> 'live-secret-key' (trailing newline trimmed)
prefixPattern(prefix: string): RegExp
- Purpose. Builds the anchored
RegExpthatwipProxy()uses to register each proxied service prefix — matches the bare prefix and any subpath, but not a sibling path that merely starts with the same string (e.g./api/def-store-evil). Exported for route-matching tests. - Parameters.
prefix: string— a service path prefix, e.g./api/def-store. - Returns. A
RegExp, synchronously; regex metacharacters inprefixare escaped first. - Throws / errors. None.
- Example.
prefixPattern('/api/def-store').test('/api/def-store/terminologies') // -> true
prefixPattern('/api/def-store').test('/api/def-store-evil') // -> false
WIP_API_PREFIXES: string[]
- Purpose. The exported list of upstream service prefixes
wipProxy()registers a route for. Not a function — an exported constant. - Value.
['/api/registry', '/api/def-store', '/api/template-store', '/api/document-store', '/api/reporting-sync', '/api/ingest-gateway']. - Example.
import { WIP_API_PREFIXES } from '@wip/proxy'
// e.g. to build an equivalent allowlist for a test outside the proxy itself
appConfigHandler(entries: AppConfigEntries | (() => AppConfigEntries)): (req: Request, res: Response) => void
- Purpose. Creates a plain Express handler that serves a runtime app-config JSON endpoint — the single source of truth for client-visible per-deployment values (namespace names, feature flags, display labels), fetched by the SPA at boot instead of baking
VITE_*values into the bundle at build time. - Parameters.
entries— either a plainAppConfigEntriesobject (captured once, at mount) or a() => AppConfigEntriesfunction (re-read on every request, for values that can change at runtime, e.g. after a config rotation).AppConfigEntriesisRecord<string, string | number | boolean | null | undefined>;undefinedvalues are dropped from the response,nullis served as an explicit "not configured". - Returns.
(req: Request, res: Response) => void— the handler itself runs synchronously; it setsCache-Control: no-storeand callsres.json(...)with exactly the passed entries — an allowlist by construction, nothing fromprocess.envis ever spread in. - Throws / errors. None directly; if a function
entriessource throws, that error propagates out of the handler. - Example.
router.get('/api/app-config', appConfigHandler({
namespace: process.env.WIP_NAMESPACE || null,
}))
// GET /api/app-config -> 200 {"namespace": "kb"} (Cache-Control: no-store)
Usage patterns
1. Mount the proxy and forward all API + file traffic.
import express from 'express'
import { wipProxy } from '@wip/proxy'
const app = express()
app.use('/wip', wipProxy({
baseUrl: process.env.WIP_BASE_URL || 'https://localhost:8443',
apiKey: process.env.WIP_API_KEY,
}))
This registers GET|POST|PUT|DELETE /wip/api/{service}/* (proxied with the API key injected) and GET /wip/files/:fileId/content (proxied file download, MinIO URLs resolved server-side). A frontend using @wip/client then points at baseUrl: '/wip' with auth: { type: 'none' }, since the proxy handles auth.
2. Scope a multi-namespace admin key.
app.use('/wip', wipProxy({
baseUrl,
apiKeyFile: '/path/to/secrets/api-key',
defaultNamespace: 'dev-my-app',
}))
Any unscoped call to /api/document-store/documents/query gets ?namespace=dev-my-app appended automatically; a caller that already passed namespace= in the query string is left untouched.
3. Serve runtime app-config alongside the proxy.
import { appConfigHandler } from '@wip/proxy'
router.get('/api/app-config', appConfigHandler({
namespace: process.env.WIP_NAMESPACE || null,
}))
The SPA fetches this once at boot instead of baking VITE_* values into the build; the response is never cached (Cache-Control: no-store) and contains only the entries explicitly passed in.
Gotchas
- Two Express majors, one peer range.
expressis a peer dependency spanning^4.17.0 || ^5.0.0. Route registration uses the anchoredprefixPattern()RegExpspecifically because Express 4 and 5 disagree on wildcard string syntax — a bare*throws at registration time on Express 5 (path-to-regexp8.x) but not on Express 4. The test suite runs the same route-matching tests against both majors via aliased devDependencies (express4,express5). apiKeyFilewins, and is read once. When bothapiKeyandapiKeyFileare set,apiKeyFiletakes precedence. It's read atwipProxy()construction time only — a key rotated on disk requires an app restart to take effect. A missing/empty key throws synchronously at construction, not as a 401 on the first request.defaultNamespacepatches exactly one endpoint. It's injected only onGET /api/document-store/documents/query— the one endpoint that both accepts anamespacequery param and silently returns zero rows without it under a multi-namespace (e.g. install-admin) key. It is deliberately not injected on file, write, or other service endpoints: those take namespace as a body/form field, or reject an unexpected query param outright.- Error forwarding is verbatim except at the proxy boundary. Any response WIP returns — including 4xx/5xx — is forwarded with its original status, headers, and body untouched. Only a failure to reach WIP at all (network error, connection refused) produces the proxy's own
502 {"error": "..."}JSON. A caller must distinguish "WIP rejected the request" from "the proxy couldn't reach WIP" by status/shape, not by one uniform error convention. - Request bodies are forwarded raw, scoped to the proxy's own router.
wipProxy()appliesexpress.raw({ type: '*/*', limit: bodyLimit })per-route inside its own router, not as an app-level parser — bodies are never JSON-parsed by the proxy. If the host app mounts a global body parser (e.g.express.json()) ahead of the proxy router, the request stream is already consumed before it reaches the proxy. Mount the proxy before any global body-parsing middleware. - Bulk-first passthrough. The proxy never parses or reshapes response bodies, so WIP's own bulk-first behavior on writes — every write endpoint accepts arrays and returns
200 OKwith per-item results, not one status for the whole batch — reaches the caller unchanged. Check each item's own result, not just the overall response status. - Exported option types are narrower than they look.
ApiProxyOptionsandFileProxyOptions(used internally by the request handlers) require an already-resolvedapiKey: string, unlikeWipProxyOptions(accepted bywipProxy()), which takes the optionalapiKey/apiKeyFilepair and resolves it once viaresolveApiKey(). The internal handlers themselves (handleApiProxy,handleFileContent) are not exported — only their option types are, for typing advanced call sites. - Node
>=18required (engines.nodeinpackage.json).
See also
- Document-Store API, Registry API, Template-Store API, Def-Store API, Reporting & Analytics — the REST APIs this library proxies (
/api/document-store,/api/registry,/api/template-store,/api/def-store,/api/reporting-sync). - Auth & Access — the identity model behind the
X-API-Keyinjection and theforwardIdentityheaders (X-WIP-User,X-WIP-Groups,X-WIP-Auth-Method). - API Conventions — the bulk-first
200 OK+ per-item-results contract this proxy passes through unchanged. - Namespaces & Tenancy — the namespace-scoping model behind
defaultNamespace. - PoNIFs — the multi-namespace admin-key /
defaultNamespacesilent-zero-rows behavior this library exists to guard against is exactly this shape of surprise.
⚠ GAP:
WIP_API_PREFIXESproxies six service prefixes, including/api/ingest-gateway— but the manifest has noingest-gatewayAPI doc to link here. Five of six proxied services have a sibling REST-API doc in this library; ingest-gateway doesn't yet.
