@equilateral_ai/mindmeld 3.5.0 → 3.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,51 @@
1
+ const { executeQuery } = require('./index');
2
+
3
+ /**
4
+ * Get standards that are consistently relevant for a project (>80% activation rate over 30 days)
5
+ */
6
+ async function getPredictedStandards(projectId) {
7
+ const result = await executeQuery(`
8
+ WITH activation_stats AS (
9
+ SELECT
10
+ standard_id,
11
+ COUNT(*) as activation_count,
12
+ COUNT(DISTINCT DATE(activated_at)) as active_days
13
+ FROM rapport.standards_activation_log
14
+ WHERE project_id = $1
15
+ AND activated_at > NOW() - INTERVAL '30 days'
16
+ GROUP BY standard_id
17
+ ),
18
+ total_sessions AS (
19
+ SELECT COUNT(DISTINCT DATE(activated_at)) as total_days
20
+ FROM rapport.standards_activation_log
21
+ WHERE project_id = $1
22
+ AND activated_at > NOW() - INTERVAL '30 days'
23
+ )
24
+ SELECT a.standard_id,
25
+ a.activation_count,
26
+ a.active_days,
27
+ t.total_days,
28
+ CASE WHEN t.total_days > 0
29
+ THEN ROUND(a.active_days::numeric / t.total_days * 100, 1)
30
+ ELSE 0 END as activation_rate
31
+ FROM activation_stats a, total_sessions t
32
+ WHERE t.total_days >= 5
33
+ AND a.active_days::numeric / GREATEST(t.total_days, 1) > 0.8
34
+ ORDER BY activation_rate DESC
35
+ `, [projectId]);
36
+ return result.rows;
37
+ }
38
+
39
+ /**
40
+ * Log which standards were activated for a project
41
+ */
42
+ async function logStandardsActivation(projectId, standardIds) {
43
+ if (!standardIds || standardIds.length === 0) return;
44
+ const values = standardIds.map((id, i) => `($1, $${i + 2})`).join(', ');
45
+ await executeQuery(
46
+ `INSERT INTO rapport.standards_activation_log (project_id, standard_id) VALUES ${values}`,
47
+ [projectId, ...standardIds]
48
+ );
49
+ }
50
+
51
+ module.exports = { getPredictedStandards, logStandardsActivation };
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Project Access Verification
3
+ *
4
+ * Centralizes the "can this user access this project?" check.
5
+ * Grants access if the user is either:
6
+ * 1. A direct project collaborator (rapport.project_collaborators)
7
+ * 2. A member of the company that owns the project (rapport.user_entitlements)
8
+ *
9
+ * Returns the project row (project_id, project_name, company_id) on success,
10
+ * or null if the user has no access.
11
+ */
12
+
13
+ const { executeQuery } = require('./dbOperations');
14
+
15
+ /**
16
+ * Verify a user has access to a project via collaborator role or company membership.
17
+ * @param {string} projectId - Project ID to check
18
+ * @param {string} email - User's email address
19
+ * @returns {Promise<{project_id: string, project_name: string, company_id: string, access_type: string}|null>}
20
+ * Project row with access_type ('collaborator' or 'company_member'), or null if no access.
21
+ */
22
+ async function verifyProjectAccess(projectId, email) {
23
+ const result = await executeQuery(`
24
+ SELECT
25
+ p.project_id,
26
+ p.project_name,
27
+ p.company_id,
28
+ CASE
29
+ WHEN pc.email_address IS NOT NULL THEN 'collaborator'
30
+ WHEN ue.email_address IS NOT NULL THEN 'company_member'
31
+ END as access_type
32
+ FROM rapport.projects p
33
+ LEFT JOIN rapport.project_collaborators pc
34
+ ON p.project_id = pc.project_id
35
+ AND pc.email_address = $2
36
+ LEFT JOIN rapport.user_entitlements ue
37
+ ON p.company_id = ue.company_id
38
+ AND ue.email_address = $2
39
+ WHERE p.project_id = $1
40
+ AND (pc.email_address IS NOT NULL OR ue.email_address IS NOT NULL)
41
+ LIMIT 1
42
+ `, [projectId, email]);
43
+
44
+ return result.rows.length > 0 ? result.rows[0] : null;
45
+ }
46
+
47
+ /**
48
+ * Verify a user has a specific collaborator role on a project,
49
+ * OR is a company admin (which grants equivalent access).
50
+ * @param {string} projectId - Project ID to check
51
+ * @param {string} email - User's email address
52
+ * @param {string[]} allowedRoles - Collaborator roles that grant access (e.g. ['owner', 'admin'])
53
+ * @returns {Promise<{project_id: string, project_name: string, company_id: string, role: string|null, company_admin: boolean}|null>}
54
+ */
55
+ async function verifyProjectRole(projectId, email, allowedRoles = ['owner', 'admin', 'collaborator']) {
56
+ const result = await executeQuery(`
57
+ SELECT
58
+ p.project_id,
59
+ p.project_name,
60
+ p.company_id,
61
+ pc.role,
62
+ COALESCE(ue.admin, false) as company_admin
63
+ FROM rapport.projects p
64
+ LEFT JOIN rapport.project_collaborators pc
65
+ ON p.project_id = pc.project_id
66
+ AND pc.email_address = $2
67
+ LEFT JOIN rapport.user_entitlements ue
68
+ ON p.company_id = ue.company_id
69
+ AND ue.email_address = $2
70
+ WHERE p.project_id = $1
71
+ AND (pc.email_address IS NOT NULL OR ue.email_address IS NOT NULL)
72
+ LIMIT 1
73
+ `, [projectId, email]);
74
+
75
+ if (result.rows.length === 0) return null;
76
+
77
+ const row = result.rows[0];
78
+ const hasRole = row.role && allowedRoles.includes(row.role);
79
+ const isCompanyAdmin = row.company_admin === true;
80
+
81
+ if (hasRole || isCompanyAdmin) {
82
+ return row;
83
+ }
84
+
85
+ return null;
86
+ }
87
+
88
+ module.exports = { verifyProjectAccess, verifyProjectRole };
@@ -3,11 +3,15 @@
3
3
  *
4
4
  * Implements MCP Streamable HTTP transport (protocol version 2025-03-26):
5
5
  * - POST: JSON-RPC messages, responds with JSON or SSE stream
6
- * - GET: Legacy SSE handshake (endpoint event + close)
6
+ * - GET: SSE handshake or OAuth discovery
7
7
  * - DELETE: Session close
8
8
  * - OPTIONS: CORS preflight
9
9
  *
10
- * Auth: X-MindMeld-Token header OR Authorization: Bearer token
10
+ * Auth: OAuth 2.1 (Cognito JWT) OR X-MindMeld-Token / Bearer API token
11
+ *
12
+ * OAuth Discovery (RFC 9728):
13
+ * GET /.well-known/oauth-protected-resource → resource metadata
14
+ * 401 responses include WWW-Authenticate with resource_metadata URL
11
15
  *
12
16
  * Uses awslambda.streamifyResponse() for Lambda Function URL streaming.
13
17
  * The `awslambda` global is provided by the Lambda Node.js runtime.
@@ -16,6 +20,11 @@
16
20
  const { validateApiToken, handleJsonRpc, CORS_HEADERS } = require('./mindmeldMcpCore');
17
21
  const crypto = require('crypto');
18
22
 
23
+ // OAuth / Cognito configuration
24
+ const COGNITO_ISSUER = 'https://cognito-idp.us-east-2.amazonaws.com/us-east-2_638OhwuV1';
25
+ const COGNITO_AUTH_DOMAIN = 'https://mindmeld-auth.auth.us-east-2.amazoncognito.com';
26
+ const MCP_OAUTH_CLIENT_ID = '5bjpug8up86so7k7ndttobc0qi';
27
+
19
28
  /**
20
29
  * Write an SSE event to the response stream
21
30
  */
@@ -50,9 +59,98 @@ function createStream(responseStream, statusCode, headers) {
50
59
  });
51
60
  }
52
61
 
62
+ /**
63
+ * Build the Function URL base from the event
64
+ */
65
+ function getBaseUrl(event) {
66
+ const host = event.headers?.host || '';
67
+ return `https://${host}`;
68
+ }
69
+
70
+ /**
71
+ * Return OAuth Protected Resource Metadata (RFC 9728)
72
+ * Points to ourselves as the authorization server so we can serve
73
+ * metadata that includes code_challenge_methods_supported (PKCE).
74
+ * Actual OAuth endpoints still point to Cognito.
75
+ */
76
+ function getResourceMetadata(baseUrl) {
77
+ return {
78
+ resource: baseUrl,
79
+ authorization_servers: [baseUrl],
80
+ scopes_supported: ['mcp/standards', 'openid', 'email', 'profile'],
81
+ bearer_methods_supported: ['header'],
82
+ };
83
+ }
84
+
85
+ /**
86
+ * Return OAuth Authorization Server Metadata (RFC 8414)
87
+ * Wraps Cognito's endpoints with PKCE support declaration.
88
+ * Cognito supports PKCE but doesn't advertise it in OIDC metadata.
89
+ */
90
+ function getAuthServerMetadata(baseUrl) {
91
+ return {
92
+ issuer: baseUrl,
93
+ authorization_endpoint: `${COGNITO_AUTH_DOMAIN}/oauth2/authorize`,
94
+ token_endpoint: `${COGNITO_AUTH_DOMAIN}/oauth2/token`,
95
+ revocation_endpoint: `${COGNITO_AUTH_DOMAIN}/oauth2/revoke`,
96
+ userinfo_endpoint: `${COGNITO_AUTH_DOMAIN}/oauth2/userInfo`,
97
+ jwks_uri: `${COGNITO_ISSUER}/.well-known/jwks.json`,
98
+ scopes_supported: ['mcp/standards', 'openid', 'email', 'profile'],
99
+ response_types_supported: ['code'],
100
+ grant_types_supported: ['authorization_code', 'refresh_token'],
101
+ token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post'],
102
+ code_challenge_methods_supported: ['S256'],
103
+ subject_types_supported: ['public'],
104
+ id_token_signing_alg_values_supported: ['RS256'],
105
+ };
106
+ }
107
+
108
+ /**
109
+ * Return 401 with WWW-Authenticate header per MCP OAuth spec
110
+ */
111
+ function write401(stream, baseUrl) {
112
+ stream.write(JSON.stringify({
113
+ error: 'unauthorized',
114
+ message: 'Authentication required. Use OAuth or provide X-MindMeld-Token header.',
115
+ }));
116
+ stream.end();
117
+ }
118
+
53
119
  // Lambda Function URL streaming handler
54
- const streamHandler = async (event, responseStream, _context) => {
120
+ const streamHandler = async (event, responseStream, context) => {
121
+ // CRITICAL: Don't wait for event loop to drain — the cached pg client
122
+ // keeps it alive forever, causing 900s timeouts on every request.
123
+ context.callbackWaitsForEmptyEventLoop = false;
124
+
55
125
  const { method, headers, body, isBase64Encoded } = parseRequest(event);
126
+ const path = parseRequest(event).path;
127
+ const baseUrl = getBaseUrl(event);
128
+
129
+ // Request logging for debugging
130
+ const hasAuth = !!(headers['authorization'] || headers['x-mindmeld-token']);
131
+ console.log(`[MCP-Stream] ${method} ${path} auth=${hasAuth} accept=${headers['accept'] || 'none'}`);
132
+
133
+ // === OAuth Discovery: Protected Resource Metadata (RFC 9728) ===
134
+ if (method === 'GET' && path === '/.well-known/oauth-protected-resource') {
135
+ const stream = createStream(responseStream, 200, {
136
+ 'Content-Type': 'application/json',
137
+ 'Cache-Control': 'public, max-age=3600',
138
+ });
139
+ stream.write(JSON.stringify(getResourceMetadata(baseUrl)));
140
+ stream.end();
141
+ return;
142
+ }
143
+
144
+ // === OAuth Discovery: Authorization Server Metadata (RFC 8414) ===
145
+ if (method === 'GET' && path === '/.well-known/oauth-authorization-server') {
146
+ const stream = createStream(responseStream, 200, {
147
+ 'Content-Type': 'application/json',
148
+ 'Cache-Control': 'public, max-age=3600',
149
+ });
150
+ stream.write(JSON.stringify(getAuthServerMetadata(baseUrl)));
151
+ stream.end();
152
+ return;
153
+ }
56
154
 
57
155
  // === OPTIONS: CORS preflight ===
58
156
  if (method === 'OPTIONS') {
@@ -72,15 +170,18 @@ const streamHandler = async (event, responseStream, _context) => {
72
170
  return;
73
171
  }
74
172
 
75
- // === GET: Legacy SSE handshake ===
173
+ // WWW-Authenticate header for 401 responses
174
+ const wwwAuth = `Bearer resource_metadata="${baseUrl}/.well-known/oauth-protected-resource"`;
175
+
176
+ // === GET: SSE handshake ===
76
177
  if (method === 'GET') {
77
178
  const authResult = await validateApiToken(headers);
78
179
  if (authResult.error) {
79
180
  const stream = createStream(responseStream, 401, {
80
181
  'Content-Type': 'application/json',
182
+ 'WWW-Authenticate': wwwAuth,
81
183
  });
82
- stream.write(JSON.stringify({ error: authResult.message }));
83
- stream.end();
184
+ write401(stream, baseUrl);
84
185
  return;
85
186
  }
86
187
 
@@ -93,7 +194,7 @@ const streamHandler = async (event, responseStream, _context) => {
93
194
  });
94
195
 
95
196
  // Send endpoint event — legacy SSE clients expect this
96
- stream.write(`event: endpoint\ndata: ${parseRequest(event).path}\n\n`);
197
+ stream.write(`event: endpoint\ndata: ${path}\n\n`);
97
198
 
98
199
  // For Streamable HTTP clients probing via GET: close after endpoint event.
99
200
  // The client will then POST to us for actual JSON-RPC messages.
@@ -114,17 +215,15 @@ const streamHandler = async (event, responseStream, _context) => {
114
215
  // Auth
115
216
  const authResult = await validateApiToken(headers);
116
217
  if (authResult.error) {
117
- const stream = createStream(responseStream, 200, {
218
+ console.log(`[MCP-Stream] POST auth failed: ${authResult.error} - ${authResult.message}`);
219
+ const stream = createStream(responseStream, 401, {
118
220
  'Content-Type': 'application/json',
221
+ 'WWW-Authenticate': wwwAuth,
119
222
  });
120
- stream.write(JSON.stringify({
121
- jsonrpc: '2.0',
122
- error: { code: -32000, message: authResult.message },
123
- id: null,
124
- }));
125
- stream.end();
223
+ write401(stream, baseUrl);
126
224
  return;
127
225
  }
226
+ console.log(`[MCP-Stream] POST auth OK: ${authResult.user.email}`);
128
227
 
129
228
  // Parse body (Function URL may base64-encode it)
130
229
  let parsedBody;
@@ -6,7 +6,7 @@
6
6
  * Auth: Cognito JWT required
7
7
  */
8
8
 
9
- const { wrapHandler, executeQuery, createSuccessResponse, createErrorResponse } = require('./helpers');
9
+ const { wrapHandler, executeQuery, createSuccessResponse, createErrorResponse, verifyProjectAccess } = require('./helpers');
10
10
 
11
11
  async function getDiscoveries({ queryStringParameters, requestContext }) {
12
12
  try {
@@ -23,13 +23,9 @@ async function getDiscoveries({ queryStringParameters, requestContext }) {
23
23
  return createErrorResponse(400, 'project_id is required');
24
24
  }
25
25
 
26
- // Verify user has access to project
27
- const accessResult = await executeQuery(`
28
- SELECT role FROM rapport.project_collaborators
29
- WHERE project_id = $1 AND email_address = $2
30
- `, [projectId, email]);
31
-
32
- if (accessResult.rowCount === 0) {
26
+ // Verify user has access to project (collaborator or company member)
27
+ const projectAccess = await verifyProjectAccess(projectId, email);
28
+ if (!projectAccess) {
33
29
  return createErrorResponse(403, 'Access denied to project');
34
30
  }
35
31
 
@@ -6,7 +6,7 @@
6
6
  * Auth: Cognito JWT required
7
7
  */
8
8
 
9
- const { wrapHandler, executeQuery, createSuccessResponse, createErrorResponse } = require('./helpers');
9
+ const { wrapHandler, executeQuery, createSuccessResponse, createErrorResponse, verifyProjectAccess } = require('./helpers');
10
10
 
11
11
  async function getProjectStandards({ queryStringParameters, pathParameters, requestContext }) {
12
12
  try {
@@ -22,19 +22,13 @@ async function getProjectStandards({ queryStringParameters, pathParameters, requ
22
22
  return createErrorResponse(400, 'projectId is required');
23
23
  }
24
24
 
25
- // Verify user has access to project
26
- const accessResult = await executeQuery(`
27
- SELECT pc.role, p.project_name
28
- FROM rapport.project_collaborators pc
29
- JOIN rapport.projects p ON pc.project_id = p.project_id
30
- WHERE pc.project_id = $1 AND pc.email_address = $2
31
- `, [projectId, email]);
32
-
33
- if (accessResult.rowCount === 0) {
25
+ // Verify user has access to project (collaborator or company member)
26
+ const projectAccess = await verifyProjectAccess(projectId, email);
27
+ if (!projectAccess) {
34
28
  return createErrorResponse(403, 'Access denied to project');
35
29
  }
36
30
 
37
- const projectName = accessResult.rows[0].project_name;
31
+ const projectName = projectAccess.project_name;
38
32
 
39
33
  // Get project standards preferences
40
34
  const prefsResult = await executeQuery(`
@@ -7,7 +7,7 @@
7
7
  * Auth: Cognito JWT required
8
8
  */
9
9
 
10
- const { wrapHandler, executeQuery, createSuccessResponse, createErrorResponse } = require('./helpers');
10
+ const { wrapHandler, executeQuery, createSuccessResponse, createErrorResponse, verifyProjectRole } = require('./helpers');
11
11
 
12
12
  async function updateProjectStandards({ body, pathParameters, requestContext }) {
13
13
  try {
@@ -27,21 +27,14 @@ async function updateProjectStandards({ body, pathParameters, requestContext })
27
27
  enabled_categories,
28
28
  standard_overrides,
29
29
  critical_overrides,
30
- catalog_id = 'equilateral-v1'
30
+ catalog_id = 'equilateral-v1',
31
+ standard_id,
32
+ load_bearing
31
33
  } = body || {};
32
34
 
33
- // Verify user has admin access to project
34
- const accessResult = await executeQuery(`
35
- SELECT role FROM rapport.project_collaborators
36
- WHERE project_id = $1 AND email_address = $2
37
- `, [projectId, email]);
38
-
39
- if (accessResult.rowCount === 0) {
40
- return createErrorResponse(403, 'Access denied to project');
41
- }
42
-
43
- const role = accessResult.rows[0].role;
44
- if (role !== 'owner' && role !== 'admin') {
35
+ // Verify user has admin access to project (project owner/admin or company admin)
36
+ const projectAccess = await verifyProjectRole(projectId, email, ['owner', 'admin']);
37
+ if (!projectAccess) {
45
38
  return createErrorResponse(403, 'Admin access required to modify standards preferences');
46
39
  }
47
40
 
@@ -70,6 +63,33 @@ async function updateProjectStandards({ body, pathParameters, requestContext })
70
63
  }
71
64
  }
72
65
 
66
+ // Update load_bearing flag on a specific standard if provided
67
+ if (standard_id && typeof load_bearing === 'boolean') {
68
+ const userCompany = await executeQuery(`
69
+ SELECT company_id FROM rapport.user_entitlements WHERE email_address = $1 LIMIT 1
70
+ `, [email]);
71
+ const companyId = userCompany.rows[0]?.company_id;
72
+
73
+ // Try base standards first
74
+ const baseResult = await executeQuery(`
75
+ UPDATE rapport.standards_patterns
76
+ SET load_bearing = $1
77
+ WHERE pattern_id = $2
78
+ AND (company_id IS NULL OR company_id = $3)
79
+ `, [load_bearing, standard_id, companyId]);
80
+
81
+ // If no base standard matched, try company overrides
82
+ if (baseResult.rowCount === 0 && companyId) {
83
+ await executeQuery(`
84
+ UPDATE rapport.company_standard_overrides
85
+ SET load_bearing = $1
86
+ WHERE (base_standard_id = $2 OR override_id::text = $3)
87
+ AND company_id = $4
88
+ AND active = TRUE
89
+ `, [load_bearing, standard_id, standard_id.replace('company-add-', ''), companyId]);
90
+ }
91
+ }
92
+
73
93
  // Upsert project standards
74
94
  const result = await executeQuery(`
75
95
  INSERT INTO rapport.project_standards (
@@ -10,7 +10,7 @@
10
10
  * parsed YAML-compatible standards ready for storage or review.
11
11
  */
12
12
 
13
- const { wrapHandler, executeQuery, createSuccessResponse, createErrorResponse } = require('./helpers');
13
+ const { wrapHandler, executeQuery, createSuccessResponse, createErrorResponse, verifyProjectAccess } = require('./helpers');
14
14
 
15
15
  const { parseAdr } = require('./core/parsers/adrParser');
16
16
  const { parseEslint } = require('./core/parsers/eslintParser');
@@ -49,13 +49,9 @@ async function parseUploadStandards({ body, requestContext }) {
49
49
  });
50
50
  }
51
51
 
52
- // Verify user has access to the project
53
- const accessResult = await executeQuery(`
54
- SELECT role FROM rapport.project_collaborators
55
- WHERE project_id = $1 AND email_address = $2
56
- `, [project_id, email]);
57
-
58
- if (accessResult.rowCount === 0) {
52
+ // Verify user has access to the project (collaborator or company member)
53
+ const projectAccess = await verifyProjectAccess(project_id, email);
54
+ if (!projectAccess) {
59
55
  return createErrorResponse(403, 'Access denied to project');
60
56
  }
61
57