@vantageos/vantage-crm-mcp 0.1.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.
- package/README.md +260 -0
- package/dist/convex/crm/_helpers.js +24 -0
- package/dist/convex/crm/activities.js +220 -0
- package/dist/convex/crm/briefing.js +198 -0
- package/dist/convex/crm/calendarCron.js +92 -0
- package/dist/convex/crm/calendarCronDispatch.js +83 -0
- package/dist/convex/crm/calendarSync.js +294 -0
- package/dist/convex/crm/companies.js +323 -0
- package/dist/convex/crm/contacts.js +346 -0
- package/dist/convex/crm/deals.js +481 -0
- package/dist/convex/crm/emailActions.js +158 -0
- package/dist/convex/crm/emailCron.js +210 -0
- package/dist/convex/crm/emailCronDispatch.js +76 -0
- package/dist/convex/crm/emailSync.js +260 -0
- package/dist/convex/crm/onboarding.js +185 -0
- package/dist/convex/crm/stats.js +75 -0
- package/dist/convex/crm/tasks.js +109 -0
- package/dist/convex/crons.js +25 -0
- package/dist/convex/integrations.js +183 -0
- package/dist/convex/lib/auditLog.js +109 -0
- package/dist/convex/lib/auth.js +372 -0
- package/dist/convex/lib/rbac.js +123 -0
- package/dist/convex/lib/workspace.js +171 -0
- package/dist/convex/organizations.js +192 -0
- package/dist/convex/schema.js +690 -0
- package/dist/convex/users.js +217 -0
- package/dist/convex/workspaces.js +603 -0
- package/dist/mcp-server/lib/convexClient.js +50 -0
- package/dist/mcp-server/lib/scopeEnforcement.js +76 -0
- package/dist/mcp-server/registry.js +116 -0
- package/dist/mcp-server/server.js +97 -0
- package/dist/mcp-server/tests/registry.test.js +163 -0
- package/dist/mcp-server/tests/scopeEnforcement.test.js +137 -0
- package/dist/mcp-server/tests/security.test.js +257 -0
- package/dist/mcp-server/tests/tools.test.js +272 -0
- package/dist/mcp-server/tools/activities.js +207 -0
- package/dist/mcp-server/tools/admin.js +190 -0
- package/dist/mcp-server/tools/companies.js +233 -0
- package/dist/mcp-server/tools/contacts.js +306 -0
- package/dist/mcp-server/tools/customFields.js +222 -0
- package/dist/mcp-server/tools/customObjects.js +235 -0
- package/dist/mcp-server/tools/deals.js +297 -0
- package/dist/mcp-server/tools/rbac.js +177 -0
- package/dist/mcp-server/tools/search.js +155 -0
- package/dist/mcp-server/tools/workflows.js +234 -0
- package/dist/mcp-server/transport/http.js +257 -0
- package/dist/mcp-server/transport/stdio.js +90 -0
- package/package.json +45 -0
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Authentication & Authorization Utilities
|
|
4
|
+
*
|
|
5
|
+
* Provides helper functions for:
|
|
6
|
+
* - Getting authenticated user IDs (Clerk integration)
|
|
7
|
+
* - Checking user roles and enforcing admin-only access
|
|
8
|
+
* - Resource ownership validation
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* // Get Clerk user ID
|
|
12
|
+
* const userId = await getAuthUserId(ctx);
|
|
13
|
+
*
|
|
14
|
+
* // Require admin access
|
|
15
|
+
* const user = await requireAdmin(ctx);
|
|
16
|
+
*
|
|
17
|
+
* // In actions: require any logged-in user (Design Studio, etc.)
|
|
18
|
+
* const identity = await requireUser(ctx);
|
|
19
|
+
*/
|
|
20
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
+
exports.checkIsAdmin = void 0;
|
|
22
|
+
exports.getAuthUserId = getAuthUserId;
|
|
23
|
+
exports.getAuthUserIdOptional = getAuthUserIdOptional;
|
|
24
|
+
exports.assertUserOwnsResource = assertUserOwnsResource;
|
|
25
|
+
exports.getUserMetadata = getUserMetadata;
|
|
26
|
+
exports.getCurrentUser = getCurrentUser;
|
|
27
|
+
exports.requireAuth = requireAuth;
|
|
28
|
+
exports.isAdmin = isAdmin;
|
|
29
|
+
exports.requireAdmin = requireAdmin;
|
|
30
|
+
exports.getOrCreateUser = getOrCreateUser;
|
|
31
|
+
exports.getOrCreateOrganization = getOrCreateOrganization;
|
|
32
|
+
exports.getOrCreateMembership = getOrCreateMembership;
|
|
33
|
+
exports.requireUser = requireUser;
|
|
34
|
+
exports.requireAdminInAction = requireAdminInAction;
|
|
35
|
+
exports.getOrgId = getOrgId;
|
|
36
|
+
exports.getEntityId = getEntityId;
|
|
37
|
+
exports.isInOrganization = isInOrganization;
|
|
38
|
+
exports.requireOrganization = requireOrganization;
|
|
39
|
+
exports.getWorkspaceContext = getWorkspaceContext;
|
|
40
|
+
const values_1 = require("convex/values");
|
|
41
|
+
const server_1 = require("../_generated/server");
|
|
42
|
+
const { internal } = require('../_generated/api');
|
|
43
|
+
// ============================================================================
|
|
44
|
+
// EXISTING FUNCTIONS (Clerk user ID helpers)
|
|
45
|
+
// ============================================================================
|
|
46
|
+
/**
|
|
47
|
+
* Get the authenticated user's Clerk ID
|
|
48
|
+
* Throws error if not authenticated
|
|
49
|
+
*/
|
|
50
|
+
async function getAuthUserId(ctx) {
|
|
51
|
+
const identity = await ctx.auth.getUserIdentity();
|
|
52
|
+
if (!identity) {
|
|
53
|
+
throw new Error('Unauthenticated - user must be logged in');
|
|
54
|
+
}
|
|
55
|
+
return identity.subject; // Clerk user ID
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Get the authenticated user's Clerk ID or null if not authenticated
|
|
59
|
+
*/
|
|
60
|
+
async function getAuthUserIdOptional(ctx) {
|
|
61
|
+
const identity = await ctx.auth.getUserIdentity();
|
|
62
|
+
return identity?.subject ?? null;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Check if the current user owns a resource
|
|
66
|
+
*/
|
|
67
|
+
async function assertUserOwnsResource(ctx, resourceUserId) {
|
|
68
|
+
const userId = await getAuthUserId(ctx);
|
|
69
|
+
if (userId !== resourceUserId) {
|
|
70
|
+
throw new Error("Unauthorized - you don't own this resource");
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Get user metadata from Clerk
|
|
75
|
+
*/
|
|
76
|
+
async function getUserMetadata(ctx) {
|
|
77
|
+
const identity = await ctx.auth.getUserIdentity();
|
|
78
|
+
if (!identity) {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
userId: identity.subject,
|
|
83
|
+
email: identity.email,
|
|
84
|
+
name: identity.name,
|
|
85
|
+
imageUrl: identity.pictureUrl,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
// ============================================================================
|
|
89
|
+
// NEW FUNCTIONS (Role-based access control)
|
|
90
|
+
// ============================================================================
|
|
91
|
+
/**
|
|
92
|
+
* Get the current authenticated user from the users table
|
|
93
|
+
* Returns null if not authenticated or user not found
|
|
94
|
+
*/
|
|
95
|
+
async function getCurrentUser(ctx) {
|
|
96
|
+
const identity = await ctx.auth.getUserIdentity();
|
|
97
|
+
if (!identity) {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
const user = await ctx.db
|
|
101
|
+
.query('users')
|
|
102
|
+
.withIndex('by_token', (q) => q.eq('tokenIdentifier', identity.tokenIdentifier))
|
|
103
|
+
.first();
|
|
104
|
+
return user;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Require authentication - throws if user not logged in
|
|
108
|
+
* Returns the user object
|
|
109
|
+
*/
|
|
110
|
+
async function requireAuth(ctx) {
|
|
111
|
+
const user = await getCurrentUser(ctx);
|
|
112
|
+
if (!user) {
|
|
113
|
+
throw new Error('Unauthorized: Authentication required');
|
|
114
|
+
}
|
|
115
|
+
return user;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Check if the current user is an admin
|
|
119
|
+
* Returns true if admin, false otherwise
|
|
120
|
+
*/
|
|
121
|
+
async function isAdmin(ctx) {
|
|
122
|
+
const user = await getCurrentUser(ctx);
|
|
123
|
+
return user?.role === 'admin';
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Require admin access - throws if user is not an admin
|
|
127
|
+
* Returns the user object
|
|
128
|
+
*/
|
|
129
|
+
async function requireAdmin(ctx) {
|
|
130
|
+
const user = await requireAuth(ctx);
|
|
131
|
+
if (user.role !== 'admin') {
|
|
132
|
+
throw new Error('Forbidden: Admin access required');
|
|
133
|
+
}
|
|
134
|
+
return user;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Get or create user record in the database
|
|
138
|
+
* Called automatically after Clerk authentication
|
|
139
|
+
*
|
|
140
|
+
* This ensures every authenticated user has a record in our users table
|
|
141
|
+
* with a default "user" role. Admins must be promoted manually.
|
|
142
|
+
*/
|
|
143
|
+
async function getOrCreateUser(ctx, clerkId, tokenIdentifier, email, name, avatarUrl) {
|
|
144
|
+
// Check if user already exists by Clerk ID first
|
|
145
|
+
let existingUser = await ctx.db
|
|
146
|
+
.query('users')
|
|
147
|
+
.withIndex('by_clerk_id', (q) => q.eq('clerkId', clerkId))
|
|
148
|
+
.first();
|
|
149
|
+
// Fallback to token identifier for backwards compatibility
|
|
150
|
+
if (!existingUser) {
|
|
151
|
+
existingUser = await ctx.db
|
|
152
|
+
.query('users')
|
|
153
|
+
.withIndex('by_token', (q) => q.eq('tokenIdentifier', tokenIdentifier))
|
|
154
|
+
.first();
|
|
155
|
+
}
|
|
156
|
+
if (existingUser) {
|
|
157
|
+
// Update user if data changed
|
|
158
|
+
const updates = {};
|
|
159
|
+
if (existingUser.email !== email)
|
|
160
|
+
updates.email = email;
|
|
161
|
+
if (existingUser.name !== name && name)
|
|
162
|
+
updates.name = name;
|
|
163
|
+
if (existingUser.avatarUrl !== avatarUrl && avatarUrl)
|
|
164
|
+
updates.avatarUrl = avatarUrl;
|
|
165
|
+
if (!existingUser.clerkId)
|
|
166
|
+
updates.clerkId = clerkId;
|
|
167
|
+
if (Object.keys(updates).length > 0) {
|
|
168
|
+
updates.updatedAt = Date.now();
|
|
169
|
+
await ctx.db.patch(existingUser._id, updates);
|
|
170
|
+
return await ctx.db.get(existingUser._id);
|
|
171
|
+
}
|
|
172
|
+
return existingUser;
|
|
173
|
+
}
|
|
174
|
+
// Create new user with default "user" role
|
|
175
|
+
const userId = await ctx.db.insert('users', {
|
|
176
|
+
clerkId,
|
|
177
|
+
tokenIdentifier,
|
|
178
|
+
email,
|
|
179
|
+
name,
|
|
180
|
+
avatarUrl,
|
|
181
|
+
role: 'user',
|
|
182
|
+
createdAt: Date.now(),
|
|
183
|
+
updatedAt: Date.now(),
|
|
184
|
+
});
|
|
185
|
+
return await ctx.db.get(userId);
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Get or create organization record in the database
|
|
189
|
+
* Called when user accesses an organization context
|
|
190
|
+
*/
|
|
191
|
+
async function getOrCreateOrganization(ctx, clerkOrgId, name, slug, imageUrl) {
|
|
192
|
+
// Check if org already exists
|
|
193
|
+
const existingOrg = await ctx.db
|
|
194
|
+
.query('organizations')
|
|
195
|
+
.withIndex('by_clerk_id', (q) => q.eq('clerkId', clerkOrgId))
|
|
196
|
+
.first();
|
|
197
|
+
if (existingOrg) {
|
|
198
|
+
// Update org if data changed
|
|
199
|
+
const updates = {};
|
|
200
|
+
if (existingOrg.name !== name)
|
|
201
|
+
updates.name = name;
|
|
202
|
+
if (existingOrg.slug !== slug && slug)
|
|
203
|
+
updates.slug = slug;
|
|
204
|
+
if (existingOrg.imageUrl !== imageUrl && imageUrl)
|
|
205
|
+
updates.imageUrl = imageUrl;
|
|
206
|
+
if (Object.keys(updates).length > 0) {
|
|
207
|
+
updates.updatedAt = Date.now();
|
|
208
|
+
await ctx.db.patch(existingOrg._id, updates);
|
|
209
|
+
return await ctx.db.get(existingOrg._id);
|
|
210
|
+
}
|
|
211
|
+
return existingOrg;
|
|
212
|
+
}
|
|
213
|
+
// Create new organization
|
|
214
|
+
const orgId = await ctx.db.insert('organizations', {
|
|
215
|
+
clerkId: clerkOrgId,
|
|
216
|
+
name,
|
|
217
|
+
slug,
|
|
218
|
+
imageUrl,
|
|
219
|
+
plan: 'free',
|
|
220
|
+
createdAt: Date.now(),
|
|
221
|
+
updatedAt: Date.now(),
|
|
222
|
+
});
|
|
223
|
+
return await ctx.db.get(orgId);
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Get or create membership record
|
|
227
|
+
* Links a user to an organization
|
|
228
|
+
*/
|
|
229
|
+
async function getOrCreateMembership(ctx, userId, orgId, clerkUserId, clerkOrgId, role) {
|
|
230
|
+
// Check if membership already exists
|
|
231
|
+
const existingMembership = await ctx.db
|
|
232
|
+
.query('memberships')
|
|
233
|
+
.withIndex('by_user_org', (q) => q.eq('userId', userId).eq('orgId', orgId))
|
|
234
|
+
.first();
|
|
235
|
+
if (existingMembership) {
|
|
236
|
+
// Update role if changed
|
|
237
|
+
if (existingMembership.role !== role) {
|
|
238
|
+
await ctx.db.patch(existingMembership._id, {
|
|
239
|
+
role,
|
|
240
|
+
updatedAt: Date.now(),
|
|
241
|
+
});
|
|
242
|
+
return await ctx.db.get(existingMembership._id);
|
|
243
|
+
}
|
|
244
|
+
return existingMembership;
|
|
245
|
+
}
|
|
246
|
+
// Create new membership
|
|
247
|
+
const membershipId = await ctx.db.insert('memberships', {
|
|
248
|
+
userId,
|
|
249
|
+
orgId,
|
|
250
|
+
clerkUserId,
|
|
251
|
+
clerkOrgId,
|
|
252
|
+
role,
|
|
253
|
+
joinedAt: Date.now(),
|
|
254
|
+
updatedAt: Date.now(),
|
|
255
|
+
});
|
|
256
|
+
return await ctx.db.get(membershipId);
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Require authenticated user in actions - throws if not logged in.
|
|
260
|
+
* Returns Clerk identity (subject, tokenIdentifier, etc.) for use in Design Studio and other actions.
|
|
261
|
+
*
|
|
262
|
+
* Usage in actions:
|
|
263
|
+
* const identity = await requireUser(ctx);
|
|
264
|
+
* const userId = identity.subject;
|
|
265
|
+
*/
|
|
266
|
+
async function requireUser(ctx) {
|
|
267
|
+
const identity = await ctx.auth.getUserIdentity();
|
|
268
|
+
if (!identity) {
|
|
269
|
+
throw new Error('Unauthorized: Authentication required');
|
|
270
|
+
}
|
|
271
|
+
return identity;
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Require admin access in actions - throws if user is not an admin
|
|
275
|
+
*
|
|
276
|
+
* Since actions don't have direct database access, this function
|
|
277
|
+
* calls a mutation to check the user's role.
|
|
278
|
+
*
|
|
279
|
+
* Usage in actions:
|
|
280
|
+
* await requireAdminInAction(ctx);
|
|
281
|
+
*/
|
|
282
|
+
async function requireAdminInAction(ctx) {
|
|
283
|
+
const identity = await ctx.auth.getUserIdentity();
|
|
284
|
+
if (!identity) {
|
|
285
|
+
throw new Error('Unauthorized: Authentication required');
|
|
286
|
+
}
|
|
287
|
+
// Call a mutation to check the user's role
|
|
288
|
+
const isUserAdmin = await ctx.runMutation(internal.lib.auth.checkIsAdmin, {
|
|
289
|
+
tokenIdentifier: identity.tokenIdentifier,
|
|
290
|
+
});
|
|
291
|
+
if (!isUserAdmin) {
|
|
292
|
+
throw new Error('Forbidden: Admin access required');
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Internal mutation to check if a user is an admin
|
|
297
|
+
* Used by requireAdminInAction
|
|
298
|
+
*/
|
|
299
|
+
exports.checkIsAdmin = (0, server_1.internalMutation)({
|
|
300
|
+
args: { tokenIdentifier: values_1.v.string() },
|
|
301
|
+
handler: async (ctx, { tokenIdentifier }) => {
|
|
302
|
+
const user = await ctx.db
|
|
303
|
+
.query('users')
|
|
304
|
+
.withIndex('by_token', (q) => q.eq('tokenIdentifier', tokenIdentifier))
|
|
305
|
+
.first();
|
|
306
|
+
return user?.role === 'admin';
|
|
307
|
+
},
|
|
308
|
+
});
|
|
309
|
+
// ============================================================================
|
|
310
|
+
// MULTI-TENANCY FUNCTIONS (Organization support)
|
|
311
|
+
// ============================================================================
|
|
312
|
+
/**
|
|
313
|
+
* Get the current organization ID from Clerk token
|
|
314
|
+
* Returns null if user is in personal workspace
|
|
315
|
+
*/
|
|
316
|
+
async function getOrgId(ctx) {
|
|
317
|
+
const identity = await ctx.auth.getUserIdentity();
|
|
318
|
+
if (!identity)
|
|
319
|
+
return null;
|
|
320
|
+
// Clerk Organizations puts org_id in the token claims when org is active
|
|
321
|
+
return identity.org_id ?? null;
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Get the entityId for Composio API calls
|
|
325
|
+
* Returns orgId if in organization, userId if in personal workspace
|
|
326
|
+
*
|
|
327
|
+
* This is the key function for multi-tenancy:
|
|
328
|
+
* - Personal workspace: entityId = userId (tools belong to user)
|
|
329
|
+
* - Team workspace: entityId = orgId (tools shared with team)
|
|
330
|
+
*/
|
|
331
|
+
async function getEntityId(ctx) {
|
|
332
|
+
const identity = await ctx.auth.getUserIdentity();
|
|
333
|
+
if (!identity)
|
|
334
|
+
throw new Error('Unauthenticated');
|
|
335
|
+
const orgId = identity.org_id;
|
|
336
|
+
return orgId ?? identity.subject; // orgId if in org, else userId
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Check if current user is in an organization context
|
|
340
|
+
*/
|
|
341
|
+
async function isInOrganization(ctx) {
|
|
342
|
+
const orgId = await getOrgId(ctx);
|
|
343
|
+
return orgId !== null;
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Require organization context - throws if in personal workspace
|
|
347
|
+
* Use this for team-only features
|
|
348
|
+
*/
|
|
349
|
+
async function requireOrganization(ctx) {
|
|
350
|
+
const orgId = await getOrgId(ctx);
|
|
351
|
+
if (!orgId) {
|
|
352
|
+
throw new Error('This action requires an organization context');
|
|
353
|
+
}
|
|
354
|
+
return orgId;
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Get workspace context for queries
|
|
358
|
+
* Returns filter criteria based on current workspace
|
|
359
|
+
*/
|
|
360
|
+
async function getWorkspaceContext(ctx) {
|
|
361
|
+
const identity = await ctx.auth.getUserIdentity();
|
|
362
|
+
if (!identity)
|
|
363
|
+
throw new Error('Unauthenticated');
|
|
364
|
+
const userId = identity.subject;
|
|
365
|
+
const orgId = identity.org_id ?? null;
|
|
366
|
+
return {
|
|
367
|
+
userId,
|
|
368
|
+
orgId,
|
|
369
|
+
entityId: orgId ?? userId,
|
|
370
|
+
isPersonal: orgId === null,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* convex/lib/rbac.ts
|
|
4
|
+
*
|
|
5
|
+
* 4-tier RBAC: vantage-crm:read / write / admin / cloud-admin
|
|
6
|
+
* requireScope enforces scope at function entry — wraps subscriptions check.
|
|
7
|
+
* D-003: field-level RBAC is handled at query time via custom_field_definitions.visibility.
|
|
8
|
+
*
|
|
9
|
+
* Ref: vantage-crm-spec-2026-05-20.md §5 D-003 / D-010
|
|
10
|
+
*/
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.scopeSatisfies = scopeSatisfies;
|
|
13
|
+
exports.roleToScope = roleToScope;
|
|
14
|
+
exports.requireScope = requireScope;
|
|
15
|
+
exports.requireScopeFromScopes = requireScopeFromScopes;
|
|
16
|
+
exports.assertAdminScope = assertAdminScope;
|
|
17
|
+
exports.canViewField = canViewField;
|
|
18
|
+
exports.checkSubscriptionScope = checkSubscriptionScope;
|
|
19
|
+
const SCOPE_HIERARCHY = [
|
|
20
|
+
'vantage-crm:read',
|
|
21
|
+
'vantage-crm:write',
|
|
22
|
+
'vantage-crm:admin',
|
|
23
|
+
'vantage-crm:cloud-admin',
|
|
24
|
+
];
|
|
25
|
+
function scopeLevel(scope) {
|
|
26
|
+
return SCOPE_HIERARCHY.indexOf(scope);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Returns true if `grantedScope` satisfies `requiredScope`.
|
|
30
|
+
* Higher-tier scopes include all lower-tier permissions.
|
|
31
|
+
*/
|
|
32
|
+
function scopeSatisfies(grantedScope, requiredScope) {
|
|
33
|
+
return scopeLevel(grantedScope) >= scopeLevel(requiredScope);
|
|
34
|
+
}
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Workspace-role → default CRM scope mapping
|
|
37
|
+
// Used when no OAuth token is present (direct Clerk auth).
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
const ROLE_TO_SCOPE = {
|
|
40
|
+
owner: 'vantage-crm:admin',
|
|
41
|
+
admin: 'vantage-crm:admin',
|
|
42
|
+
editor: 'vantage-crm:write',
|
|
43
|
+
viewer: 'vantage-crm:read',
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* Returns the CRM scope implied by a workspace member role.
|
|
47
|
+
*/
|
|
48
|
+
function roleToScope(role) {
|
|
49
|
+
return ROLE_TO_SCOPE[role] ?? 'vantage-crm:read';
|
|
50
|
+
}
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// requireScope — core RBAC gate
|
|
53
|
+
//
|
|
54
|
+
// Checks that the caller's workspace role (or OAuth scopes) satisfies
|
|
55
|
+
// the required scope level. Throws if not.
|
|
56
|
+
//
|
|
57
|
+
// Usage:
|
|
58
|
+
// const { role } = await validateWorkspaceAccess(ctx, workspaceId);
|
|
59
|
+
// await requireScope(ctx, 'vantage-crm:admin', role);
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
async function requireScope(_ctx, requiredScope, callerRole) {
|
|
62
|
+
const callerScope = roleToScope(callerRole);
|
|
63
|
+
if (!scopeSatisfies(callerScope, requiredScope)) {
|
|
64
|
+
throw new Error(`Insufficient scope: required ${requiredScope}, caller has ${callerScope} (role: ${callerRole})`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// requireScopeFromScopes — for OAuth token paths
|
|
69
|
+
// Takes the raw scopes array from validateAccessToken result.
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
function requireScopeFromScopes(scopes, requiredScope) {
|
|
72
|
+
const hasSufficient = scopes.some((s) => {
|
|
73
|
+
const sScope = s;
|
|
74
|
+
if (!SCOPE_HIERARCHY.includes(sScope))
|
|
75
|
+
return false;
|
|
76
|
+
return scopeSatisfies(sScope, requiredScope);
|
|
77
|
+
});
|
|
78
|
+
if (!hasSufficient) {
|
|
79
|
+
throw new Error(`Insufficient OAuth scope: required ${requiredScope}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// assertAdminScope — shorthand for admin-only operations (hard delete, etc.)
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
async function assertAdminScope(ctx, callerRole) {
|
|
86
|
+
await requireScope(ctx, 'vantage-crm:admin', callerRole);
|
|
87
|
+
}
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// canViewField — D-003 field-level RBAC
|
|
90
|
+
// Returns true if the caller role can see a field with the given visibility setting.
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
function canViewField(callerRole, visibility, visibleToRoles) {
|
|
93
|
+
if (visibility === 'all')
|
|
94
|
+
return true;
|
|
95
|
+
if (visibility === 'admin-only') {
|
|
96
|
+
return callerRole === 'owner' || callerRole === 'admin';
|
|
97
|
+
}
|
|
98
|
+
if (visibility === 'role-restricted') {
|
|
99
|
+
if (!visibleToRoles || visibleToRoles.length === 0)
|
|
100
|
+
return false;
|
|
101
|
+
return visibleToRoles.includes(callerRole);
|
|
102
|
+
}
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// checkSubscriptionScope — validates org subscription against required scope.
|
|
107
|
+
// Fetches the active subscription for the org and checks scopes array.
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
async function checkSubscriptionScope(ctx, orgId, requiredScope) {
|
|
110
|
+
const subscription = await ctx.db
|
|
111
|
+
.query('subscriptions')
|
|
112
|
+
.withIndex('by_org', (q) => q.eq('orgId', orgId))
|
|
113
|
+
.filter((q) => q.eq(q.field('status'), 'active'))
|
|
114
|
+
.first();
|
|
115
|
+
if (!subscription) {
|
|
116
|
+
// Free tier — only read is allowed
|
|
117
|
+
if (requiredScope !== 'vantage-crm:read') {
|
|
118
|
+
throw new Error(`No active subscription. Scope ${requiredScope} requires a paid plan.`);
|
|
119
|
+
}
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
requireScopeFromScopes(subscription.scopes, requiredScope);
|
|
123
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* convex/lib/workspace.ts
|
|
4
|
+
*
|
|
5
|
+
* Workspace access validation + actor resolution helpers.
|
|
6
|
+
* Extended V0.1.0: getCurrentActor, getCurrentActorType, assertWorkspaceAccess.
|
|
7
|
+
*
|
|
8
|
+
* Ref: vantage-crm-spec-2026-05-20.md §5 D-001 / OQ-1
|
|
9
|
+
*/
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.validateWorkspaceAccess = validateWorkspaceAccess;
|
|
12
|
+
exports.checkWorkspaceAccess = checkWorkspaceAccess;
|
|
13
|
+
exports.validateRecordAccess = validateRecordAccess;
|
|
14
|
+
exports.getCurrentActor = getCurrentActor;
|
|
15
|
+
exports.getCurrentActorType = getCurrentActorType;
|
|
16
|
+
exports.buildActor = buildActor;
|
|
17
|
+
exports.assertWorkspaceAccess = assertWorkspaceAccess;
|
|
18
|
+
/**
|
|
19
|
+
* Validate workspace access for mutations
|
|
20
|
+
*
|
|
21
|
+
* Authorization logic (Option C - Convex Master Recommended):
|
|
22
|
+
* 1. Personal workspace: Only owner has access (fast path)
|
|
23
|
+
* 2. Org workspace: Check workspaceMembers table
|
|
24
|
+
*
|
|
25
|
+
* IMPORTANT: Does NOT use activeWorkspaceId - that's UI state, not auth!
|
|
26
|
+
*
|
|
27
|
+
* @param ctx - Convex mutation or query context
|
|
28
|
+
* @param workspaceId - The workspace ID to validate access for
|
|
29
|
+
* @returns WorkspaceAccessResult with user info and role
|
|
30
|
+
* @throws Error if not authenticated or no access to workspace
|
|
31
|
+
*/
|
|
32
|
+
async function validateWorkspaceAccess(ctx, workspaceId) {
|
|
33
|
+
// 1. Authenticate
|
|
34
|
+
const identity = await ctx.auth.getUserIdentity();
|
|
35
|
+
if (!identity) {
|
|
36
|
+
throw new Error('Unauthorized: Not authenticated');
|
|
37
|
+
}
|
|
38
|
+
// 2. Get user record
|
|
39
|
+
const user = await ctx.db
|
|
40
|
+
.query('users')
|
|
41
|
+
.withIndex('by_clerk_id', (q) => q.eq('clerkId', identity.subject))
|
|
42
|
+
.first();
|
|
43
|
+
if (!user) {
|
|
44
|
+
throw new Error('Unauthorized: User not found');
|
|
45
|
+
}
|
|
46
|
+
// 3. Get workspace
|
|
47
|
+
const workspace = await ctx.db.get(workspaceId);
|
|
48
|
+
if (!workspace) {
|
|
49
|
+
throw new Error('Not found: Workspace does not exist');
|
|
50
|
+
}
|
|
51
|
+
// 4. Check authorization based on workspace type
|
|
52
|
+
// Fast path: Personal workspace owner (2 DB reads total)
|
|
53
|
+
if (workspace.ownerType === 'user' && workspace.ownerId === user._id) {
|
|
54
|
+
return {
|
|
55
|
+
clerkId: identity.subject,
|
|
56
|
+
userId: user._id,
|
|
57
|
+
role: 'owner',
|
|
58
|
+
canWrite: true,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
// Org workspace: Check membership table (3 DB reads total)
|
|
62
|
+
const membership = await ctx.db
|
|
63
|
+
.query('workspaceMembers')
|
|
64
|
+
.withIndex('by_workspace_user', (q) => q.eq('workspaceId', workspaceId).eq('userId', user._id))
|
|
65
|
+
.first();
|
|
66
|
+
if (membership) {
|
|
67
|
+
const canWrite = ['owner', 'admin', 'editor'].includes(membership.role);
|
|
68
|
+
return {
|
|
69
|
+
clerkId: identity.subject,
|
|
70
|
+
userId: user._id,
|
|
71
|
+
role: membership.role,
|
|
72
|
+
canWrite,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
throw new Error('Unauthorized: No access to this workspace');
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Lightweight version for queries - returns null instead of throwing
|
|
79
|
+
* Use this when you want to check access without throwing an error
|
|
80
|
+
*/
|
|
81
|
+
async function checkWorkspaceAccess(ctx, workspaceId) {
|
|
82
|
+
try {
|
|
83
|
+
return await validateWorkspaceAccess(ctx, workspaceId);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Gets the workspace ID from a record and validates access.
|
|
91
|
+
* Use this when you have a record ID and need to validate workspace access.
|
|
92
|
+
* V0.1.0: accepts CRM entity tables (contacts, companies, deals, activities,
|
|
93
|
+
* custom_object_records, workflow_definitions, workflow_executions).
|
|
94
|
+
*
|
|
95
|
+
* @param ctx - Convex mutation context
|
|
96
|
+
* @param recordId - The record ID to get workspace from
|
|
97
|
+
* @param tableName - The table name
|
|
98
|
+
* @returns The access result and the record
|
|
99
|
+
*/
|
|
100
|
+
async function validateRecordAccess(ctx, recordId, tableName) {
|
|
101
|
+
const record = await ctx.db.get(recordId);
|
|
102
|
+
if (!record) {
|
|
103
|
+
throw new Error(`${tableName.slice(0, -1)} not found`);
|
|
104
|
+
}
|
|
105
|
+
const access = await validateWorkspaceAccess(ctx, record.workspaceId);
|
|
106
|
+
return { access, record };
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Resolve the calling actor from Clerk identity or from a special header/token.
|
|
110
|
+
* For V0.1.0 with Clerk auth: returns user actor.
|
|
111
|
+
* Agent callers (Composio) set actorId explicitly in mutation args — they must
|
|
112
|
+
* pass actorId='agent:composio' as an argument; the function trusts it after
|
|
113
|
+
* workspace access is validated.
|
|
114
|
+
*
|
|
115
|
+
* @param ctx - Convex context
|
|
116
|
+
* @returns Actor object with actorId, actorType, auditActorType
|
|
117
|
+
*/
|
|
118
|
+
async function getCurrentActor(ctx) {
|
|
119
|
+
const identity = await ctx.auth.getUserIdentity();
|
|
120
|
+
if (!identity) {
|
|
121
|
+
throw new Error('Unauthorized: Not authenticated');
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
actorId: identity.subject,
|
|
125
|
+
actorType: 'user',
|
|
126
|
+
auditActorType: 'user',
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Returns the actorType string for the current caller.
|
|
131
|
+
* Convenience wrapper around getCurrentActor.
|
|
132
|
+
*/
|
|
133
|
+
async function getCurrentActorType(ctx) {
|
|
134
|
+
const actor = await getCurrentActor(ctx);
|
|
135
|
+
return actor.actorType;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Build an actor from an explicit actorId override (agent/system callers).
|
|
139
|
+
* Used when a mutation accepts an optional actorId arg for Composio integration.
|
|
140
|
+
*/
|
|
141
|
+
function buildActor(clerkId, overrideActorId) {
|
|
142
|
+
if (overrideActorId === 'agent:composio') {
|
|
143
|
+
return {
|
|
144
|
+
actorId: 'agent:composio',
|
|
145
|
+
actorType: 'agent:composio',
|
|
146
|
+
auditActorType: 'agent',
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
if (overrideActorId === 'system') {
|
|
150
|
+
return {
|
|
151
|
+
actorId: 'system',
|
|
152
|
+
actorType: 'system',
|
|
153
|
+
auditActorType: 'system',
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
return {
|
|
157
|
+
actorId: overrideActorId ?? clerkId,
|
|
158
|
+
actorType: 'user',
|
|
159
|
+
auditActorType: 'user',
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* assertWorkspaceAccess — alias for validateWorkspaceAccess with explicit
|
|
164
|
+
* callerActorId parameter for agent-driven mutations.
|
|
165
|
+
* Returns WorkspaceAccessResult + resolved Actor.
|
|
166
|
+
*/
|
|
167
|
+
async function assertWorkspaceAccess(ctx, workspaceId, overrideActorId) {
|
|
168
|
+
const access = await validateWorkspaceAccess(ctx, workspaceId);
|
|
169
|
+
const actor = buildActor(access.clerkId, overrideActorId);
|
|
170
|
+
return { ...access, actor };
|
|
171
|
+
}
|