@equilateral_ai/mindmeld 3.3.1 → 3.4.0

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.
Files changed (69) hide show
  1. package/README.md +1 -10
  2. package/hooks/pre-compact.js +213 -25
  3. package/hooks/session-start.js +635 -41
  4. package/hooks/subagent-start.js +150 -0
  5. package/hooks/subagent-stop.js +184 -0
  6. package/package.json +8 -7
  7. package/scripts/init-project.js +74 -33
  8. package/scripts/mcp-bridge.js +220 -0
  9. package/src/core/CorrelationAnalyzer.js +157 -0
  10. package/src/core/LLMPatternDetector.js +198 -0
  11. package/src/core/RelevanceDetector.js +123 -36
  12. package/src/core/StandardsIngestion.js +119 -18
  13. package/src/handlers/activity/activityGetMe.js +1 -1
  14. package/src/handlers/activity/activityGetTeam.js +100 -55
  15. package/src/handlers/admin/adminSetup.js +216 -0
  16. package/src/handlers/alerts/alertsAcknowledge.js +6 -6
  17. package/src/handlers/alerts/alertsGet.js +11 -11
  18. package/src/handlers/analytics/activitySummaryGet.js +34 -35
  19. package/src/handlers/analytics/coachingGet.js +11 -11
  20. package/src/handlers/analytics/convergenceGet.js +236 -0
  21. package/src/handlers/analytics/developerScoreGet.js +41 -111
  22. package/src/handlers/collaborators/collaboratorInvite.js +1 -1
  23. package/src/handlers/company/companyUsersDelete.js +141 -0
  24. package/src/handlers/company/companyUsersGet.js +90 -0
  25. package/src/handlers/company/companyUsersPost.js +267 -0
  26. package/src/handlers/company/companyUsersPut.js +76 -0
  27. package/src/handlers/correlations/correlationsDeveloperGet.js +12 -12
  28. package/src/handlers/correlations/correlationsGet.js +8 -8
  29. package/src/handlers/correlations/correlationsProjectGet.js +5 -5
  30. package/src/handlers/enterprise/controlTowerGet.js +224 -0
  31. package/src/handlers/enterprise/enterpriseOnboardingSetup.js +48 -9
  32. package/src/handlers/enterprise/enterpriseOnboardingStatus.js +1 -3
  33. package/src/handlers/github/githubConnectionStatus.js +1 -1
  34. package/src/handlers/github/githubDiscoverPatterns.js +4 -2
  35. package/src/handlers/github/githubPatternsReview.js +7 -36
  36. package/src/handlers/health/healthGet.js +55 -0
  37. package/src/handlers/helpers/checkSuperAdmin.js +13 -14
  38. package/src/handlers/helpers/subscriptionTiers.js +27 -27
  39. package/src/handlers/mcp/mcpHandler.js +569 -0
  40. package/src/handlers/mcp/mindmeldMcpHandler.js +689 -0
  41. package/src/handlers/notifications/sendNotification.js +18 -18
  42. package/src/handlers/patterns/patternEvaluatePromotionPost.js +173 -0
  43. package/src/handlers/projects/projectCreate.js +124 -10
  44. package/src/handlers/projects/projectDelete.js +4 -4
  45. package/src/handlers/projects/projectGet.js +8 -8
  46. package/src/handlers/projects/projectUpdate.js +4 -4
  47. package/src/handlers/reports/aiLeverage.js +34 -30
  48. package/src/handlers/reports/engineeringInvestment.js +16 -16
  49. package/src/handlers/reports/riskForecast.js +41 -21
  50. package/src/handlers/reports/standardsRoi.js +101 -9
  51. package/src/handlers/scheduled/maturityUpdateJob.js +166 -0
  52. package/src/handlers/sessions/sessionStandardsPost.js +43 -7
  53. package/src/handlers/standards/discoveriesGet.js +93 -0
  54. package/src/handlers/standards/projectStandardsGet.js +2 -2
  55. package/src/handlers/standards/projectStandardsPut.js +2 -2
  56. package/src/handlers/standards/standardsRelevantPost.js +107 -12
  57. package/src/handlers/standards/standardsTransition.js +112 -15
  58. package/src/handlers/stripe/billingPortalPost.js +1 -1
  59. package/src/handlers/stripe/enterpriseCheckoutPost.js +2 -2
  60. package/src/handlers/stripe/subscriptionCreatePost.js +2 -2
  61. package/src/handlers/stripe/webhookPost.js +42 -14
  62. package/src/handlers/user/apiTokenCreate.js +71 -0
  63. package/src/handlers/user/apiTokenList.js +64 -0
  64. package/src/handlers/user/userSplashGet.js +90 -73
  65. package/src/handlers/users/cognitoPostConfirmation.js +37 -1
  66. package/src/handlers/users/cognitoPreSignUp.js +114 -0
  67. package/src/handlers/users/userGet.js +12 -8
  68. package/src/handlers/webhooks/githubWebhook.js +117 -125
  69. package/src/index.js +8 -5
@@ -0,0 +1,71 @@
1
+ /**
2
+ * API Token Create Handler
3
+ *
4
+ * Creates a new MCP API token for the authenticated user.
5
+ * Token plaintext is returned ONCE — only the SHA-256 hash is stored.
6
+ *
7
+ * POST /api/user/api-tokens
8
+ * Auth: Cognito JWT required
9
+ * Body: { name?: string }
10
+ */
11
+
12
+ const { wrapHandler, executeQuery, createSuccessResponse, createErrorResponse } = require('./helpers');
13
+ const crypto = require('crypto');
14
+
15
+ async function apiTokenCreate({ body, requestContext }) {
16
+ const email = requestContext.authorizer?.claims?.email
17
+ || requestContext.authorizer?.jwt?.claims?.email;
18
+
19
+ if (!email) {
20
+ return createErrorResponse(401, 'Authentication required');
21
+ }
22
+
23
+ const tokenName = body.name || 'Default';
24
+
25
+ // Look up user's client and company
26
+ const userResult = await executeQuery(`
27
+ SELECT u.client_id, ue.company_id
28
+ FROM rapport.users u
29
+ LEFT JOIN rapport.user_entitlements ue ON u.email_address = ue.email_address
30
+ WHERE u.email_address = $1
31
+ LIMIT 1
32
+ `, [email]);
33
+
34
+ if (userResult.rows.length === 0) {
35
+ return createErrorResponse(404, 'User not found');
36
+ }
37
+
38
+ const { client_id, company_id } = userResult.rows[0];
39
+
40
+ // Verify active subscription
41
+ const clientResult = await executeQuery(
42
+ 'SELECT subscription_tier FROM rapport.clients WHERE client_id = $1',
43
+ [client_id]
44
+ );
45
+
46
+ if (clientResult.rows.length === 0 || !clientResult.rows[0].subscription_tier || clientResult.rows[0].subscription_tier === 'free') {
47
+ return createErrorResponse(403, 'Active MindMeld subscription required to create API tokens');
48
+ }
49
+
50
+ // Generate token: mm_live_ + 40 random hex chars
51
+ const tokenId = crypto.randomUUID();
52
+ const tokenRandom = crypto.randomBytes(20).toString('hex');
53
+ const plaintext = `mm_live_${tokenRandom}`;
54
+ const tokenPrefix = plaintext.substring(0, 12);
55
+ const tokenHash = crypto.createHash('sha256').update(plaintext).digest('hex');
56
+
57
+ await executeQuery(`
58
+ INSERT INTO rapport.api_tokens (token_id, token_hash, token_prefix, email_address, client_id, company_id, token_name)
59
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
60
+ `, [tokenId, tokenHash, tokenPrefix, email, client_id, company_id || null, tokenName]);
61
+
62
+ return createSuccessResponse({
63
+ token_id: tokenId,
64
+ token: plaintext,
65
+ token_prefix: tokenPrefix,
66
+ name: tokenName,
67
+ message: 'Save this token — it will not be shown again.'
68
+ }, 'API token created');
69
+ }
70
+
71
+ exports.handler = wrapHandler(apiTokenCreate);
@@ -0,0 +1,64 @@
1
+ /**
2
+ * API Token List/Revoke Handler
3
+ *
4
+ * GET /api/user/api-tokens — List tokens (prefix only, never plaintext)
5
+ * DELETE /api/user/api-tokens?token_id=xxx — Revoke a token
6
+ *
7
+ * Auth: Cognito JWT required
8
+ */
9
+
10
+ const { wrapHandler, executeQuery, createSuccessResponse, createErrorResponse } = require('./helpers');
11
+
12
+ async function apiTokenList({ body, queryParams, requestContext, httpMethod }) {
13
+ const email = requestContext.authorizer?.claims?.email
14
+ || requestContext.authorizer?.jwt?.claims?.email;
15
+
16
+ if (!email) {
17
+ return createErrorResponse(401, 'Authentication required');
18
+ }
19
+
20
+ // DELETE — revoke a token
21
+ if (httpMethod === 'DELETE') {
22
+ const tokenId = queryParams.token_id || body.token_id;
23
+ if (!tokenId) {
24
+ return createErrorResponse(400, 'token_id is required');
25
+ }
26
+
27
+ const result = await executeQuery(`
28
+ UPDATE rapport.api_tokens
29
+ SET status = 'revoked', revoked_at = NOW()
30
+ WHERE token_id = $1 AND email_address = $2 AND status = 'active'
31
+ RETURNING token_id
32
+ `, [tokenId, email]);
33
+
34
+ if (result.rows.length === 0) {
35
+ return createErrorResponse(404, 'Token not found or already revoked');
36
+ }
37
+
38
+ return createSuccessResponse({ token_id: tokenId }, 'Token revoked');
39
+ }
40
+
41
+ // GET — list tokens
42
+ const result = await executeQuery(`
43
+ SELECT token_id, token_prefix, token_name, status,
44
+ last_used_at, request_count, created_at, revoked_at
45
+ FROM rapport.api_tokens
46
+ WHERE email_address = $1
47
+ ORDER BY created_at DESC
48
+ `, [email]);
49
+
50
+ return createSuccessResponse({
51
+ tokens: result.rows.map(r => ({
52
+ token_id: r.token_id,
53
+ prefix: r.token_prefix,
54
+ name: r.token_name,
55
+ status: r.status,
56
+ last_used: r.last_used_at,
57
+ request_count: r.request_count,
58
+ created_at: r.created_at,
59
+ revoked_at: r.revoked_at
60
+ }))
61
+ }, `${result.rows.length} token(s) found`);
62
+ }
63
+
64
+ exports.handler = wrapHandler(apiTokenList);
@@ -7,8 +7,8 @@
7
7
  *
8
8
  * Returns:
9
9
  * - show_splash: whether to display the splash (false if already acknowledged this week)
10
- * - summary: weekly activity metrics
11
- * - is_greenfield: true if user has < 7 days of activity
10
+ * - summary: weekly activity metrics from sessions + session_standards
11
+ * - is_greenfield: true if user has < 7 days of session history
12
12
  * - tips: getting-started tips for greenfield users
13
13
  */
14
14
 
@@ -22,7 +22,7 @@ async function getUserSplash({ requestContext }) {
22
22
  return createErrorResponse(401, 'Authentication required');
23
23
  }
24
24
 
25
- // Calculate current week boundaries (Monday to Sunday)
25
+ // Calculate current week boundaries (Monday to Sunday UTC)
26
26
  const now = new Date();
27
27
  const dayOfWeek = now.getUTCDay();
28
28
  const mondayOffset = dayOfWeek === 0 ? -6 : 1 - dayOfWeek;
@@ -62,119 +62,136 @@ async function getUserSplash({ requestContext }) {
62
62
  );
63
63
  }
64
64
 
65
- // Determine if user is greenfield (< 7 days of activity)
65
+ // Determine if user is greenfield (< 7 days of session history)
66
66
  let isGreenfield = true;
67
67
  try {
68
68
  const activityCheck = await executeQuery(`
69
- SELECT MIN(created_at) as first_activity
70
- FROM rapport.audit_trail
69
+ SELECT MIN(started_at) as first_session
70
+ FROM rapport.sessions
71
71
  WHERE email_address = $1
72
72
  `, [email]);
73
73
 
74
- if (activityCheck.rowCount > 0 && activityCheck.rows[0].first_activity) {
75
- const firstActivity = new Date(activityCheck.rows[0].first_activity);
76
- const daysSinceFirstActivity = Math.floor((now.getTime() - firstActivity.getTime()) / (1000 * 60 * 60 * 24));
77
- isGreenfield = daysSinceFirstActivity < 7;
74
+ if (activityCheck.rowCount > 0 && activityCheck.rows[0].first_session) {
75
+ const firstSession = new Date(activityCheck.rows[0].first_session);
76
+ const daysSinceFirst = Math.floor((now.getTime() - firstSession.getTime()) / (1000 * 60 * 60 * 24));
77
+ isGreenfield = daysSinceFirst < 7;
78
78
  }
79
79
  } catch (err) {
80
80
  console.log('[Splash] Could not determine greenfield status:', err.message);
81
81
  }
82
82
 
83
- // Aggregate weekly activity metrics
84
- let patternsHarvested = 0;
85
- try {
86
- const patternsResult = await executeQuery(`
87
- SELECT COUNT(*) as count
88
- FROM rapport.patterns
89
- WHERE created_by = $1
90
- AND created_at >= $2
91
- AND created_at <= $3
92
- `, [email, weekStart.toISOString(), weekEnd.toISOString()]);
93
-
94
- patternsHarvested = parseInt(patternsResult.rows[0].count, 10) || 0;
95
- } catch (err) {
96
- console.log('[Splash] Patterns query failed:', err.message);
97
- }
83
+ // Aggregate weekly metrics from sessions + session_standards
84
+ const weekStartISO = weekStart.toISOString();
85
+ const weekEndISO = weekEnd.toISOString();
98
86
 
99
- let standardsInjected = 0;
87
+ // Session stats (count, duration, projects)
88
+ let sessionsCount = 0;
89
+ let totalDurationMinutes = 0;
90
+ let activeProjects = 0;
100
91
  try {
101
- const injectionsResult = await executeQuery(`
102
- SELECT COUNT(*) as count
103
- FROM rapport.audit_trail
92
+ const sessionResult = await executeQuery(`
93
+ SELECT
94
+ COUNT(*) as session_count,
95
+ COALESCE(ROUND(SUM(duration_seconds) / 60.0), 0) as total_minutes,
96
+ COUNT(DISTINCT project_id) as project_count
97
+ FROM rapport.sessions
104
98
  WHERE email_address = $1
105
- AND action = 'standards_injected'
106
- AND created_at >= $2
107
- AND created_at <= $3
108
- `, [email, weekStart.toISOString(), weekEnd.toISOString()]);
109
-
110
- standardsInjected = parseInt(injectionsResult.rows[0].count, 10) || 0;
99
+ AND started_at >= $2
100
+ AND started_at <= $3
101
+ `, [email, weekStartISO, weekEndISO]);
102
+
103
+ if (sessionResult.rowCount > 0) {
104
+ sessionsCount = parseInt(sessionResult.rows[0].session_count, 10) || 0;
105
+ totalDurationMinutes = parseInt(sessionResult.rows[0].total_minutes, 10) || 0;
106
+ activeProjects = parseInt(sessionResult.rows[0].project_count, 10) || 0;
107
+ }
111
108
  } catch (err) {
112
- console.log('[Splash] Injections query failed:', err.message);
109
+ console.log('[Splash] Session stats query failed:', err.message);
113
110
  }
114
111
 
115
- let standardsPromoted = 0;
112
+ // Standards stats (injected, followed, violated)
113
+ let standardsInjected = 0;
114
+ let standardsFollowed = 0;
115
+ let violationsDetected = 0;
116
116
  try {
117
- const promotionsResult = await executeQuery(`
118
- SELECT COUNT(*) as count
119
- FROM rapport.audit_trail
120
- WHERE email_address = $1
121
- AND action = 'standard_promoted'
122
- AND created_at >= $2
123
- AND created_at <= $3
124
- `, [email, weekStart.toISOString(), weekEnd.toISOString()]);
125
-
126
- standardsPromoted = parseInt(promotionsResult.rows[0].count, 10) || 0;
117
+ const standardsResult = await executeQuery(`
118
+ SELECT
119
+ COUNT(*) as total_injected,
120
+ COUNT(*) FILTER (WHERE ss.followed = true) as total_followed,
121
+ COUNT(*) FILTER (WHERE ss.violated = true) as total_violated
122
+ FROM rapport.session_standards ss
123
+ JOIN rapport.sessions s ON s.session_id = ss.session_id
124
+ WHERE s.email_address = $1
125
+ AND s.started_at >= $2
126
+ AND s.started_at <= $3
127
+ `, [email, weekStartISO, weekEndISO]);
128
+
129
+ if (standardsResult.rowCount > 0) {
130
+ standardsInjected = parseInt(standardsResult.rows[0].total_injected, 10) || 0;
131
+ standardsFollowed = parseInt(standardsResult.rows[0].total_followed, 10) || 0;
132
+ violationsDetected = parseInt(standardsResult.rows[0].total_violated, 10) || 0;
133
+ }
127
134
  } catch (err) {
128
- console.log('[Splash] Promotions query failed:', err.message);
135
+ console.log('[Splash] Standards stats query failed:', err.message);
129
136
  }
130
137
 
131
- let violationsDetected = 0;
138
+ // Patterns harvested (keep existing query — patterns table exists)
139
+ let patternsHarvested = 0;
132
140
  try {
133
- const violationsResult = await executeQuery(`
141
+ const patternsResult = await executeQuery(`
134
142
  SELECT COUNT(*) as count
135
- FROM rapport.audit_trail
136
- WHERE email_address = $1
137
- AND action = 'violation_detected'
143
+ FROM rapport.patterns
144
+ WHERE created_by = $1
138
145
  AND created_at >= $2
139
146
  AND created_at <= $3
140
- `, [email, weekStart.toISOString(), weekEnd.toISOString()]);
147
+ `, [email, weekStartISO, weekEndISO]);
141
148
 
142
- violationsDetected = parseInt(violationsResult.rows[0].count, 10) || 0;
149
+ patternsHarvested = parseInt(patternsResult.rows[0].count, 10) || 0;
143
150
  } catch (err) {
144
- console.log('[Splash] Violations query failed:', err.message);
151
+ console.log('[Splash] Patterns query failed:', err.message);
145
152
  }
146
153
 
147
- let activeProjects = 0;
154
+ // Recent category distribution (what the user has been working on)
155
+ let recentCategories = {};
148
156
  try {
149
- const projectsResult = await executeQuery(`
150
- SELECT COUNT(DISTINCT project_id) as count
151
- FROM rapport.audit_trail
152
- WHERE email_address = $1
153
- AND created_at >= $2
154
- AND created_at <= $3
155
- AND project_id IS NOT NULL
156
- `, [email, weekStart.toISOString(), weekEnd.toISOString()]);
157
-
158
- activeProjects = parseInt(projectsResult.rows[0].count, 10) || 0;
157
+ const categoryResult = await executeQuery(`
158
+ SELECT sp.category, COUNT(*) as usage_count
159
+ FROM rapport.session_standards ss
160
+ JOIN rapport.sessions s ON s.session_id = ss.session_id
161
+ JOIN rapport.standards_patterns sp ON sp.pattern_id = ss.standard_id
162
+ WHERE s.email_address = $1
163
+ AND s.started_at >= $2
164
+ AND s.started_at <= $3
165
+ GROUP BY sp.category
166
+ ORDER BY usage_count DESC
167
+ LIMIT 5
168
+ `, [email, weekStartISO, weekEndISO]);
169
+
170
+ for (const row of categoryResult.rows) {
171
+ recentCategories[row.category] = parseInt(row.usage_count, 10);
172
+ }
159
173
  } catch (err) {
160
- console.log('[Splash] Active projects query failed:', err.message);
174
+ console.log('[Splash] Recent categories query failed:', err.message);
161
175
  }
162
176
 
163
177
  // Build tips for greenfield users
164
178
  const tips = isGreenfield ? [
165
- 'Connect a GitHub repository to start harvesting patterns from your codebase.',
166
179
  'MindMeld automatically injects relevant standards into your AI coding sessions.',
180
+ 'Use /mm-load <domain> to pre-load standards before starting work (e.g., /mm-load Frontend).',
181
+ 'Use /mm-workflows to see available step-by-step procedures.',
167
182
  'Patterns discovered in your code can be promoted to team-wide standards.',
168
- 'Check the Standards page to browse and configure which standards apply to your projects.',
169
- 'Visit the Dashboard regularly to track your team\'s standards adoption progress.'
183
+ 'Use /mm-status to check your system health and API connectivity.'
170
184
  ] : [];
171
185
 
172
186
  const summary = {
173
- patterns_harvested: patternsHarvested,
187
+ sessions_count: sessionsCount,
188
+ total_duration_minutes: totalDurationMinutes,
174
189
  standards_injected: standardsInjected,
175
- standards_promoted: standardsPromoted,
190
+ standards_followed: standardsFollowed,
176
191
  violations_detected: violationsDetected,
177
192
  active_projects: activeProjects,
193
+ recent_categories: recentCategories,
194
+ patterns_harvested: patternsHarvested,
178
195
  week_start: weekStartStr,
179
196
  week_end: weekEndStr
180
197
  };
@@ -111,7 +111,7 @@ async function handler(event, context) {
111
111
 
112
112
  console.log('Personal company created:', companyId);
113
113
 
114
- // 4. Create admin entitlement
114
+ // 4. Create admin entitlement for personal workspace
115
115
  const entitlementQuery = `
116
116
  INSERT INTO rapport.user_entitlements (
117
117
  email_address,
@@ -128,6 +128,42 @@ async function handler(event, context) {
128
128
 
129
129
  console.log('Entitlement created for:', email);
130
130
 
131
+ // 5. Check for and accept pending enterprise invitations
132
+ const pendingInvites = await executeQuery(`
133
+ SELECT invitation_id, client_id, company_id, role
134
+ FROM rapport.enterprise_invitations
135
+ WHERE email = $1 AND status = 'pending'
136
+ `, [email.toLowerCase()]);
137
+
138
+ if (pendingInvites.rows.length > 0) {
139
+ console.log(`Found ${pendingInvites.rows.length} pending enterprise invitation(s)`);
140
+
141
+ for (const invite of pendingInvites.rows) {
142
+ // Create entitlement for the enterprise company
143
+ const isAdmin = invite.role === 'admin';
144
+ await executeQuery(`
145
+ INSERT INTO rapport.user_entitlements (
146
+ email_address,
147
+ client_id,
148
+ company_id,
149
+ admin,
150
+ member
151
+ )
152
+ VALUES ($1, $2, $3, $4, true)
153
+ ON CONFLICT (email_address, company_id) DO NOTHING
154
+ `, [email, invite.client_id, invite.company_id, isAdmin]);
155
+
156
+ // Mark invitation as accepted
157
+ await executeQuery(`
158
+ UPDATE rapport.enterprise_invitations
159
+ SET status = 'accepted', accepted_at = NOW()
160
+ WHERE invitation_id = $1
161
+ `, [invite.invitation_id]);
162
+
163
+ console.log(`Accepted enterprise invitation for company: ${invite.company_id} (role: ${invite.role})`);
164
+ }
165
+ }
166
+
131
167
  await executeQuery('COMMIT');
132
168
 
133
169
  console.log('Post-confirmation complete for:', email);
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Cognito PreSignUp Handler
3
+ * Blocks bot registrations before user creation in Cognito
4
+ * Auto-confirms Google OAuth users and admin-invited users
5
+ *
6
+ * Triggered by: Cognito User Pool pre-signup trigger
7
+ *
8
+ * Note: Cognito trigger must be configured manually in Cognito console
9
+ * as SAM cannot reference external user pools
10
+ */
11
+
12
+ const { executeQuery } = require('./helpers');
13
+
14
+ const DISPOSABLE_DOMAINS = new Set([
15
+ 'mailinator.com', 'guerrillamail.com', 'tempmail.com',
16
+ 'throwaway.email', 'yopmail.com', 'sharklasers.com',
17
+ 'guerrillamailblock.com', 'grr.la', 'maildrop.cc',
18
+ 'dispostable.com', 'temp-mail.org', '10minutemail.com',
19
+ 'trashmail.com', 'fakeinbox.com', 'mailnesia.com',
20
+ 'tempinbox.com', 'mailcatch.com', 'throwam.com'
21
+ ]);
22
+
23
+ /**
24
+ * Detect dot-stuffed Gmail addresses used by signup bots
25
+ * Gmail ignores dots in the local part, so bots insert random dots
26
+ * to generate unique-looking addresses that all deliver to the same inbox
27
+ */
28
+ function isDotStuffedGmail(email) {
29
+ const atIndex = email.lastIndexOf('@');
30
+ if (atIndex === -1) return false;
31
+
32
+ const localPart = email.substring(0, atIndex).toLowerCase();
33
+ const domain = email.substring(atIndex + 1).toLowerCase();
34
+
35
+ if (domain !== 'gmail.com' && domain !== 'googlemail.com') return false;
36
+
37
+ const dotCount = (localPart.match(/\./g) || []).length;
38
+ const cleanLength = localPart.replace(/\./g, '').length;
39
+
40
+ if (cleanLength === 0) return true;
41
+
42
+ // 4+ dots in a short local part is a strong bot signal
43
+ if (dotCount >= 4 && cleanLength < 20) return true;
44
+
45
+ // More than 30% dots relative to actual characters
46
+ if (dotCount > 0 && dotCount / cleanLength > 0.3) return true;
47
+
48
+ return false;
49
+ }
50
+
51
+ /**
52
+ * Check if email uses a known disposable email domain
53
+ */
54
+ function isDisposableEmail(email) {
55
+ const domain = email.toLowerCase().split('@')[1];
56
+ return DISPOSABLE_DOMAINS.has(domain);
57
+ }
58
+
59
+ async function handler(event) {
60
+ console.log('PreSignUp trigger:', JSON.stringify({
61
+ triggerSource: event.triggerSource,
62
+ userName: event.userName,
63
+ email: event.request?.userAttributes?.email
64
+ }));
65
+
66
+ // Auto-confirm external provider signups (Google OAuth)
67
+ // Google provides verified email — no Cognito verification needed
68
+ if (event.triggerSource === 'PreSignUp_ExternalProvider') {
69
+ console.log(`[PreSignUp] Auto-confirming external provider user: ${event.request?.userAttributes?.email}`);
70
+ event.response.autoConfirmUser = true;
71
+ event.response.autoVerifyEmail = true;
72
+ return event;
73
+ }
74
+
75
+ // Only validate user-initiated signups (not admin-created)
76
+ if (event.triggerSource !== 'PreSignUp_SignUp') {
77
+ return event;
78
+ }
79
+
80
+ const email = event.request?.userAttributes?.email;
81
+ if (!email) {
82
+ throw new Error('Email address is required for registration');
83
+ }
84
+
85
+ if (isDisposableEmail(email)) {
86
+ console.log(`[PreSignUp] Blocked disposable email: ${email}`);
87
+ throw new Error('Please use a permanent email address to register.');
88
+ }
89
+
90
+ if (isDotStuffedGmail(email)) {
91
+ console.log(`[PreSignUp] Blocked dot-stuffed Gmail: ${email}`);
92
+ throw new Error('This email address format is not accepted. Please use your primary email address.');
93
+ }
94
+
95
+ // Auto-confirm users who have been invited (entitlement already exists)
96
+ try {
97
+ const result = await executeQuery(
98
+ 'SELECT 1 FROM rapport.user_entitlements WHERE email_address = $1 LIMIT 1',
99
+ [email.toLowerCase()]
100
+ );
101
+ if (result.rows.length > 0) {
102
+ console.log(`[PreSignUp] Auto-confirming invited user: ${email}`);
103
+ event.response.autoConfirmUser = true;
104
+ event.response.autoVerifyEmail = true;
105
+ }
106
+ } catch (err) {
107
+ // DB check failed — don't block signup, just skip auto-confirm
108
+ console.error(`[PreSignUp] Entitlement check failed for ${email}:`, err.message);
109
+ }
110
+
111
+ return event;
112
+ }
113
+
114
+ module.exports = { handler };
@@ -22,7 +22,7 @@ async function getUser({ requestContext }) {
22
22
  return createErrorResponse(401, 'Authentication required');
23
23
  }
24
24
 
25
- // Get user with their primary client subscription
25
+ // Get user with their primary client subscription and company
26
26
  const query = `
27
27
  SELECT
28
28
  u.email_address,
@@ -35,11 +35,14 @@ async function getUser({ requestContext }) {
35
35
  c.subscription_tier,
36
36
  c.subscription_status,
37
37
  c.subscription_ends_at,
38
- c.stripe_customer_id
38
+ c.stripe_customer_id,
39
+ ue.company_id
39
40
  FROM rapport.users u
40
41
  LEFT JOIN rapport.clients c ON u.client_id = c.client_id
42
+ LEFT JOIN rapport.user_entitlements ue ON u.email_address = ue.email_address
41
43
  WHERE u.email_address = $1
42
44
  AND u.active = true
45
+ LIMIT 1
43
46
  `;
44
47
 
45
48
  const result = await executeQuery(query, [email]);
@@ -51,20 +54,20 @@ async function getUser({ requestContext }) {
51
54
  const user = result.rows[0];
52
55
  const tierConfig = getTierConfig(user.subscription_tier || 'free');
53
56
 
54
- // Get usage counts
57
+ // Get usage counts scoped to user's entitled companies
55
58
  const usageQuery = `
56
59
  SELECT
57
60
  (SELECT COUNT(*) FROM rapport.user_entitlements WHERE client_id = $1) as collaborators,
58
61
  (SELECT COUNT(*) FROM rapport.projects p
59
- JOIN rapport.companies co ON p.company_id = co.company_id
60
- WHERE co.client_id = $1 AND p.archived = false) as projects,
62
+ JOIN rapport.user_entitlements ue ON p.company_id = ue.company_id
63
+ WHERE ue.email_address = $2 AND p.archived = false) as projects,
61
64
  (SELECT COUNT(*) FROM rapport.invariants i
62
65
  JOIN rapport.projects p ON i.project_id = p.project_id
63
- JOIN rapport.companies co ON p.company_id = co.company_id
64
- WHERE co.client_id = $1) as invariants
66
+ JOIN rapport.user_entitlements ue ON p.company_id = ue.company_id
67
+ WHERE ue.email_address = $2) as invariants
65
68
  `;
66
69
 
67
- const usageResult = await executeQuery(usageQuery, [user.client_id]);
70
+ const usageResult = await executeQuery(usageQuery, [user.client_id, email]);
68
71
  const usage = usageResult.rows[0];
69
72
 
70
73
  return createSuccessResponse(
@@ -74,6 +77,7 @@ async function getUser({ requestContext }) {
74
77
  first_name: user.first_name,
75
78
  last_name: user.last_name,
76
79
  client_id: user.client_id,
80
+ company_id: user.company_id,
77
81
  client_name: user.client_name,
78
82
  user_status: user.user_status,
79
83
  member_since: user.create_date,