- Add contentFetchFlow() to proxy (FR-001 through FR-012) - Add extractArticleBody() helper with vkm:articleBody / articleBody fallback - Dynamic proxyBaseUrl derivation from x-forwarded-proto/host headers - Forward query/size/category params on /sitemap.xml requests - Add Accept: application/ld+json header to content API calls - Remove oidcAuthFlow() - unmatched requests now return 404 Not Found - Fix xmlbuilder2 import: default import, call as xmlbuilder2.create(...) - Version bump 0.2.0 → 0.3.0 - 45/45 tests passing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
7.1 KiB
Contract: VM Context Dependencies
Feature: 001-oidc-proxy-script
File: src/proxyScripts/proxy.js
Injector: src/server.js via vm.createContext()
Date: 2025-07-17
Overview
proxy.js runs in a Node.js VM sandbox. It has zero access to the Node.js module system
(require, import). All dependencies are injected by server.js through the
vm.createContext() object. This document specifies exactly what proxy.js depends on and
what server.js must provide.
Required Context Variables
These MUST be present in every vm.createContext() call. Their absence will cause a
runtime ReferenceError inside the sandbox.
adapter_settings — OIDC Configuration
Loaded from src/globalVariables/adapter_settings.json by loadGlobalVariables().
| Property | Type | Required | Used for |
|---|---|---|---|
tokenUrl |
string |
✅ | OIDC token endpoint URL (POST target) |
username |
string |
✅ | ROPC grant — resource owner username |
password |
string |
✅ | ROPC grant — resource owner password |
clientId |
string |
✅ | OAuth 2.0 client_id parameter |
scope |
string |
✅ | OAuth 2.0 scope parameter |
_pendingFetch |
Promise | null | undefined |
— | In-flight stampede guard, initialised by proxy.js at runtime |
The _pendingFetch property is NOT in the JSON file; proxy.js adds it at runtime. Token and expiry are stored in Redis, not on this object.
redis — Redis Client
Injected as a global. Used for persistent token caching across VM invocations.
| Method used | Signature | Purpose |
|---|---|---|
redis.hSet(key, field, value) |
Promise<number> |
Store token or expiry in hash |
redis.hGet(key, field) |
Promise<string | null> |
Read token or expiry from hash |
Usage pattern in proxy.js:
// Write
await redis.hSet('authorization', 'token', idToken);
await redis.hSet('authorization', 'expiry', String(expiresIn));
// Read
const token = await redis.hGet('authorization', 'token');
const expiry = parseFloat(await redis.hGet('authorization', 'expiry') ?? '0');
axios — HTTP Client
Standard axios instance from the axios npm package (v1.x).
| Method used | Signature | Purpose |
|---|---|---|
axios.post(url, data, config) |
Promise<AxiosResponse> |
POST to token endpoint |
Config properties used by proxy.js:
{
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
timeout: 5000 // milliseconds — FR-014
}
URLSearchParams — Form Body Builder
Global Web API (Node.js 18+ built-in).
| Usage | Purpose |
|---|---|
new URLSearchParams({ ... }) |
Build application/x-www-form-urlencoded body |
console — Structured Logger
Custom logger from src/logger.js (injected as console so proxy.js uses it transparently).
| Method | Used when |
|---|---|
console.error({ message, error }) |
Authentication failure or unexpected error |
req — HTTP Request Object
Node.js http.IncomingMessage, fresh per request.
| Property | Type | Usage |
|---|---|---|
| (none) | — | proxy.js does not read any request properties in this feature; req is present by convention |
res — HTTP Response Object
Node.js http.ServerResponse, fresh per request.
| Method | Signature | Called when |
|---|---|---|
res.writeHead(statusCode, headers) |
void |
Before sending body |
res.end(body) |
void |
Send response body and close |
Optional / Unused Context Variables
These are injected by server.js (globalVMContext) and are available to proxy.js but
not used by this feature's implementation:
| Variable | Type | Reason unused |
|---|---|---|
URL |
Web API | URL parsing not needed |
crypto |
Web Crypto API | No UUID or crypto ops in this script |
jwt |
jsonwebtoken | No JWT signing/verification needed |
uuidv4 |
uuid function | No request-ID generation needed |
xmlbuilder2 |
xmlbuilder2 | No XML output |
Injection Pattern (server.js)
// Static VM context (compiled once at startup)
const globalVMContext = {
URLSearchParams, // ← used by proxy.js
URL,
console: logger, // ← used by proxy.js
crypto,
axios, // ← used by proxy.js
uuidv4,
jwt,
xmlbuilder2,
redis, // ← used by proxy.js (token cache)
};
// Dynamic data (loaded from src/globalVariables/ at startup)
let globalVariableContext = {};
loadGlobalVariables(); // populates globalVariableContext.adapter_settings
// Per-request context (fresh sandbox each time)
const context = vm.createContext({
...globalVMContext, // spread — object refs, not clones
...globalVariableContext, // includes adapter_settings ← used by proxy.js
req, // fresh per request
res, // fresh per request
});
script.runInContext(context);
State Ownership
| State | Lives on | Lifetime | Owned by |
|---|---|---|---|
| OIDC credentials | adapter_settings (JSON properties) |
Process lifetime | server.js (loads from file) |
| Cached token | Redis hash authorization, field token |
Until expiry / Redis flush | proxy.js (writes on fetch) |
| Token expiry | Redis hash authorization, field expiry |
Until expiry / Redis flush | proxy.js (writes on fetch) |
| In-flight fetch promise | adapter_settings._pendingFetch (runtime property) |
Duration of one fetch | proxy.js |
Key invariant: adapter_settings is the same JS object reference in every
vm.createContext() call. The _pendingFetch property written by proxy.js persists
across requests for stampede guarding. Token data is read from and written to Redis,
making it durable across adapter restarts.
Test Contract (Minimal Fake Context)
A test context satisfying this contract:
// tests/unit/proxy.test.js
import vm from 'node:vm';
function makeContext(t, overrides = {}) {
// In-memory Redis hash store fake
const _store = {};
const redis = {
hSet: t.mock.fn(async (key, field, value) => {
_store[`${key}:${field}`] = value;
return 1;
}),
hGet: t.mock.fn(async (key, field) => _store[`${key}:${field}`] ?? null),
};
let statusCode = null;
let body = '';
const res = {
writeHead: t.mock.fn((code) => { statusCode = code; }),
end: t.mock.fn((b = '') => { body += String(b); }),
get statusCode() { return statusCode; },
get body() { return body; },
};
const adapter_settings = {
tokenUrl: 'https://auth.example.com/token',
username: 'testuser', password: 'testpass',
clientId: 'test-client', scope: 'openid',
};
return vm.createContext({
URLSearchParams,
console,
axios: {
post: t.mock.fn(async () => ({
data: { id_token: 'mock-token', expires_in: 9_999_999_999 },
})),
},
redis,
adapter_settings,
req: { url: '/', method: 'GET', headers: {} },
res,
...overrides,
});
}