Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
74dba62
Bump to 0.5.0-beta.6
sbaker Mar 13, 2026
f1c7f34
Bump @prompd/app version in licenses.json
sbaker Mar 13, 2026
d8e9f05
Add package cache IPC and explorer UI
sbaker Mar 14, 2026
dba0e4c
v0.5.0-beta.7: Registry search improvements and package install fixes
sbaker Mar 15, 2026
8982ad4
Bump to 0.5.0-beta.8, update @prompd/cli to ^0.5.0-beta.7
sbaker Mar 17, 2026
08ee9d3
Fix PR #39 review comments: security hardening and stale closure
sbaker Mar 17, 2026
c6d5bbd
Fix CI: resolve @prompd/cli from npm and exclude WIP tiptap files
sbaker Mar 17, 2026
0c362da
Add beta.8 feature work: intellisense, editor, workflow, and UI impro…
sbaker Mar 17, 2026
1bf542f
Fix CI: delete lockfile after overriding CLI dep for Linux rollup compat
sbaker Mar 17, 2026
de5fea6
Add tiptap dependencies and fix CI TypeScript errors
sbaker Mar 17, 2026
40c2bb9
Fix CI: add --legacy-peer-deps for tiptap-markdown peer conflict
sbaker Mar 17, 2026
4da5cbd
Add @prompd/test framework and Test Explorer UI for beta.9
sbaker Mar 19, 2026
962cb9f
Fix build: resolve @prompd/test from npm and hide play button for .te…
sbaker Mar 19, 2026
14b5cfa
Bump to 0.5.0-beta.10: version bumps and test explorer improvements
sbaker Mar 23, 2026
b220175
Bump to 0.5.0-beta.10: version bumps and test explorer improvements
sbaker Mar 27, 2026
0354087
Fix PR review: abort signal, evaluate target, script exit codes, word…
sbaker Mar 31, 2026
5fc507d
Merge main into 0.5.0-beta.10: resolve conflicts
sbaker Mar 31, 2026
6e7abfd
feat(backend): agent gateway, external tools, remote MCP + pricing/au…
sbaker Jun 8, 2026
7f4e5a7
feat(backend): server-side AI quota + model allowlist on the chat gat…
sbaker Jun 17, 2026
693f5c1
feat(gateway): server-side execution quota + free-tier model allowlist
sbaker Jun 17, 2026
87b3a51
Merge branch 'main' into 0.5.0-beta.10
sbaker Jun 22, 2026
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
84 changes: 84 additions & 0 deletions backend/src/models/User.js
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,31 @@ const UserSchema = new mongoose.Schema({
}, { _id: false }),
default: () => new Map()
},
// External tool API keys (e.g. Tavily web search). User's own key, else a
// Prompd-paid env key is used at call time. Same encrypted shape as llmProviders.
externalTools: {
type: Map,
of: new mongoose.Schema({
hasKey: { type: Boolean, default: false },
encryptedKey: String, // AES-256-GCM
iv: String,
addedAt: { type: Date, default: Date.now }
}, { _id: false }),
default: () => new Map()
},
// Remote MCP servers the agent can call (proxied through the backend). Keyed
// by a stable id; encryptedKey is an optional bearer token for the server.
mcpServers: {
type: Map,
of: new mongoose.Schema({
label: String,
url: String, // remote HTTP MCP endpoint
encryptedKey: String,
iv: String,
addedAt: { type: Date, default: Date.now }
}, { _id: false }),
default: () => new Map()
},
// Default provider preference
defaultProvider: {
type: String,
Expand Down Expand Up @@ -467,6 +492,13 @@ UserSchema.virtual('canCreateProjects').get(function() {
return true // Simplified for now
})

// The authenticated user's id, sourced from the Clerk token (decoded.sub is
// stored as clerkUserId). Many routes/middleware read req.user.userId; the User
// document has no such path, so without this they all silently get undefined.
UserSchema.virtual('userId').get(function() {
return this.clerkUserId
})

// Instance methods
UserSchema.methods.comparePassword = async function(candidatePassword) {
return bcrypt.compare(candidatePassword, this.password)
Expand Down Expand Up @@ -619,6 +651,58 @@ UserSchema.methods.getProviderKeyData = function(providerId) {
return providers.get(providerId) || null
}

/* ---- external tool keys (e.g. Tavily) ---- */
UserSchema.methods.setToolKey = function(tool, encryptedKey, iv) {
if (!this.aiFeatures) this.aiFeatures = {}
if (!this.aiFeatures.externalTools) this.aiFeatures.externalTools = new Map()
const data = { hasKey: true, encryptedKey, iv, addedAt: new Date() }
this.aiFeatures.externalTools.set(tool, data)
this.markModified('aiFeatures.externalTools')
return data
}
UserSchema.methods.getToolKeyData = function(tool) {
const tools = this.aiFeatures?.externalTools
if (!tools) return null
return (typeof tools.get === 'function' ? tools.get(tool) : tools[tool]) || null
}
UserSchema.methods.removeToolKey = function(tool) {
const tools = this.aiFeatures?.externalTools
if (tools?.has?.(tool)) {
tools.delete(tool)
this.markModified('aiFeatures.externalTools')
return true
}
return false
}

/* ---- remote MCP servers ---- */
UserSchema.methods.setMcpServer = function(id, server) {
if (!this.aiFeatures) this.aiFeatures = {}
if (!this.aiFeatures.mcpServers) this.aiFeatures.mcpServers = new Map()
this.aiFeatures.mcpServers.set(id, { ...server, addedAt: new Date() })
this.markModified('aiFeatures.mcpServers')
}
UserSchema.methods.getMcpServer = function(id) {
const m = this.aiFeatures?.mcpServers
if (!m) return null
return (typeof m.get === 'function' ? m.get(id) : m[id]) || null
}
UserSchema.methods.listMcpServers = function() {
const m = this.aiFeatures?.mcpServers
if (!m) return []
const entries = typeof m.entries === 'function' ? [...m.entries()] : Object.entries(m)
return entries.map(([id, s]) => ({ id, ...(typeof s.toObject === 'function' ? s.toObject() : s) }))
}
UserSchema.methods.removeMcpServer = function(id) {
const m = this.aiFeatures?.mcpServers
if (m?.has?.(id)) {
m.delete(id)
this.markModified('aiFeatures.mcpServers')
return true
}
return false
}

UserSchema.methods.canPerformAction = async function(action, resourceType = null) {
if (this.isSuspended) return false

Expand Down
192 changes: 192 additions & 0 deletions backend/src/routes/chatCompletions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
/**
* OpenAI-compatible chat-completions gateway.
*
* The browser agent harness speaks the OpenAI Chat Completions spec; this
* endpoint is a THIN passthrough: Clerk auth guard -> look up the user's
* configured OpenAI key -> forward the request straight to OpenAI (server-side
* key, never exposed to the browser). Supports streaming (stream: true) by
* piping OpenAI's SSE response through unchanged.
*
* POST /api/v1/chat/completions (Clerk Bearer)
* body: standard OpenAI ChatCompletion request { model, messages, tools, stream, ... }
*
* For now OpenAI only; a multi-provider impl (or a LiteLLM sidecar) can slot in
* behind this same endpoint later without the harness changing.
*/
import express from 'express'
import crypto from 'node:crypto'
import { Readable } from 'node:stream'
import { clerkAuth } from '../middleware/clerkAuth.js'
import { validateAiQuota, incrementAiUsage } from '../middleware/aiQuota.js'

const router = express.Router()

const OPENAI_URL = 'https://api.openai.com/v1/chat/completions'
const MAX_MESSAGES = 200
const MAX_BODY_BYTES = 1 * 1024 * 1024 // 1MB request cap
const MAX_OUTPUT_TOKENS = 8192
// Models the FREE server key is allowed to run — the server-side ENFORCEMENT of the
// cost tier. Own-key users are UNRESTRICTED (their key, any model). The client picker
// allowlist (prompd-web src/lib/models.ts ALLOWED_GATEWAY_MODELS) is only UX; this is
// the real gate. Widen both together.
const ALLOWED_MODELS = new Set(['gpt-4.1-mini', 'gpt-4o-mini'])

/** Read a provider config from the user's aiFeatures.llmProviders (Map or object). */
function getUserProviderConfig(providers, providerId) {
if (!providers) return null
if (typeof providers.get === 'function') return providers.get(providerId)
return providers[providerId]
}

/** Decrypt an AES-256-GCM key the same way EncryptionService stores it. */
function decryptApiKey(encryptedKeyHex, ivHex) {
if (!encryptedKeyHex || !ivHex) return null
try {
const secret = process.env.ENCRYPTION_SECRET || process.env.JWT_SECRET
if (!secret) return null
const KEY = crypto.scryptSync(secret, 'prompd-salt', 32)
const ivBuffer = Buffer.from(ivHex, 'hex')
const encryptedText = encryptedKeyHex.slice(0, -32)
const authTag = Buffer.from(encryptedKeyHex.slice(-32), 'hex')
const decipher = crypto.createDecipheriv('aes-256-gcm', KEY, ivBuffer)
decipher.setAuthTag(authTag)
let decrypted = decipher.update(encryptedText, 'hex', 'utf8')
decrypted += decipher.final('utf8')
return decrypted
} catch (error) {
console.error('[chatCompletions] Failed to decrypt user OpenAI key:', error.message)
return null
}
}

function getOpenAIKey(user) {
const cfg = getUserProviderConfig(user?.aiFeatures?.llmProviders, 'openai')
if (!cfg?.hasKey) return null
return decryptApiKey(cfg.encryptedKey, cfg.iv)
}

router.post('/', clerkAuth, async (req, res) => {
const body = req.body || {}

// Light guards — it's the user's own key/quota, but stop runaway loops. The model
// ALLOWLIST is enforced below for the server-key path only (own-key = any model).
if (typeof body.model !== 'string' || !body.model) {
return res.status(400).json({ error: { message: 'model is required', type: 'invalid_request_error' } })
}
if (!Array.isArray(body.messages) || body.messages.length === 0) {
return res.status(400).json({ error: { message: 'messages[] is required', type: 'invalid_request_error' } })
}
if (body.messages.length > MAX_MESSAGES) {
return res.status(400).json({ error: { message: `too many messages (max ${MAX_MESSAGES})`, type: 'invalid_request_error' } })
}
if (Buffer.byteLength(JSON.stringify(body), 'utf8') > MAX_BODY_BYTES) {
return res.status(413).json({ error: { message: 'request too large', type: 'invalid_request_error' } })
}
if (typeof body.max_tokens === 'number') {
body.max_tokens = Math.min(body.max_tokens, MAX_OUTPUT_TOKENS)
}

// Key + quota resolution (the server-side guard).
// - Bring-your-own key -> UNLIMITED (the user pays OpenAI directly, nothing of
// ours to meter), and quota is never touched.
// - No own key -> fall back to the SERVER key, but gated by the account's
// execution quota (free tier) so server-key usage can't be run unbounded. This
// is the real enforcement point: the browser guard is only a CTA, this 402 is
// the boundary that actually blocks (validateAiQuota also frees enterprise/admin
// and any own-key user). NOTE: one POST = one agent turn, so a multi-turn agent
// run consumes several executions against the lifetime quota.
let apiKey = getOpenAIKey(req.user)
let meterQuota = false
if (!apiKey) {
const serverKey = process.env.OPENAI_API_KEY
if (!serverKey) {
return res.status(402).json({
error: { message: 'No OpenAI API key configured for your account. Add one in provider settings.', type: 'no_api_key' },
})
}
// Free server-key path: only the allowlisted cheap models may run on OUR key.
if (!ALLOWED_MODELS.has(body.model)) {
return res.status(403).json({
error: {
message: `Model "${body.model}" isn't available on the free tier. Add your own OpenAI key in provider settings for full access, or choose one of: ${[...ALLOWED_MODELS].join(', ')}.`,
type: 'model_not_allowed',
code: 'MODEL_NOT_ALLOWED',
allowed_models: [...ALLOWED_MODELS],
can_add_api_key: true,
},
})
}
const quota = await validateAiQuota(req.user, 'execute')
if (!quota.allowed) {
return res.status(402).json({
error: {
message: `Free execution limit reached (${quota.reason}). Add your own OpenAI key in provider settings for unlimited use${quota.upgradeRequired ? `, or upgrade to ${quota.upgradeRequired}` : ''}.`,
type: 'quota_exceeded',
code: 'QUOTA_EXCEEDED',
upgrade_required: quota.upgradeRequired || null,
can_add_api_key: quota.canAddApiKey ?? true,
},
})
}
apiKey = serverKey
meterQuota = true
}

// Count one execution against quota only when the SERVER key was used and OpenAI
// accepted the request. Best-effort: a save failure must not break the response.
const meter = async () => {
if (!meterQuota) return
try { await incrementAiUsage(req.user, 'execute') }
catch (e) { console.error('[chatCompletions] usage increment failed:', e.message) }
}

const wantStream = body.stream === true

let upstream
try {
upstream = await fetch(OPENAI_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
body: JSON.stringify(body),
})
} catch (error) {
return res.status(502).json({ error: { message: `Upstream request failed: ${error.message}`, type: 'upstream_error' } })
}

// Non-streaming: forward status + JSON as-is.
if (!wantStream) {
const text = await upstream.text()
if (upstream.ok) await meter()
res.status(upstream.status)
res.setHeader('Content-Type', upstream.headers.get('content-type') || 'application/json')
return res.send(text)
}

// Streaming: pipe OpenAI's SSE response straight through.
if (!upstream.ok || !upstream.body) {
const text = await upstream.text().catch(() => '')
res.status(upstream.status)
res.setHeader('Content-Type', upstream.headers.get('content-type') || 'application/json')
return res.send(text)
}
// upstream accepted the request and is streaming — count one execution.
await meter()
res.status(200)
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8')
res.setHeader('Cache-Control', 'no-cache, no-transform')
res.setHeader('Connection', 'keep-alive')
res.flushHeaders?.()
try {
await new Promise((resolve, reject) => {
const nodeStream = Readable.fromWeb(upstream.body)
nodeStream.on('error', reject)
res.on('close', () => nodeStream.destroy())
nodeStream.pipe(res).on('finish', resolve).on('error', reject)
})
} catch (error) {
if (!res.writableEnded) res.end()
console.error('[chatCompletions] stream pipe error:', error.message)
}
})

export default router
19 changes: 19 additions & 0 deletions backend/src/routes/compilation.js
Original file line number Diff line number Diff line change
Expand Up @@ -492,4 +492,23 @@ router.post('/execute', compilationRateLimit, validate(executeSchema), async (re
}
})

/**
* POST /api/compilation/execute-prompt
* Pass-through single execution: runs an ALREADY-RENDERED prompt against the
* provider WITHOUT compiling it (no frontmatter/inherits resolution). The client
* compiles first (e.g. via /preview-public); this endpoint just calls the model.
*/
router.post('/execute-prompt', compilationRateLimit, validate(executeSchema), async (req, res, next) => {
try {
const { prompt, provider, model, parameters } = req.body
const result = await compilationService.execute(
prompt, provider, model, parameters || {},
req.user._id, null, req.user, null, null, null, true // skipCompile — pass-through
)
res.json({ success: true, data: result })
} catch (error) {
next(error)
}
})

export default router
7 changes: 7 additions & 0 deletions backend/src/routes/llmProviders.js
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,11 @@ router.get('/available', clerkAuth, async (req, res) => {
.sort({ sortOrder: 1, displayName: 1 })
.lean()

// The user's own configured keys — so the client can show a provider's FULL
// catalog when the user brought their own key (the gateway forwards any model
// for an own-key user), vs the limited free server-key set otherwise.
const userProviders = req.user?.aiFeatures?.llmProviders

// Get pricing for each provider
const providersWithPricing = await Promise.all(
providerConfigs.map(async (config) => {
Expand All @@ -119,6 +124,7 @@ router.get('/available', clerkAuth, async (req, res) => {
return {
providerId: providerId,
displayName: config.displayName,
hasKey: getUserProviderConfig(userProviders, providerId)?.hasKey || false,
keyPrefix: config.metadata?.keyPrefix,
consoleUrl: config.metadata?.consoleUrl,
isLocal: config.metadata?.isLocal || false,
Expand All @@ -130,6 +136,7 @@ router.get('/available', clerkAuth, async (req, res) => {
contextWindow: p.capabilities?.contextWindow,
supportsVision: p.capabilities?.supportsVision,
supportsTools: p.capabilities?.supportsTools,
supportsReasoning: p.capabilities?.supportsReasoning || false,
supportsImageGeneration: p.capabilities?.supportsImageGeneration || false
}))
}
Expand Down
Loading
Loading