relmio 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -20,6 +20,16 @@ This is a documentation-backed engineering boundary, not legal advice or a
20
20
  guarantee that a particular account or use case is permitted. Review the
21
21
  agreements and policies that apply to your account.
22
22
 
23
+ ## ChatGPT/Codex sign-in lifetime
24
+
25
+ ChatGPT/Codex sign-in tokens expire, but the official Codex client refreshes
26
+ them automatically during active use before they expire, so active sessions
27
+ usually continue without another browser login. The official [OpenAI
28
+ authentication documentation](https://learn.chatgpt.com/docs/auth) does not
29
+ publish a fixed 10-day lifetime; do not plan around one. This provider
30
+ credential is separate from Relmio's local capability, which remains valid
31
+ until you rotate it.
32
+
23
33
  ## Requirements
24
34
 
25
35
  - macOS, Linux, or Linux under WSL2. Native Windows is not supported because
@@ -40,7 +50,8 @@ project on the local computer.
40
50
  ## Install with the browser wizard
41
51
 
42
52
  1. Start Relmio on the computer that will run the endpoint. Use one of the
43
- commands in the [README](../README.md#quick-install), or run:
53
+ commands on the [hosted install page](https://relmio.vercel.app/install),
54
+ or run:
44
55
 
45
56
  ```bash
46
57
  npx --yes --ignore-scripts relmio@latest
@@ -129,8 +140,9 @@ For a quick private test:
129
140
 
130
141
  ```bash
131
142
  export RELMIO_LOCAL_KEY="<capability shown once by the wizard>"
132
- curl http://127.0.0.1:12435/v1/models \
133
- -H "Authorization: Bearer $RELMIO_LOCAL_KEY"
143
+ printf 'Authorization: Bearer %s\n' "$RELMIO_LOCAL_KEY" |
144
+ curl http://127.0.0.1:12435/v1/models --header @-
145
+ unset RELMIO_LOCAL_KEY
134
146
  ```
135
147
 
136
148
  The upstream key is passed only over stdin to a transient, network-disabled
@@ -179,12 +191,15 @@ official verification URL, enter the device code, and complete authentication.
179
191
  Relmio starts the login through the official Codex App Server account method;
180
192
  it never returns the resulting ChatGPT access or refresh tokens.
181
193
 
182
- A compatible Codex CLI can connect like this:
194
+ A compatible Codex CLI can connect like this. Read the capability without
195
+ putting it in the command line:
183
196
 
184
197
  ```bash
185
- export CODEX_REMOTE_TOKEN="<capability shown once by the wizard>"
198
+ read -r -s CODEX_REMOTE_TOKEN
199
+ printf '\n'
186
200
  codex --remote ws://127.0.0.1:14500 \
187
201
  --remote-auth-token-env CODEX_REMOTE_TOKEN
202
+ unset CODEX_REMOTE_TOKEN
188
203
  ```
189
204
 
190
205
  This is not an OpenAI `/v1` endpoint. A client must implement the official
@@ -221,14 +236,19 @@ Protocol: Relmio Codex Chat HTTP
221
236
  ```
222
237
 
223
238
  After completing the same official Codex device-code sign-in, a local backend
224
- can start a conversation with:
239
+ can start a conversation with. Read the bearer rather than placing it in a
240
+ shell command:
225
241
 
226
242
  ```bash
227
- export RELMIO_CODEX_CHAT_KEY="<capability shown once by the wizard>"
228
- curl http://127.0.0.1:14501/chat \
229
- -H "Authorization: Bearer $RELMIO_CODEX_CHAT_KEY" \
230
- -H "Content-Type: application/json" \
231
- --data '{"input":"Reply with a short hello."}'
243
+ read -r -s RELMIO_CODEX_CHAT_KEY
244
+ printf '\n'
245
+ printf 'Authorization: Bearer %s\n' "$RELMIO_CODEX_CHAT_KEY" |
246
+ curl --fail-with-body --silent --show-error \
247
+ --request POST http://127.0.0.1:14501/chat \
248
+ --header @- \
249
+ --header "Content-Type: application/json" \
250
+ --data '{"input":"Reply with a short hello."}'
251
+ unset RELMIO_CODEX_CHAT_KEY
232
252
  ```
233
253
 
234
254
  The response contains only the App Server thread ID and final conversational
@@ -262,6 +282,27 @@ LAN, multi-user, or production service. It enforces bounded request bodies,
262
282
  output, concurrency, process lifetime, and sanitized failures, but those
263
283
  controls do not create a general-purpose API entitlement.
264
284
 
285
+ ### In-wizard Chat Adapter tester
286
+
287
+ The Ready screen for an installed Chat Adapter includes a narrow local tester.
288
+ It is intended for a literal `http://127.0.0.1:PORT` adapter address only. The
289
+ browser never calls the adapter: it calls the local wizard's existing
290
+ same-origin, `X-Setup-Token` protected APIs, and the wizard makes the
291
+ server-side `POST /chat` request without an `Origin` header.
292
+
293
+ When the user secures the displayed client credential, the browser clears the
294
+ input and encrypts it with the tester's short-lived RSA-OAEP SHA-256 public
295
+ key. The private key exists only in local server memory, expires after a few
296
+ minutes, has a bounded session count, and can be invalidated with **Forget
297
+ tester**. The browser retains only ciphertext and key ID for the test session;
298
+ it keeps prompts and transcript only in current-page memory and DOM.
299
+
300
+ This reduces accidental credential transit and storage exposure. It is not
301
+ encryption at rest or end-to-end encryption, and it cannot protect against a
302
+ compromised browser, extension, or local machine. The tester rejects redirects,
303
+ non-loopback URLs, malformed or oversized data, concurrent key use, and
304
+ adapter failures with redacted messages.
305
+
265
306
  ## Network and container boundary
266
307
 
267
308
  Every local Compose project publishes exactly one host mapping:
@@ -0,0 +1,77 @@
1
+ # Reference
2
+
3
+ ## Chat Adapter test commands
4
+
5
+ The experimental Chat Adapter is a loopback-only, Relmio-specific `POST /chat`
6
+ service for trusted local backends or development servers. It is not OpenAI
7
+ `/v1` and it rejects browser `Origin` headers.
8
+
9
+ Set the endpoint, then read the one-time credential without typing the literal
10
+ credential into shell history:
11
+
12
+ ```bash
13
+ export RELMIO_CHAT_BASE_URL="http://127.0.0.1:14501"
14
+ read -r -s RELMIO_CHAT_CLIENT_CREDENTIAL
15
+ printf '\n'
16
+ ```
17
+
18
+ Start a conversation:
19
+
20
+ ```bash
21
+ printf 'Authorization: Bearer %s\n' "$RELMIO_CHAT_CLIENT_CREDENTIAL" |
22
+ curl --fail-with-body --silent --show-error \
23
+ --request POST "$RELMIO_CHAT_BASE_URL/chat" \
24
+ --header @- \
25
+ --header "Content-Type: application/json" \
26
+ --data '{"input":"Reply with exactly: adapter works"}'
27
+ ```
28
+
29
+ Copy the returned `conversationId`, then send a continuation:
30
+
31
+ ```bash
32
+ export RELMIO_CONVERSATION_ID="CONVERSATION_ID_FROM_THE_PREVIOUS_RESPONSE"
33
+ printf 'Authorization: Bearer %s\n' "$RELMIO_CHAT_CLIENT_CREDENTIAL" |
34
+ curl --fail-with-body --silent --show-error \
35
+ --request POST "$RELMIO_CHAT_BASE_URL/chat" \
36
+ --header @- \
37
+ --header "Content-Type: application/json" \
38
+ --data "{\"input\":\"Continue with one short sentence.\",\"conversationId\":\"$RELMIO_CONVERSATION_ID\"}"
39
+ ```
40
+
41
+ Unset the shell credential when finished:
42
+
43
+ ```bash
44
+ unset RELMIO_CHAT_CLIENT_CREDENTIAL
45
+ ```
46
+
47
+ ## Raw Codex App Server command
48
+
49
+ The raw Codex App Server transport is JSON-RPC over WebSocket, experimental,
50
+ and high-trust. It is for trusted native clients only; it is not an
51
+ OpenAI-compatible `/v1` endpoint. Read the one-time local capability into a
52
+ named environment variable rather than placing it in the command:
53
+
54
+ ```bash
55
+ read -r -s RELMIO_CODEX_CLIENT_CREDENTIAL
56
+ printf '\n'
57
+ codex --remote ws://127.0.0.1:14500 \
58
+ --remote-auth-token-env RELMIO_CODEX_CLIENT_CREDENTIAL
59
+ unset RELMIO_CODEX_CLIENT_CREDENTIAL
60
+ ```
61
+
62
+ Keep that capability private. A trusted Codex client can control the isolated
63
+ container and may be able to recover its ChatGPT session credential.
64
+
65
+ ## Local wizard tester API
66
+
67
+ The browser never contacts the adapter directly. While the local wizard is
68
+ running, its same-origin, `X-Setup-Token` protected APIs are:
69
+
70
+ | Method | Route | Purpose |
71
+ | --- | --- | --- |
72
+ | `POST` | `/api/local/chat-test/key` | Issue one ephemeral RSA-OAEP public key |
73
+ | `POST` | `/api/local/chat-test/message` | Send encrypted credential and one bounded chat turn |
74
+ | `POST` | `/api/local/chat-test/reset` | Invalidate the tester key and clear the browser transcript |
75
+
76
+ The local proxy accepts only a literal `http://127.0.0.1:PORT` adapter base
77
+ URL and appends `/chat` itself.
package/docs/security.md CHANGED
@@ -6,6 +6,16 @@ key, a Codex/ChatGPT session, and generated local capabilities. Treat every one
6
6
  of these values as password-equivalent. Read this page before offering the
7
7
  wizard to another person.
8
8
 
9
+ ## ChatGPT/Codex sign-in lifetime
10
+
11
+ ChatGPT/Codex sign-in tokens expire, but the official Codex client refreshes
12
+ them automatically during active use before they expire, so active sessions
13
+ usually continue without another browser login. The official [OpenAI
14
+ authentication documentation](https://learn.chatgpt.com/docs/auth) does not
15
+ publish a fixed 10-day lifetime; do not plan around one. This provider
16
+ credential is separate from Relmio's local capability, which remains valid
17
+ until you rotate it.
18
+
9
19
  ## Trust model
10
20
 
11
21
  The design assumes:
@@ -112,6 +122,30 @@ shared, or production service.
112
122
  resource limits; and uses root plus only `CHOWN` long enough to atomically
113
123
  make the stdin-seeded volume entry readable by the non-root gateway.
114
124
 
125
+ ### In-wizard Chat Adapter tester
126
+
127
+ The Ready screen's Chat Adapter tester is a deliberately narrow convenience
128
+ path, not a browser CORS exception. Its browser calls stay same-origin to the
129
+ setup-token-protected wizard. Only the local wizard server calls the adapter,
130
+ using a server-side `POST /chat` request without an `Origin` header.
131
+
132
+ The tester accepts only a literal `http://127.0.0.1:PORT` base URL and appends
133
+ `/chat` itself. It refuses `localhost`, IPv6, LAN/private/public addresses,
134
+ credentials, query strings, fragments, redirects, malformed JSON, oversized
135
+ payloads, excessive IDs/ciphertext, concurrent key use, and preview mode. The
136
+ server bounds timeout and response size, validates the upstream shape, and
137
+ returns only a conversation ID plus output with generic redacted errors.
138
+
139
+ Before a test, the browser obtains an ephemeral RSA-OAEP SHA-256 public key
140
+ from the local wizard, clears the credential input, and retains only ciphertext
141
+ and key ID in page memory. The matching private key remains only in the local
142
+ server's in-memory, time-limited, bounded session map and can be invalidated
143
+ explicitly. Prompts and transcript are not persisted server-side.
144
+
145
+ This is not encryption at rest or end-to-end encryption. It reduces accidental
146
+ credential transit and storage exposure, but cannot protect a compromised
147
+ browser, extension, or local machine.
148
+
115
149
  ## What “private” means here
116
150
 
117
151
  Port `10531` is not reachable from the public internet or VPS host through a
@@ -9,6 +9,39 @@ wizard or any manual VPS command. The documented commands are sidecar-only and
9
9
  do not delete, restart, or rebuild n8n, but they still access your VPS and write
10
10
  files there.
11
11
 
12
+ ## Docker is not running
13
+
14
+ Start Docker Desktop or Docker Engine and wait until `docker info` and
15
+ `docker compose version` both succeed. Close any stale Relmio wizard tab, start
16
+ one fresh wizard session, and review the local plan again. Do not restart or
17
+ rebuild unrelated containers while checking the local endpoint.
18
+
19
+ ## Authentication fails
20
+
21
+ Close stale wizard and device-code tabs, keep the newest Relmio terminal open,
22
+ and use only the complete wizard URL printed by that active process. Start one
23
+ fresh ChatGPT device-code attempt and complete the newest code. A ChatGPT
24
+ subscription credential is valid only for the Codex targets; the generic
25
+ OpenAI-compatible `/v1` target requires a separately billed Platform API key.
26
+ ChatGPT/Codex sign-in tokens expire, but the official Codex client refreshes
27
+ them automatically during active use before they expire, so active sessions
28
+ usually continue without another browser login. The official [OpenAI
29
+ authentication documentation](https://learn.chatgpt.com/docs/auth) does not
30
+ publish a fixed 10-day lifetime; do not plan around one. This provider
31
+ credential is separate from Relmio's local capability, which remains valid
32
+ until you rotate it. If Relmio reports the credential is invalid or refresh no
33
+ longer succeeds, select **Start ChatGPT sign-in** again in the active local
34
+ wizard. The VPS sidecar flow labels that action **Refresh ChatGPT sign-in**.
35
+
36
+ ## Local image build failed
37
+
38
+ The local wizard intentionally does not show Docker build output, filesystem
39
+ paths, or stderr in the browser. Confirm Docker Desktop or Docker Engine is
40
+ running, check that the local disk has room for the image, and confirm your
41
+ network can reach the image registry. Then close the old wizard, start one new
42
+ wizard session, review a fresh plan, and retry. Do not delete an existing
43
+ managed endpoint or rebuild unrelated containers as a workaround.
44
+
12
45
  ## Hosted chat browser extension
13
46
 
14
47
  The hosted demo at [relmio.vercel.app](https://relmio.vercel.app/) needs the
@@ -0,0 +1,30 @@
1
+ # VPS and n8n
2
+
3
+ Relmio installs a separate sidecar project at
4
+ `/docker/n8n-openai-oauth`. It does not edit, rebuild, recreate, stop, or
5
+ restart your existing n8n Compose project or image. The sidecar has no host
6
+ port: n8n reaches it over the shared Docker network at
7
+ `http://n8n-openai-oauth:10531/v1`.
8
+
9
+ ## Wizard route
10
+
11
+ 1. Run the local wizard and complete the fresh ChatGPT sign-in on your own
12
+ computer.
13
+ 2. Enter your VPS address and compare the presented SSH host fingerprint with
14
+ your provider before authorizing password authentication.
15
+ 3. Select an already-running n8n container and one of its existing shared
16
+ networks.
17
+ 4. Review the exact plan. Remote writes begin only after final confirmation.
18
+ 5. In n8n, use the private sidecar hostname rather than `127.0.0.1`.
19
+
20
+ The n8n credential's required API-key field uses `local-only` only as a UI
21
+ placeholder; it is not an OpenAI Platform API key.
22
+
23
+ ## Follow-on guides
24
+
25
+ - [Configure n8n nodes](./n8n-configuration.md) has copy-ready AI Agent and
26
+ HTTP Request recipes.
27
+ - [Beginner manual installation](./manual-install.md) is the auditable fallback
28
+ when the wizard cannot be used.
29
+ - [Troubleshooting](./troubleshooting.md) includes connection, Docker-network,
30
+ and browser sign-in recovery steps.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relmio",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Install private local OpenAI API and Codex endpoints with explicit provider credential boundaries, plus the existing isolated n8n sidecar.",
5
5
  "keywords": [
6
6
  "relmio",
@@ -0,0 +1,361 @@
1
+ import {
2
+ constants,
3
+ generateKeyPair as generateKeyPairCallback,
4
+ privateDecrypt,
5
+ randomUUID,
6
+ } from "node:crypto";
7
+ import { promisify } from "node:util";
8
+
9
+ const generateKeyPair = promisify(generateKeyPairCallback);
10
+
11
+ const MAX_SESSIONS = 8;
12
+ const KEY_TTL_MS = 5 * 60 * 1_000;
13
+ const REQUEST_TIMEOUT_MS = 120_000;
14
+ const MAX_ENDPOINT_LENGTH = 64;
15
+ const MAX_CIPHERTEXT_LENGTH = 4_096;
16
+ const MAX_INPUT_LENGTH = 8_192;
17
+ const MAX_CONVERSATION_ID_LENGTH = 160;
18
+ const MAX_RESPONSE_BYTES = 16 * 1_024;
19
+ const MAX_OUTPUT_LENGTH = 12 * 1_024;
20
+
21
+ function requestError(message, statusCode = 400) {
22
+ return Object.assign(new Error(message), { statusCode });
23
+ }
24
+
25
+ function expiredKeyError() {
26
+ return requestError(
27
+ "This test credential has expired or was forgotten. Secure it again.",
28
+ 409,
29
+ );
30
+ }
31
+
32
+ function adapterError(statusCode = 502) {
33
+ return requestError("The local adapter test could not be completed.", statusCode);
34
+ }
35
+
36
+ function isPlainObject(value) {
37
+ return value !== null && typeof value === "object" && !Array.isArray(value);
38
+ }
39
+
40
+ function isBoundedText(value, maximumLength) {
41
+ return (
42
+ typeof value === "string" &&
43
+ value.length > 0 &&
44
+ value.length <= maximumLength &&
45
+ !/[\u0000-\u001f\u007f]/u.test(value)
46
+ );
47
+ }
48
+
49
+ export function parseLocalAdapterBaseUrl(value) {
50
+ if (typeof value !== "string" || value.length > MAX_ENDPOINT_LENGTH) {
51
+ throw requestError("Enter a valid local adapter address.");
52
+ }
53
+
54
+ const match = /^http:\/\/127\.0\.0\.1:([1-9]\d{0,4})\/?$/u.exec(value);
55
+ if (!match) {
56
+ throw requestError("Enter a valid local adapter address.");
57
+ }
58
+
59
+ const port = Number(match[1]);
60
+ if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) {
61
+ throw requestError("Enter a valid local adapter address.");
62
+ }
63
+
64
+ return `http://127.0.0.1:${port}`;
65
+ }
66
+
67
+ function validateMessageRequest(request) {
68
+ if (!isPlainObject(request)) {
69
+ throw requestError("Enter a valid local adapter test request.");
70
+ }
71
+ const endpointBaseUrl = parseLocalAdapterBaseUrl(request.endpointBaseUrl);
72
+ if (!isBoundedText(request.keyId, 128)) {
73
+ throw expiredKeyError();
74
+ }
75
+ if (
76
+ typeof request.encryptedCredential !== "string" ||
77
+ request.encryptedCredential.length < 32 ||
78
+ request.encryptedCredential.length > MAX_CIPHERTEXT_LENGTH ||
79
+ !/^[A-Za-z0-9+/]+={0,2}$/u.test(request.encryptedCredential)
80
+ ) {
81
+ throw requestError("Secure the client credential again before testing.");
82
+ }
83
+ if (!isBoundedText(request.input, MAX_INPUT_LENGTH)) {
84
+ throw requestError("Enter a shorter chat message.");
85
+ }
86
+ if (
87
+ request.conversationId !== undefined &&
88
+ !isBoundedText(request.conversationId, MAX_CONVERSATION_ID_LENGTH)
89
+ ) {
90
+ throw requestError("Start a new conversation and try again.");
91
+ }
92
+
93
+ return {
94
+ endpointBaseUrl,
95
+ keyId: request.keyId,
96
+ encryptedCredential: request.encryptedCredential,
97
+ input: request.input,
98
+ ...(request.conversationId !== undefined
99
+ ? { conversationId: request.conversationId }
100
+ : {}),
101
+ };
102
+ }
103
+
104
+ function decodeCiphertext(value) {
105
+ const decoded = Buffer.from(value, "base64");
106
+ if (
107
+ decoded.length === 0 ||
108
+ decoded.toString("base64") !== value
109
+ ) {
110
+ decoded.fill(0);
111
+ throw requestError("Secure the client credential again before testing.");
112
+ }
113
+ return decoded;
114
+ }
115
+
116
+ async function readBoundedResponse(response) {
117
+ const contentLength = response.headers?.get?.("content-length");
118
+ if (
119
+ contentLength !== null &&
120
+ contentLength !== undefined &&
121
+ (!/^\d+$/u.test(contentLength) || Number(contentLength) > MAX_RESPONSE_BYTES)
122
+ ) {
123
+ throw adapterError();
124
+ }
125
+
126
+ const reader = response.body?.getReader?.();
127
+ if (!reader) {
128
+ throw adapterError();
129
+ }
130
+
131
+ const chunks = [];
132
+ let bytes = 0;
133
+ try {
134
+ for (;;) {
135
+ const { done, value } = await reader.read();
136
+ if (done) {
137
+ break;
138
+ }
139
+ const chunk = Buffer.from(value);
140
+ bytes += chunk.length;
141
+ if (bytes > MAX_RESPONSE_BYTES) {
142
+ chunk.fill(0);
143
+ throw adapterError();
144
+ }
145
+ chunks.push(chunk);
146
+ }
147
+ return Buffer.concat(chunks).toString("utf8");
148
+ } finally {
149
+ for (const chunk of chunks) {
150
+ chunk.fill(0);
151
+ }
152
+ reader.releaseLock?.();
153
+ }
154
+ }
155
+
156
+ function parseAdapterResponse(text) {
157
+ let value;
158
+ try {
159
+ value = JSON.parse(text);
160
+ } catch {
161
+ throw adapterError();
162
+ }
163
+ if (
164
+ !isPlainObject(value) ||
165
+ !isBoundedText(value.conversationId, MAX_CONVERSATION_ID_LENGTH) ||
166
+ !isBoundedText(value.output, MAX_OUTPUT_LENGTH)
167
+ ) {
168
+ throw adapterError();
169
+ }
170
+ return {
171
+ conversationId: value.conversationId,
172
+ output: value.output,
173
+ };
174
+ }
175
+
176
+ function isTimeout(error) {
177
+ return error?.name === "AbortError" || error?.name === "TimeoutError";
178
+ }
179
+
180
+ export function createLocalChatTestService({
181
+ fetchImpl = fetch,
182
+ now = () => Date.now(),
183
+ keyTtlMs = KEY_TTL_MS,
184
+ maxSessions = MAX_SESSIONS,
185
+ requestTimeoutMs = REQUEST_TIMEOUT_MS,
186
+ } = {}) {
187
+ const sessions = new Map();
188
+ let pendingKeyIssuances = 0;
189
+
190
+ function expireSession(keyId, session) {
191
+ if (sessions.get(keyId) !== session) {
192
+ return;
193
+ }
194
+ clearTimeout(session.expiryTimer);
195
+ session.abortController?.abort();
196
+ session.privateKey = undefined;
197
+ sessions.delete(keyId);
198
+ }
199
+
200
+ function discardExpiredSessions() {
201
+ const currentTime = now();
202
+ for (const [keyId, session] of sessions) {
203
+ if (session.expiresAt <= currentTime) {
204
+ expireSession(keyId, session);
205
+ }
206
+ }
207
+ }
208
+
209
+ function resetAllSessions() {
210
+ for (const [keyId, session] of sessions) {
211
+ expireSession(keyId, session);
212
+ }
213
+ }
214
+
215
+ function getLiveSession(keyId) {
216
+ discardExpiredSessions();
217
+ const session = sessions.get(keyId);
218
+ if (!session || session.expiresAt <= now()) {
219
+ if (session) {
220
+ expireSession(keyId, session);
221
+ }
222
+ throw expiredKeyError();
223
+ }
224
+ return session;
225
+ }
226
+
227
+ return {
228
+ async issueKey() {
229
+ discardExpiredSessions();
230
+ if (sessions.size + pendingKeyIssuances >= maxSessions) {
231
+ throw requestError(
232
+ "Too many open tester sessions. Forget one or wait for it to expire.",
233
+ 429,
234
+ );
235
+ }
236
+ pendingKeyIssuances += 1;
237
+ try {
238
+ const { publicKey, privateKey } = await generateKeyPair("rsa", {
239
+ modulusLength: 2_048,
240
+ publicExponent: 0x10001,
241
+ });
242
+ const keyId = randomUUID();
243
+ const expiresAt = now() + keyTtlMs;
244
+ const session = {
245
+ abortController: null,
246
+ expiresAt,
247
+ expiryTimer: null,
248
+ inFlight: false,
249
+ privateKey,
250
+ };
251
+ sessions.set(keyId, session);
252
+ session.expiryTimer = setTimeout(
253
+ () => expireSession(keyId, session),
254
+ keyTtlMs,
255
+ );
256
+ session.expiryTimer.unref?.();
257
+ return {
258
+ keyId,
259
+ publicKeyJwk: publicKey.export({ format: "jwk" }),
260
+ algorithm: "RSA-OAEP-256",
261
+ expiresAt: new Date(expiresAt).toISOString(),
262
+ };
263
+ } finally {
264
+ pendingKeyIssuances -= 1;
265
+ }
266
+ },
267
+
268
+ async message(untrustedRequest) {
269
+ const request = validateMessageRequest(untrustedRequest);
270
+ const session = getLiveSession(request.keyId);
271
+ if (session.inFlight) {
272
+ throw requestError("Wait for the current test message to finish.", 409);
273
+ }
274
+ session.inFlight = true;
275
+ let encryptedCredential;
276
+ let decryptedCredential;
277
+ let authorization;
278
+ let timeout;
279
+ try {
280
+ encryptedCredential = decodeCiphertext(request.encryptedCredential);
281
+ try {
282
+ decryptedCredential = privateDecrypt(
283
+ {
284
+ key: session.privateKey,
285
+ padding: constants.RSA_PKCS1_OAEP_PADDING,
286
+ oaepHash: "sha256",
287
+ },
288
+ encryptedCredential,
289
+ );
290
+ } catch {
291
+ throw requestError("Secure the client credential again before testing.");
292
+ }
293
+ if (
294
+ decryptedCredential.length === 0 ||
295
+ decryptedCredential.length > 512 ||
296
+ /[\r\n\u0000]/u.test(decryptedCredential.toString("utf8"))
297
+ ) {
298
+ throw requestError("Secure the client credential again before testing.");
299
+ }
300
+ authorization = `Bearer ${decryptedCredential.toString("utf8")}`;
301
+
302
+ let response;
303
+ try {
304
+ session.abortController = new AbortController();
305
+ timeout = setTimeout(
306
+ () => session.abortController?.abort(),
307
+ requestTimeoutMs,
308
+ );
309
+ response = await fetchImpl(`${request.endpointBaseUrl}/chat`, {
310
+ method: "POST",
311
+ headers: {
312
+ Accept: "application/json",
313
+ Authorization: authorization,
314
+ "Content-Type": "application/json",
315
+ },
316
+ body: JSON.stringify({
317
+ input: request.input,
318
+ ...(request.conversationId !== undefined
319
+ ? { conversationId: request.conversationId }
320
+ : {}),
321
+ }),
322
+ redirect: "error",
323
+ signal: session.abortController.signal,
324
+ });
325
+ } catch (error) {
326
+ throw adapterError(isTimeout(error) ? 504 : 502);
327
+ }
328
+ if (!response?.ok || response.status < 200 || response.status >= 300) {
329
+ throw adapterError();
330
+ }
331
+ return parseAdapterResponse(await readBoundedResponse(response));
332
+ } finally {
333
+ clearTimeout(timeout);
334
+ authorization = undefined;
335
+ encryptedCredential?.fill(0);
336
+ decryptedCredential?.fill(0);
337
+ session.abortController = null;
338
+ session.inFlight = false;
339
+ }
340
+ },
341
+
342
+ async reset(request) {
343
+ if (!isPlainObject(request) || !isBoundedText(request.keyId, 128)) {
344
+ throw requestError("Choose a valid tester session to forget.");
345
+ }
346
+ const session = sessions.get(request.keyId);
347
+ if (session) {
348
+ expireSession(request.keyId, session);
349
+ }
350
+ return { forgotten: true };
351
+ },
352
+
353
+ resetAll() {
354
+ resetAllSessions();
355
+ },
356
+
357
+ dispose() {
358
+ resetAllSessions();
359
+ },
360
+ };
361
+ }