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")

Reply via email to