diff --git a/.gitignore b/.gitignore
index e91998e..c31274b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -75,6 +75,10 @@ kitty-specs/**/tasks/*.md
!kitty-specs/009-automated-pipelines-framework/tasks/WP08-api-mcp-tools.md
!kitty-specs/009-automated-pipelines-framework/tasks/WP09-analytics.md
!kitty-specs/009-automated-pipelines-framework/tasks/WP10-integration-validation.md
+!kitty-specs/011-inngest-migration/tasks/WP01-port-remaining-pipelines.md
+!kitty-specs/011-inngest-migration/tasks/WP02-update-routes.md
+!kitty-specs/011-inngest-migration/tasks/WP03-delete-custom-plumbing.md
+!kitty-specs/011-inngest-migration/tasks/WP04-integration-tests.md
# Generated artifacts
**/tmp/
diff --git a/.kittify/memory/constitution.md b/.kittify/memory/constitution.md
index e6ade5c..dd86295 100644
--- a/.kittify/memory/constitution.md
+++ b/.kittify/memory/constitution.md
@@ -241,6 +241,55 @@ This list is not exhaustive — the skill and tenant configuration system is des
- Graceful degradation over hard failures
- Human handoff for critical failures
+### 5.4 MCP Architecture
+
+**Background**: Each MCP tool schema costs 400–1,400 tokens to load into a client's context window. The platform currently ships ~80 tools across three servers; at ~600 tokens average, a monolithic client session would consume ~48K tokens of context before a single user message. Anthropic's Tool Search (shipped January 2026) mitigates this by deferring all tool definitions above 10K tokens and replacing them with a single semantic-search tool — clients load only 3–5 relevant tools per turn. Every client connecting to joyus-ai already triggers Tool Search. These principles ensure the platform stays compatible with that mechanism and avoids re-creating the problem internally.
+
+#### Four-server boundary
+
+| Server | Tools | Availability | Notes |
+|--------|-------|--------------|-------|
+| `joyus-ai-platform` | `content_*`, `pipeline_*`, `ops_*`, future `governance_*` | Always loaded, tenant-scoped | Platform brain — no external auth required |
+| `joyus-ai-integrations` | `jira_*`, `github_*`, `slack_*`, `google_*`, future connectors | Connection-gated per integration | Currently co-located in `joyus-ai-mcp-server`; split when integration count warrants |
+| `joyus-ai-profile` | `build_profile`, `verify_content`, `identify_author`, `check_drift`, etc. | Always loaded, tenant-scoped | Already separate (Python) |
+| `joyus-ai-state` | `get_context`, `run_gates`, `verify_branch`, etc. | Dev/local only | Already separate; not shipped to end clients |
+
+**Current state**: Platform Core and Integrations are co-located in `joyus-ai-mcp-server` (55 tools). Tool Search handles the context tax. Split into separate servers when: (a) integration count exceeds 6–8 connectors, or (b) a client use case needs platform tools but never integration tools.
+
+#### Domain prefixing is mandatory
+
+Every tool has a domain prefix that determines its server home. No "misc" tools — new domains get new prefixes before any tools are added.
+
+| Prefix | Domain | Server |
+|--------|--------|--------|
+| `content_` | Content infrastructure | Platform |
+| `pipeline_` | Automated pipelines | Platform |
+| `governance_` | Org-scale governance (Spec 007) | Platform |
+| `jira_` | Jira integration | Integrations |
+| `github_` | GitHub integration | Integrations |
+| `slack_` | Slack integration | Integrations |
+| `google_` | Google Workspace integration | Integrations |
+
+#### Tool descriptions are search-optimized
+
+Tool descriptions must encode *use-case keywords*, not just "what it does" prose. Tool Search selects tools via semantic search — descriptions that read like marketing copy fail; descriptions that name the task the agent is trying to accomplish succeed.
+
+Good: `"Create a new automated pipeline for a tenant. Use when an operator or agent needs to define a trigger-to-step-sequence workflow, schedule recurring jobs, or set up corpus-change automation."`
+
+Bad: `"Creates a pipeline object in the database with the specified configuration."`
+
+Write descriptions as if answering: *"What would a Claude agent type into a search box to find this tool?"*
+
+#### No MCP loading for internal integrations
+
+External service integrations used by pipeline step handlers (Slack notifications, email delivery, webhook calls, profile generation) are implemented as **TypeScript service interfaces with direct HTTP/SDK calls** — not as MCP server tool loading. MCP is a client-facing surface; it is not an internal integration mechanism.
+
+Rationale: loading a Slack MCP server internally would add 15,000+ tokens to every pipeline execution context. The TypeScript interface + null client stub pattern (already established in Spec 009) is the correct approach. This rule applies to all future step handler integrations.
+
+#### Per-agent MCP isolation requires custom orchestration
+
+The Anthropic Agent SDK's `AgentDefinition` has no `mcp_servers` field — sub-agents inherit the parent's MCP configuration and can only filter it, not extend it. When building orchestration layers where different sub-agents need different MCP connections (e.g., a Jira sub-agent and a Slack sub-agent in the same pipeline), use **separate `query()` calls with independent `ClaudeAgentOptions`** per sub-agent rather than the built-in `Agent` tool delegation. This achieves true MCP isolation at the cost of manual orchestration code.
+
---
## 6. Quality Standards
@@ -297,9 +346,10 @@ This constitution can be amended by:
---
-*Constitution Version: 1.6*
+*Constitution Version: 1.7*
*Established: January 29, 2026*
-*Last Updated: February 19, 2026*
+*Last Updated: March 19, 2026*
+*Changes v1.7: Added §5.4 "MCP Architecture" — four-server domain boundary (Platform Core, Integrations, Profile Engine, Dev Enforcement); mandatory domain prefixing table; Tool Search-optimized description guidelines; rule against MCP loading for internal integrations; per-agent MCP isolation pattern for orchestration layers*
*Changes v1.6: Added §2.10 "Client-Informed, Platform-Generic" — client needs inform abstract platform capabilities; no client names, terminology, or domain-specific examples in the public repo; agents must generalize at point of creation*
*Changes v1.5: Open source audience rewrite — generalized "Zivtech" to "deploying organization" throughout; renamed §2.2 from "Skills as Guardrails" to "Skills as Encoded Knowledge" (skills now include operational context, business rules, report definitions, compliance, not just constraints); renamed §2.6 from "Claude Code Alternative" to "Mediated AI Access" (model-agnostic framing); added single-org and multi-org deployment models; added first validated use case (ice cream manufacturer/distributor/retailer) and early use case breadth (healthcare, legal, higher ed, museum, assessment/credentialing); expanded stakeholders to include open source community and deploying organizations; broadened monitoring to include operational accuracy; added skill ecosystem concept (community packs, archetype packs); added §3.2 Compliance Framework Awareness (HIPAA, FERPA, attorney-client privilege, assessment integrity, FDA/USDA) with hard-failure enforcement; updated Section 8 for multi-industry, model-agnostic positioning*
*Changes v1.4: Added Principle 2.8 (Open Source by Default) with repository separation model; added Principle 2.9 (Assumption Awareness) for proactive tracking of design assumptions; updated Section 8 to reflect open source posture; removed "consumer product" constraint (open source inherently broadens audience)*
diff --git a/joyus-ai-mcp-server/package-lock.json b/joyus-ai-mcp-server/package-lock.json
index 21aba8a..7b99d40 100644
--- a/joyus-ai-mcp-server/package-lock.json
+++ b/joyus-ai-mcp-server/package-lock.json
@@ -18,6 +18,7 @@
"dotenv": "^16.4.1",
"drizzle-orm": "^0.45.1",
"express": "^4.18.2",
+ "express-rate-limit": "^8.3.1",
"express-session": "^1.17.3",
"helmet": "^7.1.0",
"inngest": "^3.0.0",
@@ -992,6 +993,7 @@
"node_modules/@modelcontextprotocol/sdk/node_modules/express": {
"version": "5.2.1",
"license": "MIT",
+ "peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -1030,6 +1032,21 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/@modelcontextprotocol/sdk/node_modules/express-rate-limit": {
+ "version": "7.5.1",
+ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
+ "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/express-rate-limit"
+ },
+ "peerDependencies": {
+ "express": ">= 4.11"
+ }
+ },
"node_modules/@modelcontextprotocol/sdk/node_modules/finalhandler": {
"version": "2.1.1",
"license": "MIT",
@@ -6024,8 +6041,13 @@
}
},
"node_modules/express-rate-limit": {
- "version": "7.5.1",
+ "version": "8.3.1",
+ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz",
+ "integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==",
"license": "MIT",
+ "dependencies": {
+ "ip-address": "10.1.0"
+ },
"engines": {
"node": ">= 16"
},
@@ -6980,6 +7002,15 @@
"node": ">= 0.4"
}
},
+ "node_modules/ip-address": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
+ "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
"node_modules/ipaddr.js": {
"version": "1.9.1",
"license": "MIT",
diff --git a/joyus-ai-mcp-server/package.json b/joyus-ai-mcp-server/package.json
index e929044..025326c 100644
--- a/joyus-ai-mcp-server/package.json
+++ b/joyus-ai-mcp-server/package.json
@@ -34,11 +34,12 @@
"dotenv": "^16.4.1",
"drizzle-orm": "^0.45.1",
"express": "^4.18.2",
+ "express-rate-limit": "^8.3.1",
"express-session": "^1.17.3",
"helmet": "^7.1.0",
+ "inngest": "^3.0.0",
"jose": "^6.1.3",
"luxon": "^3.4.4",
- "inngest": "^3.0.0",
"node-cron": "^3.0.3",
"pg": "^8.18.0",
"zod": "^3.22.0"
diff --git a/joyus-ai-mcp-server/src/auth/routes.ts b/joyus-ai-mcp-server/src/auth/routes.ts
index 29d6f32..cfb3d02 100644
--- a/joyus-ai-mcp-server/src/auth/routes.ts
+++ b/joyus-ai-mcp-server/src/auth/routes.ts
@@ -165,7 +165,7 @@ authRouter.get('/', async (req: Request, res: Response) => {
${connectedServices.has('GOOGLE')
- ? 'Disconnect'
+ ? '
'
: 'Connect'}
@@ -177,7 +177,7 @@ authRouter.get('/', async (req: Request, res: Response) => {
${connectedServices.has('JIRA')
- ? 'Disconnect'
+ ? ''
: 'Connect'}
@@ -189,7 +189,7 @@ authRouter.get('/', async (req: Request, res: Response) => {
${connectedServices.has('SLACK')
- ? 'Disconnect'
+ ? ''
: 'Connect'}
@@ -201,7 +201,7 @@ authRouter.get('/', async (req: Request, res: Response) => {
${connectedServices.has('GITHUB')
- ? 'Disconnect'
+ ? ''
: 'Connect'}
@@ -652,7 +652,7 @@ authRouter.get('/github/callback', async (req: Request, res: Response) => {
// Disconnect & Logout
// ============================================================
-authRouter.get('/:service/disconnect', requireSessionOrRedirect, async (req: Request, res: Response) => {
+authRouter.post('/:service/disconnect', requireSessionOrRedirect, async (req: Request, res: Response) => {
const userId = req.session!.userId!;
const service = req.params.service.toUpperCase() as Service;
diff --git a/joyus-ai-mcp-server/src/config.ts b/joyus-ai-mcp-server/src/config.ts
new file mode 100644
index 0000000..bfc204b
--- /dev/null
+++ b/joyus-ai-mcp-server/src/config.ts
@@ -0,0 +1,33 @@
+/**
+ * Environment configuration validation
+ *
+ * Validates required env vars at import time using Zod.
+ * Import this module early in the application entry point.
+ */
+
+import { z } from 'zod';
+
+const envSchema = z.object({
+ NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
+ PORT: z.coerce.number().default(3000),
+ DATABASE_URL: z.string().optional(),
+ SESSION_SECRET: z.string().optional(),
+ TOKEN_ENCRYPTION_KEY: z.string().optional(),
+ INNGEST_EVENT_KEY: z.string().optional(),
+ INNGEST_SIGNING_KEY: z.string().optional(),
+});
+
+// Validate at import time
+const parsed = envSchema.safeParse(process.env);
+
+if (!parsed.success) {
+ console.error('[joyus] Invalid environment configuration:');
+ for (const issue of parsed.error.issues) {
+ console.error(` ${issue.path.join('.')}: ${issue.message}`);
+ }
+ if (process.env.NODE_ENV === 'production') {
+ throw new Error('FATAL: Invalid environment configuration');
+ }
+}
+
+export const config = parsed.success ? parsed.data : envSchema.parse({});
diff --git a/joyus-ai-mcp-server/src/content/generation/index.ts b/joyus-ai-mcp-server/src/content/generation/index.ts
index b1cc99f..4f42a08 100644
--- a/joyus-ai-mcp-server/src/content/generation/index.ts
+++ b/joyus-ai-mcp-server/src/content/generation/index.ts
@@ -7,7 +7,8 @@ import { drizzle } from 'drizzle-orm/node-postgres';
import { createId } from '@paralleldrive/cuid2';
import { contentGenerationLogs, contentOperationLogs } from '../schema.js';
import type { ResolvedEntitlements, GenerationResult } from '../types.js';
-import { ContentRetriever, type SearchService, type RetrievalResult, type RetrievedItem } from './retriever.js';
+import { ContentRetriever, type RetrievalResult, type RetrievedItem } from './retriever.js';
+import type { SearchService } from '../search/index.js';
import {
ContentGenerator,
PlaceholderGenerationProvider,
@@ -110,7 +111,6 @@ export class GenerationService {
// Re-exports so callers can import everything from this module
export {
ContentRetriever,
- type SearchService,
type RetrievalResult,
type RetrievedItem,
} from './retriever.js';
diff --git a/joyus-ai-mcp-server/src/content/generation/retriever.ts b/joyus-ai-mcp-server/src/content/generation/retriever.ts
index 2ae197f..ffbecfd 100644
--- a/joyus-ai-mcp-server/src/content/generation/retriever.ts
+++ b/joyus-ai-mcp-server/src/content/generation/retriever.ts
@@ -1,14 +1,15 @@
/**
* ContentRetriever — fetches relevant content items for generation.
*
- * Filters accessible sources by entitlements, runs a search, then hydrates
- * full item bodies from the database for context assembly.
+ * Delegates search to the real SearchService (entitlement-filtered FTS),
+ * then hydrates full item bodies from the database for context assembly.
*/
import { drizzle } from 'drizzle-orm/node-postgres';
import { eq } from 'drizzle-orm';
import { contentItems } from '../schema.js';
-import type { ResolvedEntitlements, SearchResult } from '../types.js';
+import type { ResolvedEntitlements } from '../types.js';
+import type { SearchService } from '../search/index.js';
type DrizzleClient = ReturnType;
@@ -26,14 +27,6 @@ export interface RetrievedItem {
metadata: Record;
}
-export interface SearchService {
- search(
- query: string,
- accessibleSourceIds: string[],
- options?: { limit?: number },
- ): Promise;
-}
-
export class ContentRetriever {
constructor(
private searchService: SearchService,
@@ -45,18 +38,13 @@ export class ContentRetriever {
entitlements: ResolvedEntitlements,
options?: { sourceIds?: string[]; maxSources?: number },
): Promise {
- // 1. Filter sourceIds by entitlements
- const accessibleSourceIds = options?.sourceIds
- ? options.sourceIds.filter(id => entitlements.sourceIds.includes(id))
- : entitlements.sourceIds;
-
- // 2. Search via SearchService
+ // 1. Search via SearchService (handles entitlement → sourceId resolution internally)
const maxSources = options?.maxSources ?? 5;
- const results = await this.searchService.search(query, accessibleSourceIds, {
+ const results = await this.searchService.search(query, entitlements, {
limit: maxSources,
});
- // 3. Fetch full content for each result
+ // 2. Fetch full content for each result
const items: RetrievedItem[] = [];
for (const result of results) {
const rows = await this.db
@@ -77,7 +65,7 @@ export class ContentRetriever {
}
}
- // 4. Format context text with numbered source labels
+ // 3. Format context text with numbered source labels
const contextText = items
.map((item, i) => `[Source ${i + 1}: "${item.title}"] ${item.body}`)
.join('\n\n');
diff --git a/joyus-ai-mcp-server/src/content/index.ts b/joyus-ai-mcp-server/src/content/index.ts
index dde5fa3..6be0608 100644
--- a/joyus-ai-mcp-server/src/content/index.ts
+++ b/joyus-ai-mcp-server/src/content/index.ts
@@ -11,7 +11,8 @@ import type { DrizzleClient } from './types.js';
import { connectorRegistry } from './connectors/index.js';
import { PgFtsProvider, SearchService } from './search/index.js';
import { EntitlementCache, EntitlementService, HttpEntitlementResolver } from './entitlements/index.js';
-import { GenerationService, PlaceholderGenerationProvider, type SearchService as GenSearchService } from './generation/index.js';
+import { GenerationService, PlaceholderGenerationProvider } from './generation/index.js';
+import { setContentContext } from '../tools/executor.js';
import { SyncEngine, initializeSyncScheduler } from './sync/index.js';
import { HealthChecker } from './monitoring/health.js';
import { MetricsCollector } from './monitoring/metrics.js';
@@ -35,8 +36,17 @@ export async function initializeContentModule(
const searchProvider = new PgFtsProvider(db);
const searchService = new SearchService(searchProvider, db);
+ // Inject SearchService into content tool executor
+ setContentContext({ searchService });
+
// 2. Entitlements
const entitlementCache = new EntitlementCache();
+
+ // Clean up expired cache entries every 5 minutes
+ const cacheCleanupInterval = setInterval(() => {
+ entitlementCache.cleanup();
+ }, 5 * 60 * 1000);
+ cacheCleanupInterval.unref();
const entitlementResolver = new HttpEntitlementResolver({
name: 'default',
defaultTtlSeconds: 300,
@@ -50,10 +60,10 @@ export async function initializeContentModule(
});
const entitlementService = new EntitlementService(entitlementResolver, entitlementCache, db);
- // 3. Generation (bridge search service to generation's expected interface)
+ // 3. Generation
const generationProvider = new PlaceholderGenerationProvider();
const generationService = new GenerationService(
- searchService as unknown as GenSearchService,
+ searchService,
generationProvider,
db,
);
diff --git a/joyus-ai-mcp-server/src/db/encryption.ts b/joyus-ai-mcp-server/src/db/encryption.ts
index 0172870..bbdc931 100644
--- a/joyus-ai-mcp-server/src/db/encryption.ts
+++ b/joyus-ai-mcp-server/src/db/encryption.ts
@@ -1,47 +1,58 @@
/**
* Token Encryption Utilities
- * AES-256 encryption for OAuth tokens at rest
+ * AES-256-GCM encryption for OAuth tokens at rest
*/
-import CryptoJS from 'crypto-js';
+import crypto from 'node:crypto';
-const ENCRYPTION_KEY = process.env.TOKEN_ENCRYPTION_KEY;
+const ALGORITHM = 'aes-256-gcm';
+const IV_LENGTH = 16;
+const ENCRYPTION_KEY = process.env.TOKEN_ENCRYPTION_KEY;
if (!ENCRYPTION_KEY) {
- console.warn('⚠️ TOKEN_ENCRYPTION_KEY not set - tokens will not be encrypted!');
+ if (process.env.NODE_ENV === 'production') {
+ throw new Error('FATAL: TOKEN_ENCRYPTION_KEY is required in production');
+ }
+ console.warn('[joyus] TOKEN_ENCRYPTION_KEY not set - using development fallback');
}
/**
* Encrypt a token for storage
*/
export function encryptToken(token: string): string {
- if (!ENCRYPTION_KEY) {
- return token; // Dev fallback - not for production!
- }
- return CryptoJS.AES.encrypt(token, ENCRYPTION_KEY).toString();
+ if (!ENCRYPTION_KEY) return token; // Dev-only fallback
+ const key = Buffer.from(ENCRYPTION_KEY, 'hex');
+ const iv = crypto.randomBytes(IV_LENGTH);
+ const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
+ const encrypted = Buffer.concat([cipher.update(token, 'utf8'), cipher.final()]);
+ const tag = cipher.getAuthTag();
+ return `${iv.toString('hex')}:${tag.toString('hex')}:${encrypted.toString('hex')}`;
}
/**
* Decrypt a stored token
*/
-export function decryptToken(encryptedToken: string): string {
- if (!ENCRYPTION_KEY) {
- return encryptedToken;
- }
- const bytes = CryptoJS.AES.decrypt(encryptedToken, ENCRYPTION_KEY);
- return bytes.toString(CryptoJS.enc.Utf8);
+export function decryptToken(stored: string): string {
+ if (!ENCRYPTION_KEY) return stored; // Dev-only fallback
+ const parts = stored.split(':');
+ if (parts.length !== 3 || !parts[0] || !parts[1]) return stored; // Legacy plaintext fallback
+ const [ivHex, tagHex, encHex] = parts;
+ const key = Buffer.from(ENCRYPTION_KEY, 'hex');
+ const decipher = crypto.createDecipheriv(ALGORITHM, key, Buffer.from(ivHex, 'hex'));
+ decipher.setAuthTag(Buffer.from(tagHex, 'hex'));
+ return decipher.update(Buffer.from(encHex, 'hex'), undefined, 'utf8') + decipher.final('utf8');
}
/**
* Generate a random MCP token for user authentication
*/
export function generateMcpToken(): string {
- return CryptoJS.lib.WordArray.random(32).toString(CryptoJS.enc.Hex);
+ return crypto.randomBytes(32).toString('hex');
}
/**
* Generate OAuth state parameter
*/
export function generateOAuthState(): string {
- return CryptoJS.lib.WordArray.random(16).toString(CryptoJS.enc.Hex);
+ return crypto.randomBytes(16).toString('hex');
}
diff --git a/joyus-ai-mcp-server/src/index.ts b/joyus-ai-mcp-server/src/index.ts
index e81488d..0cb23c9 100644
--- a/joyus-ai-mcp-server/src/index.ts
+++ b/joyus-ai-mcp-server/src/index.ts
@@ -11,9 +11,12 @@
* Transport: Streamable HTTP (recommended for remote MCP servers)
*/
+import './config.js'; // Env validation — must be first
+
import cors from 'cors';
import { config } from 'dotenv';
import express, { Request, Response, NextFunction } from 'express';
+import rateLimit from 'express-rate-limit';
import session from 'express-session';
import helmet from 'helmet';
@@ -37,9 +40,38 @@ config();
const app = express();
const PORT = process.env.PORT || 3000;
+// Session secret fail-closed in production
+const SESSION_SECRET = process.env.SESSION_SECRET;
+if (!SESSION_SECRET && process.env.NODE_ENV === 'production') {
+ throw new Error('FATAL: SESSION_SECRET is required in production');
+}
+
+// Rate limiters
+const apiLimiter = rateLimit({
+ windowMs: 15 * 60 * 1000, // 15 minutes
+ max: 100,
+ standardHeaders: true,
+ legacyHeaders: false,
+});
+
+const authLimiter = rateLimit({
+ windowMs: 15 * 60 * 1000,
+ max: 20, // stricter for auth
+ standardHeaders: true,
+ legacyHeaders: false,
+});
+
// Security middleware
app.use(helmet({
- contentSecurityPolicy: false // Disable for API
+ contentSecurityPolicy: {
+ directives: {
+ defaultSrc: ["'self'"],
+ scriptSrc: ["'self'", "'unsafe-inline'"],
+ styleSrc: ["'self'", "'unsafe-inline'"],
+ imgSrc: ["'self'", "data:"],
+ connectSrc: ["'self'"],
+ },
+ },
}));
app.use(cors({
origin: process.env.ALLOWED_ORIGINS?.split(',') || ['http://localhost:3000'],
@@ -48,12 +80,13 @@ app.use(cors({
app.use(express.json());
app.use(express.urlencoded({ extended: true })); // For form submissions
app.use(session({
- secret: process.env.SESSION_SECRET || 'change-me-in-production',
+ secret: SESSION_SECRET || 'dev-only-secret-not-for-production',
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production',
httpOnly: true,
+ sameSite: 'lax',
maxAge: 24 * 60 * 60 * 1000 // 24 hours
}
}));
@@ -160,7 +193,7 @@ app.get('/health', async (req, res) => {
});
// Auth routes (OAuth callbacks, token management)
-app.use('/auth', authRouter);
+app.use('/auth', authLimiter, authRouter);
// Task management routes (scheduled tasks)
app.use('/tasks', taskRouter);
@@ -319,8 +352,8 @@ app.listen(PORT, async () => {
connectionString: process.env.DATABASE_URL ?? '',
});
- // Mount pipeline routes
- app.use('/api', pipelineModule.router);
+ // Mount pipeline routes (auth + rate limit)
+ app.use('/api', apiLimiter, requireBearerToken, pipelineModule.router);
// Inject pipeline deps into tool executor
setPipelineContext({
diff --git a/joyus-ai-mcp-server/src/inngest/index.ts b/joyus-ai-mcp-server/src/inngest/index.ts
index 775ace5..8f3396f 100644
--- a/joyus-ai-mcp-server/src/inngest/index.ts
+++ b/joyus-ai-mcp-server/src/inngest/index.ts
@@ -4,9 +4,9 @@
* Exports client and all registered functions.
* Import `allFunctions` to pass to the serve() adapter in index.ts.
*
- * `allFunctions` registers all pipeline functions with an empty step registry
- * (stub mode). When a real StepHandlerRegistry is available (see pipelines/init.ts),
- * construct the pipeline functions with it and replace allFunctions at serve() time.
+ * Pipeline functions are created with `lazyRegistry`, a proxy that delegates
+ * to the mutable singleton at call time. Once `setStepRegistry()` is called
+ * during pipeline module init, all functions see the real registry.
*/
export { inngest } from './client.js';
export { stubFunction } from './functions/stub.js';
@@ -16,24 +16,19 @@ export { createContentAuditPipeline } from './functions/content-audit-pipeline.j
export { createRegulatoryChangeMonitorPipeline } from './functions/regulatory-change-monitor-pipeline.js';
export { createInngestAdapter } from './adapter.js';
export type { InngestStep, InngestStepHandlerAdapter } from './adapter.js';
+export { setStepRegistry, getStepRegistry } from './registry.js';
import { stubFunction } from './functions/stub.js';
import { createCorpusUpdatePipeline } from './functions/corpus-update-pipeline.js';
import { createScheduleTickPipeline } from './functions/schedule-tick-pipeline.js';
import { createContentAuditPipeline } from './functions/content-audit-pipeline.js';
import { createRegulatoryChangeMonitorPipeline } from './functions/regulatory-change-monitor-pipeline.js';
-import type { StepHandlerRegistry } from '../pipelines/engine/step-runner.js';
-
-// Empty registry — functions run in stub mode until a real registry is provided.
-// WP03 (deletion cleanup) will restructure how the registry is wired.
-const emptyRegistry: StepHandlerRegistry = {
- getHandler: () => undefined,
-};
+import { lazyRegistry } from './registry.js';
export const allFunctions = [
stubFunction,
- createCorpusUpdatePipeline(emptyRegistry),
- createContentAuditPipeline(emptyRegistry),
- createRegulatoryChangeMonitorPipeline(emptyRegistry),
+ createCorpusUpdatePipeline(lazyRegistry),
+ createContentAuditPipeline(lazyRegistry),
+ createRegulatoryChangeMonitorPipeline(lazyRegistry),
createScheduleTickPipeline(),
];
diff --git a/joyus-ai-mcp-server/src/inngest/registry.ts b/joyus-ai-mcp-server/src/inngest/registry.ts
new file mode 100644
index 0000000..0f7dd62
--- /dev/null
+++ b/joyus-ai-mcp-server/src/inngest/registry.ts
@@ -0,0 +1,40 @@
+/**
+ * Inngest Step Registry — mutable singleton with lazy proxy.
+ *
+ * Starts empty so pipeline functions can be created at module load time.
+ * Populated with the real registry during `initializePipelineModule()`.
+ *
+ * `lazyRegistry` is a proxy that delegates to the current singleton at
+ * call time, so Inngest functions created with it at module load always
+ * see the real registry once it has been set.
+ */
+
+import type { StepHandlerRegistry } from '../pipelines/engine/step-runner.js';
+
+// Mutable singleton — populated during pipeline module initialization
+let _registry: StepHandlerRegistry = {
+ getHandler: () => undefined,
+};
+
+/**
+ * Replace the step registry singleton. Called once during pipeline module init.
+ */
+export function setStepRegistry(registry: StepHandlerRegistry): void {
+ _registry = registry;
+}
+
+/**
+ * Get the current step registry.
+ */
+export function getStepRegistry(): StepHandlerRegistry {
+ return _registry;
+}
+
+/**
+ * A proxy registry that always delegates to the current singleton.
+ * Pass this to pipeline function factories at module load time so they
+ * pick up the real registry at invocation time.
+ */
+export const lazyRegistry: StepHandlerRegistry = {
+ getHandler: (...args) => _registry.getHandler(...args),
+};
diff --git a/joyus-ai-mcp-server/src/pipelines/init.ts b/joyus-ai-mcp-server/src/pipelines/init.ts
index be51940..b421072 100644
--- a/joyus-ai-mcp-server/src/pipelines/init.ts
+++ b/joyus-ai-mcp-server/src/pipelines/init.ts
@@ -25,6 +25,7 @@ import { startEscalationJob, stopEscalationJob } from './review/index.js';
import { createPipelineRouter, type PipelineRouterDeps } from './routes.js';
import type { ToolDefinition } from '../tools/index.js';
import { pipelineTools } from '../tools/pipeline-tools.js';
+import { setStepRegistry } from '../inngest/registry.js';
// ============================================================
// CONFIG & MODULE INTERFACE
@@ -64,6 +65,9 @@ export async function initializePipelineModule(
const triggerRegistry = defaultTriggerRegistry;
const stepRegistry = createStepRegistry(stepHandlerDeps ?? {});
+ // Populate the Inngest lazy registry so pipeline functions see real handlers
+ setStepRegistry(stepRegistry);
+
// 3. Step runner
const stepRunner = new StepRunner(db, stepRegistry);
diff --git a/joyus-ai-mcp-server/src/pipelines/routes.ts b/joyus-ai-mcp-server/src/pipelines/routes.ts
index bd26118..b2fea1b 100644
--- a/joyus-ai-mcp-server/src/pipelines/routes.ts
+++ b/joyus-ai-mcp-server/src/pipelines/routes.ts
@@ -51,28 +51,16 @@ export interface PipelineRouterDeps {
// ============================================================
/**
- * Extract tenantId from request. Uses x-tenant-id header, falling back
- * to the authenticated user ID (matching the content executor pattern).
+ * Extract tenantId from request. Derives from authenticated user only
+ * — never trust headers for tenant identity.
*/
function getTenantId(req: Request): string {
- const header = req.headers['x-tenant-id'];
- if (typeof header === 'string' && header.length > 0) {
- return header;
- }
- // Fall back to mcpUser id (matches content executor pattern)
+ // Derive from authenticated user — never trust headers
+ if (req.session?.userId) return req.session.userId;
const user = (req as unknown as Record)['mcpUser'] as
| { id: string }
| undefined;
- if (user?.id) {
- return user.id;
- }
- // Last resort: session userId
- if (req.session) {
- const session = req.session as unknown as Record;
- if (session['userId']) {
- return session['userId'] as string;
- }
- }
+ if (user?.id) return user.id;
return '';
}
diff --git a/joyus-ai-mcp-server/src/scheduler/routes.ts b/joyus-ai-mcp-server/src/scheduler/routes.ts
index 8887c50..f1368e8 100644
--- a/joyus-ai-mcp-server/src/scheduler/routes.ts
+++ b/joyus-ai-mcp-server/src/scheduler/routes.ts
@@ -17,6 +17,10 @@ import { scheduleTask, unscheduleTask, runTask, reloadTask, getSchedulerStatus }
export const taskRouter = Router();
+function escapeHtml(str: string): string {
+ return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''');
+}
+
// ============================================================
// Task Management UI
// ============================================================
@@ -97,15 +101,15 @@ taskRouter.get('/', requireSessionOrRedirect, async (req: Request, res: Response
-
${task.description || 'No description'}
+
${escapeHtml(task.description || 'No description')}
- | Type: ${task.taskType.replace(/_/g, ' ')} |
- Schedule: ${task.schedule} |
- Timezone: ${task.timezone} |
+ Type: ${escapeHtml(task.taskType.replace(/_/g, ' '))} |
+ Schedule: ${escapeHtml(task.schedule)} |
+ Timezone: ${escapeHtml(task.timezone)} |
| Last Run: ${task.lastRunAt ? new Date(task.lastRunAt).toLocaleString() : 'Never'} |
Next Run: ${task.nextRunAt ? new Date(task.nextRunAt).toLocaleString() : 'N/A'} |
Notify:
- ${task.notifySlack ? `Slack: ${task.notifySlack}` : ''}
- ${task.notifyEmail ? `Email: ${task.notifyEmail}` : ''}
+ ${task.notifySlack ? `Slack: ${escapeHtml(task.notifySlack)}` : ''}
+ ${task.notifyEmail ? `Email: ${escapeHtml(task.notifyEmail)}` : ''}
${!task.notifySlack && !task.notifyEmail ? 'None' : ''}
|
@@ -397,22 +401,22 @@ taskRouter.get('/:id/edit', requireSessionOrRedirect, async (req: Request, res:
- Edit Task: ${task.name}
+ Edit Task: ${escapeHtml(task.name)}
-