[Spec Kit] Implementation progress
This commit is contained in:
222
specs/001-oidc-proxy-script/contracts/vm-context.md
Normal file
222
specs/001-oidc-proxy-script/contracts/vm-context.md
Normal file
@@ -0,0 +1,222 @@
|
||||
# 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,
|
||||
});
|
||||
}
|
||||
```
|
||||
Reference in New Issue
Block a user