Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .contentrain/content/system/error-messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}",
Expand Down
7 changes: 7 additions & 0 deletions .contentrain/content/system/ui-strings/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
87 changes: 87 additions & 0 deletions app/pages/auth/review-login.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
<script setup lang="ts">
/**
* Directory-review password login — /auth/review-login.
*
* Companion page for the env-gated review account (see
* server/api/auth/review-login.post.ts). Reviewers get this URL plus the
* credentials in the submission portal's test-account instructions. On
* deployments without the env opt-in the POST 404s and the page shows the
* generic failure message — nothing about the surface is discoverable.
*/
definePageMeta({
layout: 'auth',
auth: false,
})

const { t } = useContent()

useHead({ title: () => t('review_login.title') })

const email = ref('')
const password = ref('')
const loading = ref(false)
const error = ref('')

async function handleSubmit() {
if (!email.value.trim() || !password.value) return
loading.value = true
error.value = ''
try {
await $fetch('/api/auth/review-login', {
method: 'POST',
body: { email: email.value.trim(), password: password.value },
})
const { init } = useAuth()
await init()
await navigateTo('/')
}
catch (e: unknown) {
error.value = resolveApiError(e, t('review_login.failed'))
}
finally {
loading.value = false
}
}
</script>

<template>
<div class="w-full max-w-sm">
<AtomsHeadingText tag="h1" size="lg">
{{ t('review_login.title') }}
</AtomsHeadingText>
<p class="mt-1 text-sm text-muted">
{{ t('review_login.description') }}
</p>

<form class="mt-6 space-y-4" @submit.prevent="handleSubmit">
<div>
<AtomsFormLabel for="review-email" :text="t('review_login.email_label')" size="sm" />
<AtomsFormInput
id="review-email"
v-model="email"
type="email"
autocomplete="username"
class="mt-1.5"
/>
</div>
<div>
<AtomsFormLabel for="review-password" :text="t('review_login.password_label')" size="sm" />
<AtomsFormInput
id="review-password"
v-model="password"
type="password"
autocomplete="current-password"
class="mt-1.5"
/>
</div>

<p v-if="error" class="text-sm text-danger-600 dark:text-danger-400">
{{ error }}
</p>

<AtomsBaseButton type="submit" variant="primary" block :disabled="loading || !email.trim() || !password">
{{ loading ? t('review_login.signing_in') : t('review_login.submit') }}
</AtomsBaseButton>
</form>
</div>
</template>
121 changes: 121 additions & 0 deletions docs/REMOTE_MCP_SUBMISSION.md
Original file line number Diff line number Diff line change
@@ -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: `[email protected]`.
- [ ] **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 <url>` | 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.
6 changes: 6 additions & 0 deletions nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '',
Expand Down
64 changes: 64 additions & 0 deletions server/api/auth/review-login.post.ts
Original file line number Diff line number Diff line change
@@ -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 }
})
1 change: 1 addition & 0 deletions server/middleware/01.auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions server/plugins/00.validate-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions server/providers/managed-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AuthSession> {
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<AuthSession | null> {
if (!raw.startsWith('mlc_') && !raw.startsWith('inv_')) return null
Expand Down
15 changes: 15 additions & 0 deletions server/routes/.well-known/openai-apps-challenge.get.ts
Original file line number Diff line number Diff line change
@@ -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
})
Loading
Loading