# 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` | Store token or expiry in hash | | `redis.hGet(key, field)` | `Promise` | Read token or expiry from hash | Usage pattern in proxy.js: ```javascript // 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` | POST to token endpoint | Config properties used by proxy.js: ```javascript { 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 | | `xmlBuilder` | xmlbuilder2 | No XML output | --- ## Injection Pattern (server.js) ```javascript // 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, xmlBuilder, 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: ```javascript // 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, }); } ```