This is an automated email from the ASF dual-hosted git repository. miki3421 pushed a commit to branch codex/immutable-env-redis-auth in repository https://gitbox.apache.org/repos/asf/openserverless-mcp.git
commit fc0ef0fde1e7216a4cf50d33ebd7787f082d5953 Author: Trustable User <[email protected]> AuthorDate: Sat Jul 25 10:44:48 2026 +0200 Use Redis sessions for application authentication --- README.md | 30 ++++++------ spec.md | 77 +++++++++++++++++++++---------- src/index.ts | 5 +- src/secrets.ts | 59 ++---------------------- src/tools/add-redis.ts | 9 +++- src/tools/auth-setup.ts | 97 +++++++++++++++++++++++++++++---------- src/tools/secret-ensure.ts | 43 ------------------ tests/secrets.test.ts | 111 +++++++++++++++++++++++++-------------------- 8 files changed, 218 insertions(+), 213 deletions(-) diff --git a/README.md b/README.md index c00a469..3364276 100644 --- a/README.md +++ b/README.md @@ -13,13 +13,12 @@ Model Context Protocol so any MCP-capable agent can drive them. | `action_requirements` | Add a library to an endpoint's `requirements.txt` (skips preinstalled libs). | | `action_add_secret` | Wire a `.env` secret into an endpoint's context as `ctx.<SECRET>`. | | `secret_status` | Check secret presence and endpoint bindings without reading its value. | -| `secret_ensure` | Generate a missing `.env` secret without returning its value. | | `secret_bind` | Atomically bind the same secret to multiple endpoints. | | `secret_unbind` | Atomically remove an obsolete generated binding without reading or deleting the secret. | -| `auth_setup` | Prepare one shared secret for token-issuing and protected endpoints. | +| `auth_setup` | Atomically wire Redis into all token-issuing, protected/session, and logout endpoints; never writes `.env` or configures JWT. | | `action_add_s3` | Add bucket-scoped S3 to an endpoint's context (`ctx.S3_CLIENT`, `ctx.S3_DATA`, `ctx.S3_WEB`, `ctx.S3_PUBLIC`); forbids `list_buckets` and requires `put_object` → `get_object`/compare → `delete_object` for read/write verification. | | `action_add_postgresql` | Add PostgreSQL (`ctx.POSTGRESQL`). | -| `action_add_redis` | Add Redis (`ctx.REDIS`, `ctx.REDIS_PREFIX`) and its Python runtime dependency. | +| `action_add_redis` | Add Redis (`ctx.REDIS`, `ctx.REDIS_PREFIX`) and its Python runtime dependency; use it for opaque authenticated sessions. | | `action_add_milvus` | Add Milvus vector DB (`ctx.MILVUS`). | | `action_add_mongodb` | Add MongoDB (`ctx.MONGODB_CLIENT`, `ctx.MONGODB`). | @@ -34,15 +33,21 @@ reported as MCP errors. Secret values are never returned by these tools. Missing secrets and invalid endpoints are MCP errors (`isError: true`), so clients cannot mistake an -incomplete authentication setup for a successful tool call. `action_add_secret` -remains as the single-endpoint compatibility tool; use `secret_bind` or -`auth_setup` when several actions must share one credential. - -When `OPENSERVERLESS_SECRETS_FILE` is set, `secret_ensure` also synchronizes the -requested name with that persistent env file. A value already present in the -working `.env` is copied without being returned; a persisted value is restored -to `.env` when needed. This lets hosts whose `.env` is generated keep app -secrets outside the source checkout. +incomplete binding for a successful tool call. `action_add_secret` remains as +the single-endpoint compatibility tool; use `secret_bind` when several actions +must share one user-configured credential. + +Application `.env` and `.env.production` files are owned by Trustable's +user-facing configuration flow. The MCP performs only value-free presence +checks needed for binding validation and never creates, edits, imports, +synchronizes, regenerates, or automatically populates those files. A missing +variable must be added by the user through Trustable. + +Application authentication uses Redis-backed opaque sessions. Create login, +registration, `me`/session, every protected endpoint, and logout, then call +`auth_setup` once with those complete endpoint sets. Store token-to-identity +mappings under keys derived from `ctx.REDIS_PREFIX` with a bounded TTL; never +use JWT or an application signing secret as a substitute. ## Working directory @@ -69,7 +74,6 @@ src/ add-milvus.ts action_add_milvus add-mongodb.ts action_add_mongodb secret-status.ts secret_status - secret-ensure.ts secret_ensure secret-bind.ts secret_bind secret-unbind.ts secret_unbind auth-setup.ts auth_setup diff --git a/spec.md b/spec.md index 6d9b3b1..b05237c 100644 --- a/spec.md +++ b/spec.md @@ -6,6 +6,9 @@ Rule: assume the packages referenced by the tools are already available and prei Rule: `action-add-secret`, `action-add-s3`, `action-add-redis`, `action-add-postgresql`, `action-add-milvus`, and `action-add-mongodb` are idempotent — if the secret or service is already present in `__main__.py`, skip the injection silently and return a message saying it is already configured. +Rule: application authentication uses Redis-backed opaque sessions. It never +uses JWT, an application signing secret, or an MCP-generated environment value. + Implement the following tools in folder tools/ using this template: ``` @@ -139,17 +142,12 @@ Receive a secret name and an optional endpoint list. Report only whether the name exists in `.env` and whether each generated wrapper contains the matching parameter binding. Never read or return the value. -# tool secret-ensure - -Receive an authorized exact secret name and optional random byte count. If the -name is absent, append a cryptographically random base64url value to `.env`. -If it already exists, leave it unchanged. Never return either an existing or a -new value. - -When `OPENSERVERLESS_SECRETS_FILE` is configured, synchronize only the requested -name with that persistent env file. Prefer an existing persistent value, copy an -existing `.env` value into the persistent file when no persistent value exists, -and generate only when both are absent. +Application `.env` and `.env.production` files are owned exclusively by the +Trustable user-facing configuration flow. This MCP may perform a value-free +presence check needed to validate a binding, but it must not expose a tool that +creates, edits, imports, synchronizes, regenerates, or automatically populates +either file. Missing variables are MCP errors and must be reported to the user +for configuration through Trustable. # tool secret-bind @@ -163,23 +161,42 @@ some endpoints configured when validation fails. Receive one bound parameter name and a non-empty endpoint list. Validate every generated wrapper before changing anything, then atomically remove only the exact binding block produced by the secret tools. Do not read or delete the -value from `.env` or the persistent secret store. The operation is idempotent -and is the supported recovery path for legacy invalid bindings of -Trustable-managed variables such as `OPS_APIHOST`. When a binding is removed, -the tool must instruct the caller to recreate every changed endpoint with -`ops ide undeploy <endpoint>` followed by `ops ide deploy <endpoint>`, because -updating an existing OpenWhisk action without the parameter does not remove the -previously deployed binding. `ops ide clean` is insufficient because it removes -only local build artifacts. +value from `.env`. The operation is idempotent and is the supported recovery +path for legacy invalid bindings of Trustable-managed variables such as +`OPS_APIHOST`. When a binding is removed, the tool must instruct the caller to +recreate every changed endpoint with `ops ide undeploy <endpoint>` followed by +`ops ide deploy <endpoint>`, because updating an existing OpenWhisk action +without the parameter does not remove the previously deployed binding. +`ops ide clean` is insufficient because it removes only local build artifacts. # tool auth-setup -Receive a secret name, token-issuing endpoints, protected endpoints, and an -optional `generate_if_missing` flag. Ensure every action that creates or -validates authentication tokens receives the same secret. If generation is -requested, create the value without exposing it. The returned contract must -tell action code to use only `ctx.<SECRET>` and to fail closed instead of using -an environment or hardcoded fallback. +Receive three non-empty endpoint lists: + +- token endpoints that issue a session, such as login and registration; +- protected endpoints that validate the session, including `me`/session and + every protected resource; +- logout endpoints that revoke the session. + +Validate the complete endpoint set before changing any file, then atomically +apply the same Redis connector as `action-add-redis` to every unique endpoint. +If validation or writing fails, leave or restore every endpoint unchanged. +This tool must never read or write `.env` or `.env.production`. + +The result instructs editable action modules to: + +- verify password hashes with bcrypt; +- generate a cryptographically random opaque token; +- build a Redis key from `ctx.REDIS_PREFIX` without adding a duplicate + separator, plus an app-local `session:<token>` suffix; +- store a token-to-identity mapping with a bounded TTL; +- derive identity and authorization only from that server-side record on every + protected request; +- delete the record on logout; +- return only the opaque token to the browser. + +JWT, application signing secrets, browser-supplied user identifiers, and direct +generated-wrapper edits are forbidden alternatives. # MCP error semantics @@ -273,6 +290,16 @@ calls are idempotent and upgrade an existing generated Redis connector to `decode_responses=True` so action results contain JSON-safe strings rather than raw bytes. +Authenticated application pages use Redis-backed opaque sessions. After every +login, registration, `me`/session, protected-resource, and logout endpoint +exists, the caller must use `auth-setup` once with the complete endpoint sets; +use `action-add-redis` directly only for unrelated single-endpoint Redis use. +Login or registration creates a cryptographically random opaque token and +stores only its token-to-identity mapping in Redis with a bounded TTL. Every +Redis key must be derived from `ctx.REDIS_PREFIX`; protected endpoints validate +the record and derive identity from it, and logout deletes it. JWT and +application signing secrets are not an alternative to this session contract. + ## returns Information on the updated context. diff --git a/src/index.ts b/src/index.ts index 82b4ac8..3012662 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,7 +29,6 @@ import actionAddPostgresql from "./tools/add-postgresql.ts" import actionAddRedis from "./tools/add-redis.ts" import actionAddMilvus from "./tools/add-milvus.ts" import actionAddMongodb from "./tools/add-mongodb.ts" -import secretEnsure from "./tools/secret-ensure.ts" import secretStatus from "./tools/secret-status.ts" import secretBind from "./tools/secret-bind.ts" import secretUnbind from "./tools/secret-unbind.ts" @@ -45,11 +44,13 @@ const tools: Tool[] = [ actionAddRedis, actionAddMilvus, actionAddMongodb, - secretEnsure, secretStatus, secretBind, secretUnbind, authSetup, + // WHY: application env files are owned by Trustable's user-facing + // configuration flow. auth_setup wires Redis only; no registered tool + // generates values or writes .env behind the user's back. ] const server = new McpServer({ diff --git a/src/secrets.ts b/src/secrets.ts index 1848c14..7a7de91 100644 --- a/src/secrets.ts +++ b/src/secrets.ts @@ -15,9 +15,7 @@ // specific language governing permissions and limitations // under the License. -import { randomBytes } from "node:crypto" -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs" -import { dirname } from "node:path" +import { existsSync, readFileSync, writeFileSync } from "node:fs" import { BUILD_CONTEXT_MARKER, parseEndpoint, type Endpoint } from "./lib.ts" const SECRET_NAME = /^[A-Z_][A-Z0-9_]*$/ @@ -44,11 +42,6 @@ export interface SecretUnbindingResult { alreadyAbsent: string[] } -export interface EnsureEnvSecretResult { - created: boolean - persisted: boolean -} - function normalizeEnvName(secret: string): string { const name = secret.trim() if (!SECRET_NAME.test(name)) { @@ -80,55 +73,11 @@ function envValues(path: string): Map<string, string> { return values } -function writeEnvValue(path: string, name: string, value: string): void { - const current = existsSync(path) ? readFileSync(path, "utf-8") : "" - const lines = current.split(/\r?\n/) - const assignment = new RegExp(`^\\s*(?:export\\s+)?${name}\\s*=`) - let replaced = false - const next: string[] = [] - for (const [index, line] of lines.entries()) { - if (!assignment.test(line)) { - if (index < lines.length - 1 || line !== "") next.push(line) - continue - } - if (!replaced) next.push(`${name}=${value}`) - replaced = true - } - if (!replaced) next.push(`${name}=${value}`) - - mkdirSync(dirname(path), { recursive: true }) - writeFileSync(path, `${next.join("\n")}\n`, { encoding: "utf-8", mode: 0o600 }) -} - -function persistentSecretsPath(): string | undefined { - const path = process.env.OPENSERVERLESS_SECRETS_FILE?.trim() - return path || undefined -} - export function hasEnvSecret(secret: string, path = ".env"): boolean { const name = normalizeSecretName(secret) - const local = envValues(path).get(name) - if (local) return true - const persistentPath = persistentSecretsPath() - return persistentPath ? Boolean(envValues(persistentPath).get(name)) : false -} - -/** - * Create an application-local secret without returning its value to the MCP - * client. Existing values are never read back, changed, or exposed. - */ -export function ensureEnvSecret(secret: string, bytes = 48, path = ".env"): EnsureEnvSecretResult { - const name = normalizeSecretName(secret) - const persistentPath = persistentSecretsPath() - const localValue = envValues(path).get(name) - const persistentValue = persistentPath ? envValues(persistentPath).get(name) : undefined - const created = !localValue && !persistentValue - const value = persistentValue || localValue || randomBytes(bytes).toString("base64url") - - if (localValue !== value) writeEnvValue(path, name, value) - if (persistentPath && persistentValue !== value) writeEnvValue(persistentPath, name, value) - - return { created, persisted: Boolean(persistentPath) } + // WHY: this is a value-free existence probe only. Environment mutation is + // reserved for the Trustable UI and is intentionally absent from this MCP. + return Boolean(envValues(path).get(name)) } function secretInjection(secret: string): string { diff --git a/src/tools/add-redis.ts b/src/tools/add-redis.ts index 63e07e4..96dddaa 100644 --- a/src/tools/add-redis.ts +++ b/src/tools/add-redis.ts @@ -25,7 +25,7 @@ const TEXT_CLIENT = 'redis.from_url(args.get("REDIS_URL", os.getenv("REDIS_URL") export default defineTool({ name: "action_add_redis", config: { - description: "Add Redis connection to an endpoint's context. Provides ctx.REDIS and ctx.REDIS_PREFIX.", + description: "Add Redis to one endpoint as ctx.REDIS and ctx.REDIS_PREFIX. For authenticated pages, every login/registration, me/session, protected-resource, and logout endpoint must be wired; use auth_setup to configure the complete set atomically. Authentication uses opaque random tokens stored server-side in prefix-safe Redis keys with a bounded TTL, never JWT or an application signing secret.", inputSchema: { endpoint: endpointArg }, }, handler({ endpoint }) { @@ -60,6 +60,11 @@ builder.append(init_redis)` const requirement = ensurePythonRequirement(ep.dir, "redis") if (requirement.startsWith("Error:")) return status(requirement) - return status(`${connector}\n${requirement}`) + // WHY: tool metadata is not always retained after discovery. Repeat the + // authentication invariant in the result so a successful connector call + // cannot be mistaken for a complete one-endpoint auth setup. + return status(`${connector} +${requirement} +Authentication contract: use auth_setup for the complete endpoint set. Store a cryptographically random opaque token as a prefix-safe ctx.REDIS_PREFIX session key with a bounded TTL; protected endpoints derive identity from that server-side record and logout deletes it. Do not use JWT or an application signing secret.`) }, }) diff --git a/src/tools/auth-setup.ts b/src/tools/auth-setup.ts index 2b81150..36c62af 100644 --- a/src/tools/auth-setup.ts +++ b/src/tools/auth-setup.ts @@ -15,43 +15,90 @@ // specific language governing permissions and limitations // under the License. +import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { join } from "node:path" import { z } from "zod" -import { defineTool, endpointArg, error, text } from "../lib.ts" -import { bindSecret, ensureEnvSecret, hasEnvSecret, normalizeSecretName } from "../secrets.ts" +import { BUILD_CONTEXT_MARKER, defineTool, endpointArg, error, parseEndpoint, text } from "../lib.ts" +import actionAddRedis from "./add-redis.ts" + +interface FileSnapshot { + path: string + existed: boolean + content: string +} + +function snapshot(path: string): FileSnapshot { + return { + path, + existed: existsSync(path), + content: existsSync(path) ? readFileSync(path, "utf-8") : "", + } +} + +function restore(files: FileSnapshot[]): void { + for (const file of files) { + if (file.existed) writeFileSync(file.path, file.content) + else rmSync(file.path, { force: true }) + } +} export default defineTool({ name: "auth_setup", config: { - description: "Prepare one shared secret for token-issuing and protected endpoints. Optionally generates a missing secret without exposing it, then binds every endpoint atomically.", + description: "Atomically add Redis session wiring to the complete authentication surface: token issuers (login/registration), token validators (me/session and every protected resource), and logout. This tool never reads or writes .env. Generated auth must use bcrypt password verification plus a cryptographically random opaque token stored as a prefix-safe ctx.REDIS_PREFIX key with a bounded TTL; protected endpoints derive identity from Redis and logout deletes the record. JWT and app [...] inputSchema: { - secret: z.string().describe("The exact application secret name (e.g. JWT_SECRET)"), - token_endpoints: z.array(endpointArg).min(1).describe("Endpoints that create or refresh authentication tokens, such as register and login"), - protected_endpoints: z.array(endpointArg).min(1).describe("Endpoints that validate authentication, including me/session and protected resources"), - generate_if_missing: z.boolean().optional().describe("Generate the secret in .env when absent. Defaults to false."), + token_endpoints: z.array(endpointArg).min(1).describe("All endpoints that issue an authenticated session, such as v1/login and v1/register"), + protected_endpoints: z.array(endpointArg).min(1).describe("The me/session endpoint and every endpoint that requires an authenticated identity"), + logout_endpoints: z.array(endpointArg).min(1).describe("All endpoints that revoke an authenticated session, normally v1/logout"), }, }, - handler({ secret, token_endpoints, protected_endpoints, generate_if_missing }) { + async handler({ token_endpoints, protected_endpoints, logout_endpoints }) { + const endpoints = [...new Set([...token_endpoints, ...protected_endpoints, ...logout_endpoints])] + const parsed: ReturnType<typeof parseEndpoint>[] = [] + try { - const name = normalizeSecretName(secret) - let created = false - if (!hasEnvSecret(name)) { - if (!generate_if_missing) { - return error(`Authentication setup failed: secret '${name}' is not configured in .env. Call secret_ensure after the exact name is authorized, then retry auth_setup. No endpoint was changed.`) + // WHY: validate the complete graph before writing anything. A missing + // protected endpoint must not leave only login wired, which would make + // the application appear authenticated while its backend is unusable. + for (const endpoint of endpoints) { + const action = parseEndpoint(endpoint) + if (!existsSync(action.mainPath)) { + return error(`Authentication setup failed: endpoint not found at ${action.mainPath}. No endpoint was changed.`) + } + const wrapper = readFileSync(action.mainPath, "utf-8") + if (!wrapper.includes(BUILD_CONTEXT_MARKER)) { + return error(`Authentication setup failed: marker '${BUILD_CONTEXT_MARKER}' not found in ${action.mainPath}. No endpoint was changed.`) + } + parsed.push(action) + } + + const snapshots = parsed.flatMap((action) => [ + snapshot(action.mainPath), + snapshot(join(action.dir, "requirements.txt")), + ]) + + try { + for (const endpoint of endpoints) { + const result = await actionAddRedis.handler({ endpoint }) + if (result.isError) { + restore(snapshots) + const detail = result.content.map((part) => part.text).join("\n") + return error(`Authentication setup failed while wiring '${endpoint}': ${detail}. All endpoint files were restored.`) + } } - created = ensureEnvSecret(name).created + } catch (cause) { + restore(snapshots) + throw cause } - const endpoints = [...new Set([...token_endpoints, ...protected_endpoints])] - const result = bindSecret(name, endpoints) - const lines = [ - `Authentication secret '${name}' is ready for all ${endpoints.length} endpoints.`, - created ? "A new value was generated in .env and was not returned." : "The existing value was not read or returned.", - ] - if (result.configured.length > 0) lines.push(`Configured: ${result.configured.join(", ")}.`) - if (result.alreadyConfigured.length > 0) lines.push(`Already configured: ${result.alreadyConfigured.join(", ")}.`) - lines.push(`All token creation and validation code must use ctx.${name}; never os.getenv with a default or a hardcoded fallback.`) - lines.push("Registration must establish the same authenticated state as login. The frontend must validate persisted tokens through a protected me/session endpoint before rendering private pages.") - return text(lines.join("\n")) + return text([ + `Redis authentication wiring is ready for ${endpoints.length} endpoints: ${endpoints.join(", ")}.`, + "Token endpoints must verify password hashes with bcrypt, generate a cryptographically random opaque token, and store only a token-to-identity record in Redis with a bounded TTL.", + "Build keys without duplicate separators: append an app-local 'session:<token>' suffix to ctx.REDIS_PREFIX, adding ':' only when the configured prefix does not already end with it.", + "Protected endpoints must read the Bearer token, load the Redis record, derive user id and roles only from that record, and fail closed when it is absent or expired.", + "Logout endpoints must delete the same Redis record. The browser stores only the opaque token.", + "Do not use JWT, an application signing secret, browser-supplied user ids, or direct edits to generated __main__.py wrappers.", + ].join("\n")) } catch (cause) { return error(`Authentication setup failed: ${(cause as Error).message}`) } diff --git a/src/tools/secret-ensure.ts b/src/tools/secret-ensure.ts deleted file mode 100644 index edb79a7..0000000 --- a/src/tools/secret-ensure.ts +++ /dev/null @@ -1,43 +0,0 @@ -// Licensed to the Apache Software Foundation (ASF) under one -// or more contributor license agreements. See the NOTICE file -// distributed with this work for additional information -// regarding copyright ownership. The ASF licenses this file -// to you under the Apache License, Version 2.0 (the -// "License"); you may not use this file except in compliance -// with the License. You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, -// software distributed under the License is distributed on an -// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -// KIND, either express or implied. See the License for the -// specific language governing permissions and limitations -// under the License. - -import { z } from "zod" -import { defineTool, error, text } from "../lib.ts" -import { ensureEnvSecret, normalizeSecretName } from "../secrets.ts" - -export default defineTool({ - name: "secret_ensure", - config: { - description: "Create an application secret in .env only when absent. The value is generated securely and is never returned to the MCP client.", - inputSchema: { - secret: z.string().describe("The exact secret name authorized for this application (e.g. JWT_SECRET)"), - bytes: z.number().int().min(32).max(128).optional().describe("Random byte count. Defaults to 48."), - }, - }, - handler({ secret, bytes }) { - try { - const name = normalizeSecretName(secret) - const result = ensureEnvSecret(name, bytes ?? 48) - const persistence = result.persisted ? " It was synchronized with the configured persistent secret store." : "" - return text(result.created - ? `Created secret '${name}' in .env. Its value was not returned.${persistence}` - : `Secret '${name}' is already configured in .env. Its value was not returned.${persistence}`) - } catch (cause) { - return error(`Secret setup failed: ${(cause as Error).message}`) - } - }, -}) diff --git a/tests/secrets.test.ts b/tests/secrets.test.ts index 5956941..92390eb 100644 --- a/tests/secrets.test.ts +++ b/tests/secrets.test.ts @@ -16,7 +16,7 @@ // under the License. import assert from "node:assert/strict" -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import { join } from "node:path" import test from "node:test" @@ -27,7 +27,6 @@ import actionNew from "../src/tools/new.ts" import { endpointArg, parseEndpoint } from "../src/lib.ts" import authSetup from "../src/tools/auth-setup.ts" import secretBind from "../src/tools/secret-bind.ts" -import secretEnsure from "../src/tools/secret-ensure.ts" import secretUnbind from "../src/tools/secret-unbind.ts" function resultText(result: { content: { text: string }[] }): string { @@ -59,28 +58,24 @@ function inTemporaryProject(run: () => void): void { } } -test("secret_ensure creates an idempotent secret without returning its value", () => { - inTemporaryProject(() => { - const persistent = join(process.cwd(), "state", "secrets.env") - const previous = process.env.OPENSERVERLESS_SECRETS_FILE - process.env.OPENSERVERLESS_SECRETS_FILE = persistent - try { - const first = secretEnsure.handler({ secret: "JWT_SECRET", bytes: 32 }) - assert.equal(first.isError, undefined) - const env = readFileSync(".env", "utf-8") - const value = env.trim().split("=", 2)[1] - assert.ok(value.length >= 43) - assert.equal(resultText(first).includes(value), false) - assert.equal(readFileSync(persistent, "utf-8"), env) - - const second = secretEnsure.handler({ secret: "JWT_SECRET", bytes: 64 }) - assert.equal(second.isError, undefined) - assert.equal(readFileSync(".env", "utf-8"), env) - } finally { - if (previous === undefined) delete process.env.OPENSERVERLESS_SECRETS_FILE - else process.env.OPENSERVERLESS_SECRETS_FILE = previous - } - }) +async function inTemporaryProjectAsync(run: () => Promise<void>): Promise<void> { + const previous = process.cwd() + const directory = mkdtempSync(join(tmpdir(), "openserverless-mcp-test-")) + try { + process.chdir(directory) + await run() + } finally { + process.chdir(previous) + rmSync(directory, { recursive: true, force: true }) + } +} + +test("server surface omits env mutation while retaining Redis auth setup", () => { + const registry = readFileSync(new URL("../src/index.ts", import.meta.url), "utf-8") + assert.doesNotMatch(registry, /secret-ensure/) + assert.match(registry, /auth-setup/) + assert.equal(existsSync(new URL("../src/tools/secret-ensure.ts", import.meta.url)), false) + assert.equal(existsSync(new URL("../src/tools/auth-setup.ts", import.meta.url)), true) }) test("action_add_secret reports a real MCP error when the secret is absent", () => { @@ -106,9 +101,6 @@ test("secret tools reject Trustable-managed runtime variables", () => { assert.match(resultText(bind), /Trustable-managed runtime variable/) assert.equal(readFileSync("packages/v1/stack-status/__main__.py", "utf-8"), original) - const ensure = secretEnsure.handler({ secret: "OPS_APIHOST", bytes: 32 }) - assert.equal(ensure.isError, true) - assert.match(resultText(ensure), /Trustable-managed runtime variable/) assert.equal(readFileSync(".env", "utf-8"), "OPS_APIHOST=http://miniops.me\n") }) }) @@ -180,27 +172,7 @@ test("secret_bind validates every endpoint before changing any wrapper", () => { }) assert.equal(repeated.isError, undefined) assert.match(resultText(repeated), /Already configured: v1\/login, v1\/me/) - }) -}) - -test("auth_setup generates one undisclosed secret and binds token plus protected endpoints", () => { - inTemporaryProject(() => { - for (const name of ["register", "login", "me", "projects"]) endpoint(`v1/${name}`) - - const result = authSetup.handler({ - secret: "JWT_SECRET", - token_endpoints: ["v1/register", "v1/login"], - protected_endpoints: ["v1/me", "v1/projects"], - generate_if_missing: true, - }) - assert.equal(result.isError, undefined) - const value = readFileSync(".env", "utf-8").trim().split("=", 2)[1] - assert.equal(resultText(result).includes(value), false) - assert.match(resultText(result), /All token creation and validation code must use ctx\.JWT_SECRET/) - - for (const name of ["register", "login", "me", "projects"]) { - assert.match(readFileSync(`packages/v1/${name}/__main__.py`, "utf-8"), /#--param JWT_SECRET/) - } + assert.equal(readFileSync(".env", "utf-8"), "JWT_SECRET=not-returned\n") }) }) @@ -222,6 +194,8 @@ test("action_add_redis installs its runtime dependency and migrates text respons readFileSync("packages/v1/cache/__main__.py", "utf-8"), /redis\.from_url\(.+decode_responses=True\)/, ) + assert.match(resultText(first), /opaque token/i) + assert.match(resultText(first), /Do not use JWT/i) const repeated = actionAddRedis.handler({ endpoint: "v1/cache" }) assert.equal(repeated.isError, undefined) @@ -230,6 +204,47 @@ test("action_add_redis installs its runtime dependency and migrates text respons }) }) +test("auth_setup atomically wires Redis to the complete authentication surface", async () => { + await inTemporaryProjectAsync(async () => { + for (const name of ["register", "login", "me", "employees", "logout"]) endpoint(`v1/${name}`) + + const result = await authSetup.handler({ + token_endpoints: ["v1/register", "v1/login"], + protected_endpoints: ["v1/me", "v1/employees"], + logout_endpoints: ["v1/logout"], + }) + + assert.equal(result.isError, undefined) + assert.equal(existsSync(".env"), false) + assert.match(resultText(result), /bcrypt/) + assert.match(resultText(result), /duplicate separators/) + assert.match(resultText(result), /bounded TTL/) + assert.match(resultText(result), /Do not use JWT/) + + for (const name of ["register", "login", "me", "employees", "logout"]) { + assert.match(readFileSync(`packages/v1/${name}/__main__.py`, "utf-8"), /def init_redis/) + assert.equal(readFileSync(`packages/v1/${name}/requirements.txt`, "utf-8"), "redis\n") + } + }) +}) + +test("auth_setup leaves every endpoint untouched when preflight fails", async () => { + await inTemporaryProjectAsync(async () => { + const original = endpoint("v1/login") + const result = await authSetup.handler({ + token_endpoints: ["v1/login"], + protected_endpoints: ["v1/missing"], + logout_endpoints: ["v1/logout"], + }) + + assert.equal(result.isError, true) + assert.match(resultText(result), /No endpoint was changed/) + assert.equal(readFileSync("packages/v1/login/__main__.py", "utf-8"), original) + assert.equal(existsSync("packages/v1/login/requirements.txt"), false) + assert.equal(existsSync(".env"), false) + }) +}) + test("action_add_s3 exposes the scoped-bucket read/write verification contract", () => { inTemporaryProject(() => { endpoint("v1/storage")
