History

Simplify invite flow and tighten operational schema enums

Rhys Sullivan committed 9 months ago

Commit 7e076dd1 parent 7e375607 files · +68 −117Browse files

Unified · Split

7 files
  1. executor/apps/web/src/components/app-shell.tsx +1 −1
  2. executor/apps/web/src/components/members-view.tsx +1 −4
  3. executor/convex/app.ts +1 −1
  4. executor/convex/auth.ts +14 −14
  5. executor/convex/database.ts +20 −8
  6. executor/convex/invites.ts +1 −80
  7. executor/convex/schema.ts +30 −9
executor/apps/web/src/components/app-shell.tsx+1−1
@@ -208,7 +208,7 @@ function WorkspaceSelector({ inHeader = false }: { inHeader?: boolean }) { · 207 unmodified lines
208208 New workspace
209209 </DropdownMenuItem>
210210 <DropdownMenuLabel className="text-[10px] text-muted-foreground">
211− Invites via {clientConfig?.invitesProvider === "workos" ? "WorkOS" : "local provider"}
211+ Invites via WorkOS
212212 </DropdownMenuLabel>
213213 </>
214214 ) : null}
executor/apps/web/src/components/members-view.tsx+1−4
@@ -18,7 +18,6 @@ const ROLE_OPTIONS: Role[] = ["owner", "admin", "member", "billing_admin"]; · 17 unmodified lines
1818 export function MembersView() {
1919 const {
2020 context,
21− clientConfig,
2221 workspaces,
2322 } = useSession();
2423
@@ -145,9 +144,7 @@ export function MembersView() { · 120 unmodified lines
145144 {inviteState === "sending" ? "Sending..." : "Send invite"}
146145 </Button>
147146 </div>
148− <p className="text-xs text-muted-foreground">
149− Provider: {clientConfig?.invitesProvider === "workos" ? "WorkOS" : "Local invite flow"}
150− </p>
147+ <p className="text-xs text-muted-foreground">Provider: WorkOS</p>
151148 {inviteMessage ? (
152149 <p className={inviteState === "failed" ? "text-xs text-destructive" : "text-xs text-muted-foreground"}>
153150 {inviteMessage}
executor/convex/app.ts+1−1
@@ -8,7 +8,7 @@ export const getClientConfig = query({ · 7 unmodified lines
88 handler: async () => {
99 return {
1010 authProviderMode: workosEnabled ? "workos" : "local",
11− invitesProvider: workosEnabled ? "workos" : "local",
11+ invitesProvider: workosEnabled ? "workos" : "disabled",
1212 features: {
1313 organizations: true,
1414 billing: true,
executor/convex/auth.ts+14−14
@@ -1,5 +1,4 @@
11 import { AuthKit, type AuthFunctions } from "@convex-dev/workos-authkit";
2−import { v } from "convex/values";
32 import { components, internal } from "./_generated/api";
43 import type { DataModel, Doc, Id } from "./_generated/dataModel";
54 import type { MutationCtx } from "./_generated/server";
@@ -16,10 +15,10 @@ const workosEnabled = Boolean( · 10 unmodified lines
1615 process.env.WORKOS_CLIENT_ID && process.env.WORKOS_API_KEY && process.env.WORKOS_WEBHOOK_SECRET,
1716 );
1817
19−const authFunctions = (internal as any).auth as AuthFunctions;
18+const authFunctions: AuthFunctions = internal.auth;
2019
2120 const authKitInstance = workosEnabled
22− ? new AuthKit<DataModel>((components as any).workOSAuthKit, {
21+ ? new AuthKit<DataModel>(components.workOSAuthKit, {
2322 authFunctions,
2423 additionalEventTypes: [
2524 "organization.created",
@@ -233,7 +232,7 @@ function getIdentityString(identity: Record<string, unknown>, keys: string[]): s · 207 unmodified lines
233232
234233 async function getAuthKitUserProfile(ctx: RunQueryCtx, workosUserId: string) {
235234 try {
236− return await ctx.runQuery((components as any).workOSAuthKit.lib.getAuthUser, {
235+ return await ctx.runQuery(components.workOSAuthKit.lib.getAuthUser, {
237236 id: workosUserId,
238237 });
239238 } catch {
@@ -241,7 +240,7 @@ async function getAuthKitUserProfile(ctx: RunQueryCtx, workosUserId: string) { · 1 unmodified line
241240 }
242241 }
243242
244−const workosEventHandlers: Record<string, (ctx: WorkosEventCtx, event: any) => Promise<void>> = {
243+const workosEventHandlers = {
245244 "user.created": async (ctx, event) => {
246245 const now = Date.now();
247246 const data = event.data;
@@ -572,15 +571,16 @@ const workosEventHandlers: Record<string, (ctx: WorkosEventCtx, event: any) => P · 324 unmodified lines
572571
573572 await ctx.db.delete(membership._id);
574573 },
575−};
576−
577−export const authKitEvent =
578− workosEnabled && authKitInstance
579− ? (authKitInstance.events(workosEventHandlers as any) as any).authKitEvent
580− : internalMutation({
581− args: {},
582− handler: async () => null,
583− });
574+} satisfies Partial<Parameters<AuthKit<DataModel>["events"]>[0]>;
575+
576+const authKitEvents = workosEnabled && authKitInstance
577+ ? authKitInstance.events(workosEventHandlers)
578+ : null;
579+
580+export const authKitEvent = authKitEvents?.authKitEvent ?? internalMutation({
581+ args: {},
582+ handler: async () => null,
583+});
584584
585585 export const bootstrapCurrentWorkosAccount = mutation({
586586 args: {},
executor/convex/database.ts+20−8
@@ -8,6 +8,18 @@ const DEFAULT_TIMEOUT_MS = 15_000; · 7 unmodified lines
88 type OrganizationRole = "owner" | "admin" | "member" | "billing_admin";
99 type OrganizationMemberStatus = "active" | "pending" | "removed";
1010
11+const completedTaskStatusValidator = v.union(
12+ v.literal("completed"),
13+ v.literal("failed"),
14+ v.literal("timed_out"),
15+ v.literal("denied"),
16+);
17+const approvalStatusValidator = v.union(v.literal("pending"), v.literal("approved"), v.literal("denied"));
18+const policyDecisionValidator = v.union(v.literal("allow"), v.literal("require_approval"), v.literal("deny"));
19+const credentialScopeValidator = v.union(v.literal("workspace"), v.literal("actor"));
20+const toolSourceTypeValidator = v.union(v.literal("mcp"), v.literal("openapi"), v.literal("graphql"));
21+const agentTaskStatusValidator = v.union(v.literal("running"), v.literal("completed"), v.literal("failed"));
22+
1123 function normalizeOptional(value?: string): string {
1224 if (typeof value !== "string") {
1325 return "";
@@ -426,7 +438,7 @@ export const markTaskRunning = mutation({ · 412 unmodified lines
426438 export const markTaskFinished = mutation({
427439 args: {
428440 taskId: v.string(),
429− status: v.string(),
441+ status: completedTaskStatusValidator,
430442 stdout: v.string(),
431443 stderr: v.string(),
432444 exitCode: v.optional(v.number()),
@@ -502,7 +514,7 @@ export const getApproval = query({ · 69 unmodified lines
502514 export const listApprovals = query({
503515 args: {
504516 workspaceId: v.string(),
505− status: v.optional(v.string()),
517+ status: v.optional(approvalStatusValidator),
506518 },
507519 handler: async (ctx, args) => {
508520 if (args.status) {
@@ -570,7 +582,7 @@ export const listPendingApprovals = query({ · 61 unmodified lines
570582 export const resolveApproval = mutation({
571583 args: {
572584 approvalId: v.string(),
573− decision: v.string(),
585+ decision: v.union(v.literal("approved"), v.literal("denied")),
574586 reviewerId: v.optional(v.string()),
575587 reason: v.optional(v.string()),
576588 },
@@ -673,7 +685,7 @@ export const getAgentTask = query({ · 96 unmodified lines
673685 export const updateAgentTask = mutation({
674686 args: {
675687 agentTaskId: v.string(),
676− status: v.optional(v.string()),
688+ status: v.optional(agentTaskStatusValidator),
677689 resultText: v.optional(v.string()),
678690 error: v.optional(v.string()),
679691 codeRuns: v.optional(v.number()),
@@ -772,7 +784,7 @@ export const upsertAccessPolicy = mutation({ · 92 unmodified lines
772784 actorId: v.optional(v.string()),
773785 clientId: v.optional(v.string()),
774786 toolPathPattern: v.string(),
775− decision: v.string(),
787+ decision: policyDecisionValidator,
776788 priority: v.optional(v.number()),
777789 },
778790 handler: async (ctx, args) => {
@@ -842,7 +854,7 @@ export const upsertCredential = mutation({ · 63 unmodified lines
842854 id: v.optional(v.string()),
843855 workspaceId: v.string(),
844856 sourceKey: v.string(),
845− scope: v.string(),
857+ scope: credentialScopeValidator,
846858 actorId: v.optional(v.string()),
847859 secretJson: v.any(),
848860 },
@@ -914,7 +926,7 @@ export const resolveCredential = query({ · 65 unmodified lines
914926 args: {
915927 workspaceId: v.string(),
916928 sourceKey: v.string(),
917− scope: v.string(),
929+ scope: credentialScopeValidator,
918930 actorId: v.optional(v.string()),
919931 },
920932 handler: async (ctx, args) => {
@@ -958,7 +970,7 @@ export const upsertToolSource = mutation({ · 37 unmodified lines
958970 id: v.optional(v.string()),
959971 workspaceId: v.string(),
960972 name: v.string(),
961− type: v.string(),
973+ type: toolSourceTypeValidator,
962974 config: v.any(),
963975 enabled: v.optional(v.boolean()),
964976 },
executor/convex/invites.ts+1−80
@@ -2,8 +2,7 @@ import { WorkOS } from "@workos-inc/node"; · 1 unmodified line
22 import { v } from "convex/values";
33 import { internal } from "./_generated/api";
44 import { internalAction, internalMutation, internalQuery } from "./_generated/server";
5−import { getOrganizationMembership } from "./lib/identity";
6−import { authedMutation, organizationMutation, organizationQuery } from "./lib/functionBuilders";
5+import { organizationMutation, organizationQuery } from "./lib/functionBuilders";
76
87 const workosEnabled = Boolean(process.env.WORKOS_CLIENT_ID && process.env.WORKOS_API_KEY);
98 const workosClient = process.env.WORKOS_API_KEY ? new WorkOS(process.env.WORKOS_API_KEY) : null;
@@ -16,14 +15,6 @@ const organizationRoleValidator = v.union( · 6 unmodified lines
1615 v.literal("billing_admin"),
1716 );
1817
19−async function sha256Hex(value: string): Promise<string> {
20− const bytes = new TextEncoder().encode(value);
21− const digest = await crypto.subtle.digest("SHA-256", bytes);
22− return Array.from(new Uint8Array(digest))
23− .map((value) => value.toString(16).padStart(2, "0"))
24− .join("");
25−}
26−
2718 type WorkosInvitationResponse = {
2819 id: string;
2920 state: string;
@@ -136,8 +127,6 @@ export const create = organizationMutation({ · 106 unmodified lines
136127 }
137128 const inviterWorkosUserId = ctx.account.providerAccountId;
138129
139− const token = `invite_${crypto.randomUUID()}`;
140− const tokenHash = await sha256Hex(token);
141130 const provider = "workos";
142131
143132 const inviteId = await ctx.db.insert("invites", {
@@ -146,7 +135,6 @@ export const create = organizationMutation({ · 2 unmodified lines
146135 email: normalizedEmail,
147136 role: args.role,
148137 status: "pending",
149− tokenHash,
150138 provider,
151139 invitedByAccountId: ctx.account._id,
152140 expiresAt,
@@ -174,7 +162,6 @@ export const create = organizationMutation({ · 21 unmodified lines
174162 role: invite.role,
175163 status: invite.status,
176164 expiresAt: invite.expiresAt,
177− token,
178165 },
179166 delivery: {
180167 provider,
@@ -388,69 +375,3 @@ export const markInviteDeliveryFailed = internalMutation({ · 207 unmodified lines
388375 });
389376 },
390377 });
391−
392−export const accept = authedMutation({
393− args: {
394− token: v.string(),
395− },
396− handler: async (ctx, args) => {
397− const tokenHash = await sha256Hex(args.token);
398−
399− const invite = await ctx.db
400− .query("invites")
401− .withIndex("by_token_hash", (q) => q.eq("tokenHash", tokenHash))
402− .unique();
403−
404− if (!invite || invite.status !== "pending") {
405− throw new Error("Invite is invalid or expired");
406− }
407− if (invite.expiresAt < Date.now()) {
408− await ctx.db.patch(invite._id, {
409− status: "expired",
410− updatedAt: Date.now(),
411− });
412− throw new Error("Invite has expired");
413− }
414−
415− const existing = await getOrganizationMembership(ctx, invite.organizationId, ctx.account._id);
416− if (existing) {
417− await ctx.db.patch(existing._id, {
418− role: invite.role,
419− status: "active",
420− joinedAt: existing.joinedAt ?? Date.now(),
421− updatedAt: Date.now(),
422− });
423− } else {
424− await ctx.db.insert("organizationMembers", {
425− organizationId: invite.organizationId,
426− accountId: ctx.account._id,
427− role: invite.role,
428− status: "active",
429− billable: true,
430− invitedByAccountId: invite.invitedByAccountId,
431− joinedAt: Date.now(),
432− createdAt: Date.now(),
433− updatedAt: Date.now(),
434− });
435− }
436−
437− await ctx.db.patch(invite._id, {
438− status: "accepted",
439− acceptedAt: Date.now(),
440− updatedAt: Date.now(),
441− });
442−
443− const nextVersion = await ctx.runMutation(internal.billingInternal.bumpSeatSyncVersion, {
444− organizationId: invite.organizationId,
445− });
446− await ctx.scheduler.runAfter(0, internal.billingSync.syncSeatQuantity, {
447− organizationId: invite.organizationId,
448− expectedVersion: nextVersion,
449− });
450−
451− return {
452− ok: true,
453− organizationId: invite.organizationId,
454− };
455− },
456−});
executor/convex/schema.ts+30−9
@@ -8,6 +8,16 @@ const orgRole = v.union(v.literal("owner"), v.literal("admin"), v.literal("membe · 7 unmodified lines
88 const orgMemberStatus = v.union(v.literal("active"), v.literal("pending"), v.literal("removed"));
99 const workspaceMemberRole = v.union(v.literal("owner"), v.literal("admin"), v.literal("member"));
1010 const workspaceMemberStatus = v.union(v.literal("active"), v.literal("pending"), v.literal("removed"));
11+const billingSubscriptionStatus = v.union(
12+ v.literal("incomplete"),
13+ v.literal("incomplete_expired"),
14+ v.literal("trialing"),
15+ v.literal("active"),
16+ v.literal("past_due"),
17+ v.literal("canceled"),
18+ v.literal("unpaid"),
19+ v.literal("paused"),
20+);
1121 const inviteStatus = v.union(
1222 v.literal("pending"),
1323 v.literal("accepted"),
@@ -16,6 +26,19 @@ const inviteStatus = v.union( · 2 unmodified lines
1626 v.literal("failed"),
1727 );
1828 const inviteProvider = v.literal("workos");
29+const taskStatus = v.union(
30+ v.literal("queued"),
31+ v.literal("running"),
32+ v.literal("completed"),
33+ v.literal("failed"),
34+ v.literal("timed_out"),
35+ v.literal("denied"),
36+);
37+const approvalStatus = v.union(v.literal("pending"), v.literal("approved"), v.literal("denied"));
38+const policyDecision = v.union(v.literal("allow"), v.literal("require_approval"), v.literal("deny"));
39+const credentialScope = v.union(v.literal("workspace"), v.literal("actor"));
40+const toolSourceType = v.union(v.literal("mcp"), v.literal("openapi"), v.literal("graphql"));
41+const agentTaskStatus = v.union(v.literal("running"), v.literal("completed"), v.literal("failed"));
1942
2043 export default defineSchema({
2144 accounts: defineTable({
@@ -102,7 +125,6 @@ export default defineSchema({ · 80 unmodified lines
102125 email: v.string(),
103126 role: orgRole,
104127 status: inviteStatus,
105− tokenHash: v.string(),
106128 provider: inviteProvider,
107129 providerInviteId: v.optional(v.string()),
108130 invitedByAccountId: v.id("accounts"),
@@ -111,7 +133,6 @@ export default defineSchema({ · 2 unmodified lines
111133 createdAt: v.number(),
112134 updatedAt: v.number(),
113135 })
114− .index("by_token_hash", ["tokenHash"])
115136 .index("by_org", ["organizationId"])
116137 .index("by_org_status_created", ["organizationId", "status", "createdAt"])
117138 .index("by_org_email_status", ["organizationId", "email", "status"]),
@@ -129,7 +150,7 @@ export default defineSchema({ · 11 unmodified lines
129150 organizationId: v.id("organizations"),
130151 stripeSubscriptionId: v.string(),
131152 stripePriceId: v.string(),
132− status: v.string(),
153+ status: billingSubscriptionStatus,
133154 currentPeriodStart: v.optional(v.number()),
134155 currentPeriodEnd: v.optional(v.number()),
135156 cancelAtPeriodEnd: v.boolean(),
@@ -158,7 +179,7 @@ export default defineSchema({ · 22 unmodified lines
158179 workspaceId: v.string(),
159180 actorId: v.string(),
160181 clientId: v.string(),
161− status: v.string(),
182+ status: taskStatus,
162183 timeoutMs: v.number(),
163184 metadata: v.any(),
164185 error: v.optional(v.string()),
@@ -180,7 +201,7 @@ export default defineSchema({ · 15 unmodified lines
180201 workspaceId: v.string(),
181202 toolPath: v.string(),
182203 input: v.any(),
183− status: v.string(),
204+ status: approvalStatus,
184205 reason: v.optional(v.string()),
185206 reviewerId: v.optional(v.string()),
186207 createdAt: v.number(),
@@ -208,7 +229,7 @@ export default defineSchema({ · 21 unmodified lines
208229 actorId: v.string(),
209230 clientId: v.string(),
210231 toolPathPattern: v.string(),
211− decision: v.string(),
232+ decision: policyDecision,
212233 priority: v.number(),
213234 createdAt: v.number(),
214235 updatedAt: v.number(),
@@ -220,7 +241,7 @@ export default defineSchema({ · 5 unmodified lines
220241 credentialId: v.string(),
221242 workspaceId: v.string(),
222243 sourceKey: v.string(),
223− scope: v.string(),
244+ scope: credentialScope,
224245 actorId: v.string(),
225246 secretJson: v.any(),
226247 createdAt: v.number(),
@@ -234,7 +255,7 @@ export default defineSchema({ · 7 unmodified lines
234255 sourceId: v.string(),
235256 workspaceId: v.string(),
236257 name: v.string(),
237− type: v.string(),
258+ type: toolSourceType,
238259 config: v.any(),
239260 enabled: v.boolean(),
240261 createdAt: v.number(),
@@ -251,7 +272,7 @@ export default defineSchema({ · 10 unmodified lines
251272 requesterId: v.string(),
252273 workspaceId: v.string(),
253274 actorId: v.string(),
254− status: v.string(), // "running" | "completed" | "failed"
275+ status: agentTaskStatus,
255276 resultText: v.optional(v.string()),
256277 error: v.optional(v.string()),
257278 codeRuns: v.number(),