Files
kme_content_adapter/specs/001-oidc-proxy-script/contracts/vm-context.md

223 lines
7.1 KiB
Markdown

# 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:
```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<AxiosResponse>` | 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,
});
}
```