From 8217a869f479f4c2b68d47e15254e87d4549c041 Mon Sep 17 00:00:00 2001 From: Contentrain Date: Mon, 13 Jul 2026 13:07:57 +0300 Subject: [PATCH] =?UTF-8?q?feat(auth):=20directory-review=20support=20?= =?UTF-8?q?=E2=80=94=20env-gated=20reviewer=20login=20+=20submission=20pre?= =?UTF-8?q?p?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Everything the Claude Directory / OpenAI Apps reviews need from this repo, ahead of listing the remote MCP endpoint. - POST /api/auth/review-login + /auth/review-login page: password login for directory reviewers (OpenAI rejects OAuth/MFA/email-confirmation test accounts). Exists ONLY when NUXT_REVIEW_ACCOUNT_EMAIL + NUXT_REVIEW_ACCOUNT_PASSWORD (>=16 chars, boot-validated) are set on the managed pair; accepts exactly that one address; timing-safe digest comparison; per-IP rate limit; 404 otherwise - /.well-known/openai-apps-challenge: domain-verification token from NUXT_OPENAI_APPS_CHALLENGE (unset = route does not exist) - docs/REMOTE_MCP_SUBMISSION.md: operator runbook — org prerequisites, review-account setup, 9-step manual test matrix, Claude + OpenAI portal walkthroughs, post-listing invariants, and the upstream @contentrain/mcp checklist (openWorldHint missing on all 19 tools; server instructions + sessionFingerprint land with 1.9.0) - tests: review-login gating matrix (env opt-in shapes, wrong creds, rate limit, session cookie), challenge route, middleware public-path pin for /api/auth/review-login --- .../content/system/error-messages/en.json | 1 + .../content/system/ui-strings/en.json | 7 + app/pages/auth/review-login.vue | 87 ++++++++++++ docs/REMOTE_MCP_SUBMISSION.md | 121 +++++++++++++++++ nuxt.config.ts | 6 + server/api/auth/review-login.post.ts | 64 +++++++++ server/middleware/01.auth.ts | 1 + server/plugins/00.validate-config.ts | 11 ++ server/providers/managed-auth.ts | 13 ++ .../.well-known/openai-apps-challenge.get.ts | 15 +++ .../review-login.integration.test.ts | 124 ++++++++++++++++++ .../unit/auth-middleware-public-paths.test.ts | 1 + 12 files changed, 451 insertions(+) create mode 100644 app/pages/auth/review-login.vue create mode 100644 docs/REMOTE_MCP_SUBMISSION.md create mode 100644 server/api/auth/review-login.post.ts create mode 100644 server/routes/.well-known/openai-apps-challenge.get.ts create mode 100644 tests/integration/review-login.integration.test.ts diff --git a/.contentrain/content/system/error-messages/en.json b/.contentrain/content/system/error-messages/en.json index 79ef4f1e..c5c41e55 100644 --- a/.contentrain/content/system/error-messages/en.json +++ b/.contentrain/content/system/error-messages/en.json @@ -31,6 +31,7 @@ "auth.magic_link_failed": "Magic link failed: {detail}", "auth.oauth_redirect_failed": "OAuth redirect failed: {detail}", "auth.rate_limited": "Too many requests. Please wait a moment and try again.", + "auth.review_login_invalid": "Invalid review credentials.", "auth.session_expired": "Your session has expired. Please sign in again.", "auth.session_invalid": "Your session is no longer valid. Please sign in again.", "auth.token_validation_failed": "Token validation failed: {detail}", diff --git a/.contentrain/content/system/ui-strings/en.json b/.contentrain/content/system/ui-strings/en.json index cdabe13d..7956ce85 100644 --- a/.contentrain/content/system/ui-strings/en.json +++ b/.contentrain/content/system/ui-strings/en.json @@ -640,6 +640,13 @@ "projects.empty_description": "Start from a ready-made template or connect an existing GitHub repository. Each project is linked to a repo where your content lives.", "projects.empty_title": "Create your first project", "projects.title": "Projects", + "review_login.description": "Sign in with the review credentials provided in the submission notes.", + "review_login.email_label": "Email", + "review_login.failed": "Sign-in failed. Check the credentials and try again.", + "review_login.password_label": "Password", + "review_login.signing_in": "Signing in…", + "review_login.submit": "Sign in", + "review_login.title": "Reviewer sign-in", "settings.ai_tab": "AI Keys", "settings.billing_tab": "Billing", "settings.connected_apps_tab": "Connected Apps", diff --git a/app/pages/auth/review-login.vue b/app/pages/auth/review-login.vue new file mode 100644 index 00000000..2741180d --- /dev/null +++ b/app/pages/auth/review-login.vue @@ -0,0 +1,87 @@ + + + diff --git a/docs/REMOTE_MCP_SUBMISSION.md b/docs/REMOTE_MCP_SUBMISSION.md new file mode 100644 index 00000000..95e7866b --- /dev/null +++ b/docs/REMOTE_MCP_SUBMISSION.md @@ -0,0 +1,121 @@ +# Remote MCP — Directory Submission Runbook (internal) + +Operator playbook for listing Studio's remote MCP endpoint +(`https://studio.contentrain.io/api/mcp/remote`) in the **Claude +Connectors Directory** and the **OpenAI Apps / plugins universal +directory** (covers ChatGPT + Codex). User-facing docs live in +[REMOTE_MCP.md](./REMOTE_MCP.md). + +> **The origin is contractual.** OpenAI treats a change of scheme/host/port +> as a brand-new app (path changes ride the version flow); Claude caches +> discovery documents keyed by URL. Submit with the production origin only +> — never staging — and never change the PRM `resource` string after +> listing. + +## 1. Organizational prerequisites (weeks of lead time — start first) + +- [ ] **Claude**: Team or Enterprise org with *directory management* + access; submissions run inside claude.ai → Admin settings → + Directory. Escalations: `mcp-review@anthropic.com`. +- [ ] **OpenAI**: organization verification under the publishing name + (Platform Dashboard), `api.apps.write` permission, and a + **global-residency** project (EU-residency projects cannot submit). +- [ ] Public docs URL, privacy policy URL and support contact live on + contentrain.io (submission forms require all three). + +## 2. Technical prerequisites (this repo) + +- [ ] Production runs the managed pair with `NUXT_PUBLIC_SITE_URL=https://studio.contentrain.io`. +- [ ] Discovery chain answers on the production origin: + `/.well-known/oauth-protected-resource/api/mcp/remote`, + `/.well-known/oauth-authorization-server`, and a bare + `POST /api/mcp/remote` returns 401 + `WWW-Authenticate`. +- [ ] **OpenAI domain verification**: set `NUXT_OPENAI_APPS_CHALLENGE` to + the token issued by the plugin portal; confirm + `/.well-known/openai-apps-challenge` serves it; unset after + verification completes. +- [ ] **Review account**: set `NUXT_REVIEW_ACCOUNT_EMAIL` + + `NUXT_REVIEW_ACCOUNT_PASSWORD` (≥16 chars) for the review window. + Reviewers sign in at `/auth/review-login` — no OAuth/MFA/email + confirmation (OpenAI hard requirement). Prepare the account as a + *fully populated* workspace: connected repo with real content + models/entries, plan that includes `api.mcp_cloud_oauth`, GitHub App + installed. Rotate/unset the password after each review cycle. + +## 3. Upstream `@contentrain/mcp` checklist (target: 1.9.0) + +Audit of the installed 1.8.1 annotations (all 19 tools): + +- [x] `title` present on every tool +- [x] `readOnlyHint` / `destructiveHint` correct (delete tools carry + `destructiveHint: true`) +- [x] Tool names ≤ 64 chars +- [ ] **`openWorldHint` missing on all 19 tools** — required by OpenAI's + plugin review (Claude doesn't require it). Add upstream. +- [ ] Consider `destructiveHint: true` for `contentrain_bulk` (bulk + operations can include deletes). +- [ ] **Server `instructions`** — not supported by + `startHttpMcpServerWith` in 1.8.1. When it lands, write them + deliberately and keep ≤512 chars self-contained: OpenAI snapshots + instructions into the versioned API contract at scan time. +- [ ] **1.9.0 Studio integration** (from upstream release notes): pass a + `sessionFingerprint` derived from the same `x-cr-*` headers + `resolveProvider` reads (`server/plugins/mcp-cloud-server.ts`), and + evaluate the exported `TOOL_REQUIREMENTS` for the media-phase + scope→tool mapping (`server/utils/mcp-tool-classes.ts`). +- [ ] After any MCP bump: diff the git internals and do a real staging + content save before trusting the mocked test suite. + +## 4. Manual test matrix (run on staging first, then production origin) + +| # | Client | Steps | Pass criteria | +|---|--------|-------|---------------| +| 1 | MCP Inspector | Point at `/api/mcp/remote`; walk discovery → register/CIMD → consent → `tools/list`; exercise EVERY tool once | Each tool returns a non-generic response; Claude submission requires confirming this | +| 2 | Claude custom connector | Settings → Connectors → add the URL | Connect card appears from the bare 401; consent shows the client host; tools run | +| 3 | Claude Code | `claude mcp add --transport http contentrain ` | Loopback ephemeral-port callback works (port-agnostic redirect match) | +| 4 | ChatGPT developer mode | Add MCP server in Settings → Plugins | CIMD/DCR registration + consent complete; tools listed | +| 5 | Codex | `[mcp_servers.contentrain]` in config.toml → `codex mcp login contentrain` | DCR path completes; tools run | +| 6 | Scope step-up | Connect read-only, call `contentrain_content_save` | 403 `insufficient_scope` → client re-prompts consent → retry succeeds | +| 7 | Refresh rotation | Stay connected > 1h, call a tool | Silent refresh; no re-auth prompt | +| 8 | Revocation | Disconnect in Settings → Connected Apps | Client's next call prompts a clean re-authorization | +| 9 | Write path | `contentrain_content_save` on a test project | `cr/*` branch lands + auto-merge reconcile fires (or waits on review-gated projects) | + +## 5. Claude submission (claude.ai → Admin settings → Directory) + +1. Run the [pre-submission checklist](https://claude.com/docs/connectors/building/review-criteria). +2. Portal steps: connection (production URL, streamable HTTP, "every user + connects to the same URL"), tools sync (annotations must be clean), + listing (name ≤100, tagline ≤55, description ≤2000, categories, docs + + privacy URLs, support contact, icon, **permanent slug**), use cases, + company, authentication (**OAuth with CIMD/DCR** — out-of-the-box + modes), data handling (first-party API), test & launch (review-account + credentials + step-by-step access instructions + confirmation every + tool was exercised), compliance acknowledgments. +3. Listings start as *community connector* (automated scan); *verified* + escalation is automatic — no action needed. + +## 6. OpenAI submission (plugin portal — one submission covers ChatGPT + Codex) + +1. Verify the domain (`NUXT_OPENAI_APPS_CHALLENGE`, §2). +2. Create the plugin draft → add MCP server details → **Scan Tools** + (snapshots names/descriptions/schemas/annotations/`_meta`/instructions + as a versioned contract — changing any of these later requires a new + version submission). +3. Provide: app name/logo/description, company + privacy URLs, review + credentials (§2 review account — no MFA/email confirmation), test + prompts with expected responses (verify on web AND mobile), + justifications for the annotation values. +4. After approval, publish from the portal. Add the per-app redirect URI + (`https://chatgpt.com/connector/oauth/{callback_id}`) to nothing — CIMD + makes it self-serve; it's listed here only as the value you'll see in + their app management page. + +## 7. Post-listing invariants + +- Never change the PRM `resource` string or the endpoint origin. +- Discovery documents are cached ~5 min globally by Claude; scope changes + propagate on that horizon. +- Loopback MCP sessions are per-instance (15 min TTL): keep a single + replica or sticky sessions in front of `/api/mcp/remote`. +- Tool-surface changes (names/descriptions/schemas via an MCP bump) + require an OpenAI version resubmission — batch them. diff --git a/nuxt.config.ts b/nuxt.config.ts index 104366a3..9c48085f 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -60,6 +60,12 @@ export default defineNuxtConfig({ url: '', // NUXT_POSTGRES_URL — required when databaseProvider=postgres }, authJwtSecret: '', // NUXT_AUTH_JWT_SECRET — min 32 chars, managed-auth JWT signing + // Directory-review support (managed pair only, both optional): + openaiAppsChallenge: '', // NUXT_OPENAI_APPS_CHALLENGE — domain-verification token served at /.well-known/openai-apps-challenge + reviewAccount: { + email: '', // NUXT_REVIEW_ACCOUNT_EMAIL — the ONLY address the password login accepts + password: '', // NUXT_REVIEW_ACCOUNT_PASSWORD — min 16 chars; unset = /auth/review-login does not exist + }, supabase: { url: '', serviceRoleKey: '', diff --git a/server/api/auth/review-login.post.ts b/server/api/auth/review-login.post.ts new file mode 100644 index 00000000..613ec63e --- /dev/null +++ b/server/api/auth/review-login.post.ts @@ -0,0 +1,64 @@ +/** + * POST /api/auth/review-login — directory-review password login. + * + * Claude/OpenAI reviewers need credentials that work without OAuth, MFA or + * email confirmation (OpenAI rejects all three). This surface exists ONLY + * when the operator opts in via env and accepts exactly ONE address: + * + * NUXT_REVIEW_ACCOUNT_EMAIL — the single allowed email + * NUXT_REVIEW_ACCOUNT_PASSWORD — min 16 chars (boot-validated) + * + * Unset (the default everywhere, including production until a review + * cycle starts) → 404, indistinguishable from a nonexistent route. The + * comparison is timing-safe over SHA-256 digests, per-IP rate limited, + * and managed-pair only. The user materializes through the same email + * bootstrap chain as a magic-link signup. + */ +import { createHash, timingSafeEqual } from 'node:crypto' +import { readBody } from 'h3' +import { createReviewSession } from '~~/server/providers/managed-auth' +import { errorMessage } from '~~/server/utils/content-strings' +import { checkRateLimit } from '~~/server/utils/rate-limit' + +function digestEquals(a: string, b: string): boolean { + return timingSafeEqual( + createHash('sha256').update(a).digest(), + createHash('sha256').update(b).digest(), + ) +} + +export default defineEventHandler(async (event) => { + const config = useRuntimeConfig() + const review = config.reviewAccount as { email?: string, password?: string } | undefined + + if (config.authProvider !== 'managed' || !review?.email || !review.password || review.password.length < 16) { + throw createError({ statusCode: 404, message: 'Not found' }) + } + + const ip = getClientIp(event) + const rateCheck = await checkRateLimit(`review-login:${ip}`, 5, 60_000) + if (!rateCheck.allowed) { + throw createError({ statusCode: 429, message: errorMessage('auth.rate_limited') }) + } + + const body = await readBody<{ email?: string, password?: string }>(event).catch(() => null) + const email = typeof body?.email === 'string' ? body.email.trim() : '' + const password = typeof body?.password === 'string' ? body.password : '' + + const emailOk = email.length > 0 && digestEquals(email.toLowerCase(), review.email.trim().toLowerCase()) + const passwordOk = password.length > 0 && digestEquals(password, review.password) + if (!emailOk || !passwordOk) { + throw createError({ statusCode: 401, message: errorMessage('auth.review_login_invalid') }) + } + + const session = await createReviewSession(review.email.trim()) + + await setServerSession(event, { + userId: session.user.id, + accessToken: session.tokens.accessToken, + refreshToken: session.tokens.refreshToken, + expiresAt: session.tokens.expiresAt, + }) + + return { ok: true } +}) diff --git a/server/middleware/01.auth.ts b/server/middleware/01.auth.ts index 7073c679..6d4736c4 100644 --- a/server/middleware/01.auth.ts +++ b/server/middleware/01.auth.ts @@ -14,6 +14,7 @@ const PUBLIC_PATHS = [ '/api/auth/oauth/', // managed pair: OAuth dance legs (pre-session by definition) '/api/auth/verify', '/api/auth/refresh', + '/api/auth/review-login', // managed pair: directory-review password login (env-gated, pre-session) // nuxt-auth-utils module session endpoint (GET/DELETE). The module's SSR // plugin fetches it on every server-rendered page load — logged-out // included — and the route self-authenticates via its own sealed cookie diff --git a/server/plugins/00.validate-config.ts b/server/plugins/00.validate-config.ts index 0bb045de..0c81816c 100644 --- a/server/plugins/00.validate-config.ts +++ b/server/plugins/00.validate-config.ts @@ -105,6 +105,17 @@ export default defineNitroPlugin(() => { if (!oauth?.google?.clientId || !oauth.google.clientSecret) warnings.push('NUXT_OAUTH_GOOGLE_CLIENT_ID / NUXT_OAUTH_GOOGLE_CLIENT_SECRET are not set — Google sign-in disabled') + // Directory-review account — optional, but when opted in it must be + // opted in COMPLETELY and with a non-trivial password: this is a + // password login into a real account. + const review = config.reviewAccount as { email?: string, password?: string } | undefined + if (review?.email || review?.password) { + if (!review.email || !review.password) + warnings.push('NUXT_REVIEW_ACCOUNT_EMAIL / NUXT_REVIEW_ACCOUNT_PASSWORD must BOTH be set — review login stays disabled') + else if (review.password.length < 16) + errors.push('NUXT_REVIEW_ACCOUNT_PASSWORD must be at least 16 characters (it gates a password login)') + } + // The managed pair is also the OAuth AS for the remote MCP surface: // public.siteUrl is the issuer in every discovery document and the base // of every redirect the dance constructs. MCP clients require https on diff --git a/server/providers/managed-auth.ts b/server/providers/managed-auth.ts index 1524cfb5..dbb72bb3 100644 --- a/server/providers/managed-auth.ts +++ b/server/providers/managed-auth.ts @@ -338,6 +338,19 @@ export async function createCliAuthCode(user: AuthUser, providerTokens: Provider }) } +/** + * Session for the directory-review account (POST /api/auth/review-login). + * The route owns ALL gating (env opt-in, single allowed address, + * timing-safe password check, rate limit) — this just materializes the + * user through the normal email bootstrap chain and issues Studio tokens. + */ +export async function createReviewSession(email: string): Promise { + const row = await upsertEmailUser(email) + const user = toAuthUser(row) + const tokens = await issueTokens(user) + return { user, tokens } +} + /** Consume a magic-link token (GET /api/auth/magic/verify). */ export async function consumeMagicLinkToken(raw: string): Promise { if (!raw.startsWith('mlc_') && !raw.startsWith('inv_')) return null diff --git a/server/routes/.well-known/openai-apps-challenge.get.ts b/server/routes/.well-known/openai-apps-challenge.get.ts new file mode 100644 index 00000000..420c1a2c --- /dev/null +++ b/server/routes/.well-known/openai-apps-challenge.get.ts @@ -0,0 +1,15 @@ +/** + * OpenAI Apps domain verification. The plugin-submission portal issues a + * token that must be served verbatim at this exact path; operators set it + * via NUXT_OPENAI_APPS_CHALLENGE for the duration of the verification. + * Unset (the default everywhere) → the route does not exist. + */ +export default defineEventHandler((event) => { + const token = useRuntimeConfig().openaiAppsChallenge as string + if (!token) { + throw createError({ statusCode: 404, message: 'Not found' }) + } + + setResponseHeader(event, 'Content-Type', 'text/plain; charset=utf-8') + return token +}) diff --git a/tests/integration/review-login.integration.test.ts b/tests/integration/review-login.integration.test.ts new file mode 100644 index 00000000..9e6a0ca1 --- /dev/null +++ b/tests/integration/review-login.integration.test.ts @@ -0,0 +1,124 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { withTestServer } from '../helpers/http' + +/** + * Directory-review support surfaces: the env-gated password login (exists + * ONLY with full env opt-in on the managed pair; timing-safe check; sets + * the session cookie) and the OpenAI domain-verification token route. + */ + +const state = vi.hoisted(() => ({ + rateCheck: { allowed: true, remaining: 4, retryAfterMs: 0 }, + createReviewSession: vi.fn(), +})) + +vi.mock('~~/server/providers/managed-auth', () => ({ + createReviewSession: state.createReviewSession, +})) + +vi.mock('~~/server/utils/rate-limit', () => ({ + checkRateLimit: vi.fn(async () => state.rateCheck), +})) + +vi.mock('~~/server/utils/content-strings', () => ({ + errorMessage: (key: string) => key, +})) + +const REVIEW_EMAIL = 'reviewer@contentrain.io' +const REVIEW_PASSWORD = 'a-long-review-password' + +function runtimeConfig(overrides: Record = {}) { + return { + authProvider: 'managed', + sessionSecret: 'test-session-secret-32-characters-min', + public: { siteUrl: 'http://localhost:3000' }, + reviewAccount: { email: REVIEW_EMAIL, password: REVIEW_PASSWORD }, + openaiAppsChallenge: '', + ...overrides, + } +} + +describe('POST /api/auth/review-login', () => { + beforeEach(() => { + state.rateCheck = { allowed: true, remaining: 4, retryAfterMs: 0 } + state.createReviewSession.mockResolvedValue({ + user: { id: 'user-review', email: REVIEW_EMAIL, avatarUrl: null, provider: 'email', providerAccountId: null }, + tokens: { accessToken: 'jwt-review', refreshToken: 'crt_review', expiresAt: Math.floor(Date.now() / 1000) + 3600 }, + }) + vi.stubGlobal('useRuntimeConfig', () => runtimeConfig()) + }) + + async function post(body: unknown) { + const handler = (await import('../../server/api/auth/review-login.post')).default + return withTestServer({ routes: [{ path: '/api/auth/review-login', handler }] }, async ({ request }) => + request('/api/auth/review-login', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + })) + } + + it('signs the reviewer in and sets the session cookie', async () => { + const response = await post({ email: REVIEW_EMAIL, password: REVIEW_PASSWORD }) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ ok: true }) + expect(response.headers.getSetCookie().join('; ')).toContain('contentrain-session=') + expect(state.createReviewSession).toHaveBeenCalledWith(REVIEW_EMAIL) + }) + + it('accepts the configured address case-insensitively', async () => { + const response = await post({ email: 'Reviewer@Contentrain.IO', password: REVIEW_PASSWORD }) + expect(response.status).toBe(200) + }) + + it('rejects a wrong password or a foreign email with 401', async () => { + expect((await post({ email: REVIEW_EMAIL, password: 'wrong-password-here' })).status).toBe(401) + expect((await post({ email: 'other@contentrain.io', password: REVIEW_PASSWORD })).status).toBe(401) + expect(state.createReviewSession).not.toHaveBeenCalled() + }) + + it('does not exist without the env opt-in', async () => { + vi.stubGlobal('useRuntimeConfig', () => runtimeConfig({ reviewAccount: { email: '', password: '' } })) + expect((await post({ email: REVIEW_EMAIL, password: REVIEW_PASSWORD })).status).toBe(404) + }) + + it('does not exist with a partial opt-in or a short password', async () => { + vi.stubGlobal('useRuntimeConfig', () => runtimeConfig({ reviewAccount: { email: REVIEW_EMAIL, password: '' } })) + expect((await post({ email: REVIEW_EMAIL, password: REVIEW_PASSWORD })).status).toBe(404) + + vi.stubGlobal('useRuntimeConfig', () => runtimeConfig({ reviewAccount: { email: REVIEW_EMAIL, password: 'short' } })) + expect((await post({ email: REVIEW_EMAIL, password: 'short' })).status).toBe(404) + }) + + it('does not exist on the Supabase pair', async () => { + vi.stubGlobal('useRuntimeConfig', () => runtimeConfig({ authProvider: 'supabase' })) + expect((await post({ email: REVIEW_EMAIL, password: REVIEW_PASSWORD })).status).toBe(404) + }) + + it('rate limits attempts per IP', async () => { + state.rateCheck = { allowed: false, remaining: 0, retryAfterMs: 60_000 } + expect((await post({ email: REVIEW_EMAIL, password: REVIEW_PASSWORD })).status).toBe(429) + }) +}) + +describe('GET /.well-known/openai-apps-challenge', () => { + async function get(token: string) { + vi.stubGlobal('useRuntimeConfig', () => runtimeConfig({ openaiAppsChallenge: token })) + const handler = (await import('../../server/routes/.well-known/openai-apps-challenge.get')).default + return withTestServer({ routes: [{ path: '/.well-known/openai-apps-challenge', handler }] }, async ({ request }) => + request('/.well-known/openai-apps-challenge')) + } + + it('serves the verification token as plain text', async () => { + const response = await get('openai-verify-abc123') + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toContain('text/plain') + await expect(response.text()).resolves.toBe('openai-verify-abc123') + }) + + it('404s when no token is configured', async () => { + const response = await get('') + expect(response.status).toBe(404) + }) +}) diff --git a/tests/unit/auth-middleware-public-paths.test.ts b/tests/unit/auth-middleware-public-paths.test.ts index 193d6138..1f52e269 100644 --- a/tests/unit/auth-middleware-public-paths.test.ts +++ b/tests/unit/auth-middleware-public-paths.test.ts @@ -48,6 +48,7 @@ describe('auth middleware public paths', () => { '/api/webhooks/github', // GitHub webhook — HMAC signature '/api/billing/webhook/polar', // billing webhook — provider signature '/api/_auth/session', // nuxt-auth-utils module session — own sealed cookie + '/api/auth/review-login', // directory-review password login — env-gated, pre-session ])('lets self-authenticating external endpoint %s through without a session lookup', async (path) => { await expect(run(path)).resolves.toBeUndefined() expect(getServerSession).not.toHaveBeenCalled()