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
208 New workspace208 New workspace
209 </DropdownMenuItem>209 </DropdownMenuItem>
210 <DropdownMenuLabel className="text-[10px] text-muted-foreground">210 <DropdownMenuLabel className="text-[10px] text-muted-foreground">
211 Invites via {clientConfig?.invitesProvider === "workos" ? "WorkOS" : "local provider"}211 Invites via WorkOS
212 </DropdownMenuLabel>212 </DropdownMenuLabel>
213 </>213 </>
214 ) : null}214 ) : 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
18export function MembersView() {18export function MembersView() {
19 const {19 const {
20 context,20 context,
21 clientConfig,
22 workspaces,21 workspaces,
23 } = useSession();22 } = useSession();
24 23
@@ -145,9 +144,7 @@ export function MembersView() { · 120 unmodified lines
145 {inviteState === "sending" ? "Sending..." : "Send invite"}144 {inviteState === "sending" ? "Sending..." : "Send invite"}
146 </Button>145 </Button>
147 </div>146 </div>
148 <p className="text-xs text-muted-foreground">147 <p className="text-xs text-muted-foreground">Provider: WorkOS</p>
149 Provider: {clientConfig?.invitesProvider === "workos" ? "WorkOS" : "Local invite flow"}
150 </p>
151 {inviteMessage ? (148 {inviteMessage ? (
152 <p className={inviteState === "failed" ? "text-xs text-destructive" : "text-xs text-muted-foreground"}>149 <p className={inviteState === "failed" ? "text-xs text-destructive" : "text-xs text-muted-foreground"}>
153 {inviteMessage}150 {inviteMessage}
executor/convex/app.ts+1−1
@@ -8,7 +8,7 @@ export const getClientConfig = query({ · 7 unmodified lines
8 handler: async () => {8 handler: async () => {
9 return {9 return {
10 authProviderMode: workosEnabled ? "workos" : "local",10 authProviderMode: workosEnabled ? "workos" : "local",
11 invitesProvider: workosEnabled ? "workos" : "local",11 invitesProvider: workosEnabled ? "workos" : "disabled",
12 features: {12 features: {
13 organizations: true,13 organizations: true,
14 billing: true,14 billing: true,
executor/convex/auth.ts+14−14
@@ -1,5 +1,4 @@
1import { AuthKit, type AuthFunctions } from "@convex-dev/workos-authkit";1import { AuthKit, type AuthFunctions } from "@convex-dev/workos-authkit";
2import { v } from "convex/values";
3import { components, internal } from "./_generated/api";2import { components, internal } from "./_generated/api";
4import type { DataModel, Doc, Id } from "./_generated/dataModel";3import type { DataModel, Doc, Id } from "./_generated/dataModel";
5import type { MutationCtx } from "./_generated/server";4import type { MutationCtx } from "./_generated/server";
@@ -16,10 +15,10 @@ const workosEnabled = Boolean( · 10 unmodified lines
16 process.env.WORKOS_CLIENT_ID && process.env.WORKOS_API_KEY && process.env.WORKOS_WEBHOOK_SECRET,15 process.env.WORKOS_CLIENT_ID && process.env.WORKOS_API_KEY && process.env.WORKOS_WEBHOOK_SECRET,
17);16);
18 17
19const authFunctions = (internal as any).auth as AuthFunctions;18const authFunctions: AuthFunctions = internal.auth;
20 19
21const authKitInstance = workosEnabled20const authKitInstance = workosEnabled
22 ? new AuthKit<DataModel>((components as any).workOSAuthKit, {21 ? new AuthKit<DataModel>(components.workOSAuthKit, {
23 authFunctions,22 authFunctions,
24 additionalEventTypes: [23 additionalEventTypes: [
25 "organization.created",24 "organization.created",
@@ -233,7 +232,7 @@ function getIdentityString(identity: Record<string, unknown>, keys: string[]): s · 207 unmodified lines
233 232
234async function getAuthKitUserProfile(ctx: RunQueryCtx, workosUserId: string) {233async function getAuthKitUserProfile(ctx: RunQueryCtx, workosUserId: string) {
235 try {234 try {
236 return await ctx.runQuery((components as any).workOSAuthKit.lib.getAuthUser, {235 return await ctx.runQuery(components.workOSAuthKit.lib.getAuthUser, {
237 id: workosUserId,236 id: workosUserId,
238 });237 });
239 } catch {238 } catch {
@@ -241,7 +240,7 @@ async function getAuthKitUserProfile(ctx: RunQueryCtx, workosUserId: string) { · 1 unmodified line
241 }240 }
242}241}
243 242
244const workosEventHandlers: Record<string, (ctx: WorkosEventCtx, event: any) => Promise<void>> = {243const workosEventHandlers = {
245 "user.created": async (ctx, event) => {244 "user.created": async (ctx, event) => {
246 const now = Date.now();245 const now = Date.now();
247 const data = event.data;246 const data = event.data;
@@ -572,15 +571,16 @@ const workosEventHandlers: Record<string, (ctx: WorkosEventCtx, event: any) => P · 324 unmodified lines
572 571
573 await ctx.db.delete(membership._id);572 await ctx.db.delete(membership._id);
574 },573 },
575};574} satisfies Partial<Parameters<AuthKit<DataModel>["events"]>[0]>;
576 575
577export const authKitEvent =576const authKitEvents = workosEnabled && authKitInstance
578 workosEnabled && authKitInstance577 ? authKitInstance.events(workosEventHandlers)
579 ? (authKitInstance.events(workosEventHandlers as any) as any).authKitEvent578 : null;
580 : internalMutation({579
581 args: {},580export const authKitEvent = authKitEvents?.authKitEvent ?? internalMutation({
582 handler: async () => null,581 args: {},
583 });582 handler: async () => null,
583});
584 584
585export const bootstrapCurrentWorkosAccount = mutation({585export const bootstrapCurrentWorkosAccount = mutation({
586 args: {},586 args: {},
executor/convex/database.ts+20−8
@@ -8,6 +8,18 @@ const DEFAULT_TIMEOUT_MS = 15_000; · 7 unmodified lines
8type OrganizationRole = "owner" | "admin" | "member" | "billing_admin";8type OrganizationRole = "owner" | "admin" | "member" | "billing_admin";
9type OrganizationMemberStatus = "active" | "pending" | "removed";9type OrganizationMemberStatus = "active" | "pending" | "removed";
10 10
11const completedTaskStatusValidator = v.union(
12 v.literal("completed"),
13 v.literal("failed"),
14 v.literal("timed_out"),
15 v.literal("denied"),
16);
17const approvalStatusValidator = v.union(v.literal("pending"), v.literal("approved"), v.literal("denied"));
18const policyDecisionValidator = v.union(v.literal("allow"), v.literal("require_approval"), v.literal("deny"));
19const credentialScopeValidator = v.union(v.literal("workspace"), v.literal("actor"));
20const toolSourceTypeValidator = v.union(v.literal("mcp"), v.literal("openapi"), v.literal("graphql"));
21const agentTaskStatusValidator = v.union(v.literal("running"), v.literal("completed"), v.literal("failed"));
22
11function normalizeOptional(value?: string): string {23function normalizeOptional(value?: string): string {
12 if (typeof value !== "string") {24 if (typeof value !== "string") {
13 return "";25 return "";
@@ -426,7 +438,7 @@ export const markTaskRunning = mutation({ · 412 unmodified lines
426export const markTaskFinished = mutation({438export const markTaskFinished = mutation({
427 args: {439 args: {
428 taskId: v.string(),440 taskId: v.string(),
429 status: v.string(),441 status: completedTaskStatusValidator,
430 stdout: v.string(),442 stdout: v.string(),
431 stderr: v.string(),443 stderr: v.string(),
432 exitCode: v.optional(v.number()),444 exitCode: v.optional(v.number()),
@@ -502,7 +514,7 @@ export const getApproval = query({ · 69 unmodified lines
502export const listApprovals = query({514export const listApprovals = query({
503 args: {515 args: {
504 workspaceId: v.string(),516 workspaceId: v.string(),
505 status: v.optional(v.string()),517 status: v.optional(approvalStatusValidator),
506 },518 },
507 handler: async (ctx, args) => {519 handler: async (ctx, args) => {
508 if (args.status) {520 if (args.status) {
@@ -570,7 +582,7 @@ export const listPendingApprovals = query({ · 61 unmodified lines
570export const resolveApproval = mutation({582export const resolveApproval = mutation({
571 args: {583 args: {
572 approvalId: v.string(),584 approvalId: v.string(),
573 decision: v.string(),585 decision: v.union(v.literal("approved"), v.literal("denied")),
574 reviewerId: v.optional(v.string()),586 reviewerId: v.optional(v.string()),
575 reason: v.optional(v.string()),587 reason: v.optional(v.string()),
576 },588 },
@@ -673,7 +685,7 @@ export const getAgentTask = query({ · 96 unmodified lines
673export const updateAgentTask = mutation({685export const updateAgentTask = mutation({
674 args: {686 args: {
675 agentTaskId: v.string(),687 agentTaskId: v.string(),
676 status: v.optional(v.string()),688 status: v.optional(agentTaskStatusValidator),
677 resultText: v.optional(v.string()),689 resultText: v.optional(v.string()),
678 error: v.optional(v.string()),690 error: v.optional(v.string()),
679 codeRuns: v.optional(v.number()),691 codeRuns: v.optional(v.number()),
@@ -772,7 +784,7 @@ export const upsertAccessPolicy = mutation({ · 92 unmodified lines
772 actorId: v.optional(v.string()),784 actorId: v.optional(v.string()),
773 clientId: v.optional(v.string()),785 clientId: v.optional(v.string()),
774 toolPathPattern: v.string(),786 toolPathPattern: v.string(),
775 decision: v.string(),787 decision: policyDecisionValidator,
776 priority: v.optional(v.number()),788 priority: v.optional(v.number()),
777 },789 },
778 handler: async (ctx, args) => {790 handler: async (ctx, args) => {
@@ -842,7 +854,7 @@ export const upsertCredential = mutation({ · 63 unmodified lines
842 id: v.optional(v.string()),854 id: v.optional(v.string()),
843 workspaceId: v.string(),855 workspaceId: v.string(),
844 sourceKey: v.string(),856 sourceKey: v.string(),
845 scope: v.string(),857 scope: credentialScopeValidator,
846 actorId: v.optional(v.string()),858 actorId: v.optional(v.string()),
847 secretJson: v.any(),859 secretJson: v.any(),
848 },860 },
@@ -914,7 +926,7 @@ export const resolveCredential = query({ · 65 unmodified lines
914 args: {926 args: {
915 workspaceId: v.string(),927 workspaceId: v.string(),
916 sourceKey: v.string(),928 sourceKey: v.string(),
917 scope: v.string(),929 scope: credentialScopeValidator,
918 actorId: v.optional(v.string()),930 actorId: v.optional(v.string()),
919 },931 },
920 handler: async (ctx, args) => {932 handler: async (ctx, args) => {
@@ -958,7 +970,7 @@ export const upsertToolSource = mutation({ · 37 unmodified lines
958 id: v.optional(v.string()),970 id: v.optional(v.string()),
959 workspaceId: v.string(),971 workspaceId: v.string(),
960 name: v.string(),972 name: v.string(),
961 type: v.string(),973 type: toolSourceTypeValidator,
962 config: v.any(),974 config: v.any(),
963 enabled: v.optional(v.boolean()),975 enabled: v.optional(v.boolean()),
964 },976 },
executor/convex/invites.ts+1−80
@@ -2,8 +2,7 @@ import { WorkOS } from "@workos-inc/node"; · 1 unmodified line
2import { v } from "convex/values";2import { v } from "convex/values";
3import { internal } from "./_generated/api";3import { internal } from "./_generated/api";
4import { internalAction, internalMutation, internalQuery } from "./_generated/server";4import { internalAction, internalMutation, internalQuery } from "./_generated/server";
5import { getOrganizationMembership } from "./lib/identity";5import { organizationMutation, organizationQuery } from "./lib/functionBuilders";
6import { authedMutation, organizationMutation, organizationQuery } from "./lib/functionBuilders";
7 6
8const workosEnabled = Boolean(process.env.WORKOS_CLIENT_ID && process.env.WORKOS_API_KEY);7const workosEnabled = Boolean(process.env.WORKOS_CLIENT_ID && process.env.WORKOS_API_KEY);
9const workosClient = process.env.WORKOS_API_KEY ? new WorkOS(process.env.WORKOS_API_KEY) : null;8const 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
16 v.literal("billing_admin"),15 v.literal("billing_admin"),
17);16);
18 17
19async 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
27type WorkosInvitationResponse = {18type WorkosInvitationResponse = {
28 id: string;19 id: string;
29 state: string;20 state: string;
@@ -136,8 +127,6 @@ export const create = organizationMutation({ · 106 unmodified lines
136 }127 }
137 const inviterWorkosUserId = ctx.account.providerAccountId;128 const inviterWorkosUserId = ctx.account.providerAccountId;
138 129
139 const token = `invite_${crypto.randomUUID()}`;
140 const tokenHash = await sha256Hex(token);
141 const provider = "workos";130 const provider = "workos";
142 131
143 const inviteId = await ctx.db.insert("invites", {132 const inviteId = await ctx.db.insert("invites", {
@@ -146,7 +135,6 @@ export const create = organizationMutation({ · 2 unmodified lines
146 email: normalizedEmail,135 email: normalizedEmail,
147 role: args.role,136 role: args.role,
148 status: "pending",137 status: "pending",
149 tokenHash,
150 provider,138 provider,
151 invitedByAccountId: ctx.account._id,139 invitedByAccountId: ctx.account._id,
152 expiresAt,140 expiresAt,
@@ -174,7 +162,6 @@ export const create = organizationMutation({ · 21 unmodified lines
174 role: invite.role,162 role: invite.role,
175 status: invite.status,163 status: invite.status,
176 expiresAt: invite.expiresAt,164 expiresAt: invite.expiresAt,
177 token,
178 },165 },
179 delivery: {166 delivery: {
180 provider,167 provider,
@@ -388,69 +375,3 @@ export const markInviteDeliveryFailed = internalMutation({ · 207 unmodified lines
388 });375 });
389 },376 },
390});377});
391
392export 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
8const orgMemberStatus = v.union(v.literal("active"), v.literal("pending"), v.literal("removed"));8const orgMemberStatus = v.union(v.literal("active"), v.literal("pending"), v.literal("removed"));
9const workspaceMemberRole = v.union(v.literal("owner"), v.literal("admin"), v.literal("member"));9const workspaceMemberRole = v.union(v.literal("owner"), v.literal("admin"), v.literal("member"));
10const workspaceMemberStatus = v.union(v.literal("active"), v.literal("pending"), v.literal("removed"));10const workspaceMemberStatus = v.union(v.literal("active"), v.literal("pending"), v.literal("removed"));
11const 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);
11const inviteStatus = v.union(21const inviteStatus = v.union(
12 v.literal("pending"),22 v.literal("pending"),
13 v.literal("accepted"),23 v.literal("accepted"),
@@ -16,6 +26,19 @@ const inviteStatus = v.union( · 2 unmodified lines
16 v.literal("failed"),26 v.literal("failed"),
17);27);
18const inviteProvider = v.literal("workos");28const inviteProvider = v.literal("workos");
29const 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);
37const approvalStatus = v.union(v.literal("pending"), v.literal("approved"), v.literal("denied"));
38const policyDecision = v.union(v.literal("allow"), v.literal("require_approval"), v.literal("deny"));
39const credentialScope = v.union(v.literal("workspace"), v.literal("actor"));
40const toolSourceType = v.union(v.literal("mcp"), v.literal("openapi"), v.literal("graphql"));
41const agentTaskStatus = v.union(v.literal("running"), v.literal("completed"), v.literal("failed"));
19 42
20export default defineSchema({43export default defineSchema({
21 accounts: defineTable({44 accounts: defineTable({
@@ -102,7 +125,6 @@ export default defineSchema({ · 80 unmodified lines
102 email: v.string(),125 email: v.string(),
103 role: orgRole,126 role: orgRole,
104 status: inviteStatus,127 status: inviteStatus,
105 tokenHash: v.string(),
106 provider: inviteProvider,128 provider: inviteProvider,
107 providerInviteId: v.optional(v.string()),129 providerInviteId: v.optional(v.string()),
108 invitedByAccountId: v.id("accounts"),130 invitedByAccountId: v.id("accounts"),
@@ -111,7 +133,6 @@ export default defineSchema({ · 2 unmodified lines
111 createdAt: v.number(),133 createdAt: v.number(),
112 updatedAt: v.number(),134 updatedAt: v.number(),
113 })135 })
114 .index("by_token_hash", ["tokenHash"])
115 .index("by_org", ["organizationId"])136 .index("by_org", ["organizationId"])
116 .index("by_org_status_created", ["organizationId", "status", "createdAt"])137 .index("by_org_status_created", ["organizationId", "status", "createdAt"])
117 .index("by_org_email_status", ["organizationId", "email", "status"]),138 .index("by_org_email_status", ["organizationId", "email", "status"]),
@@ -129,7 +150,7 @@ export default defineSchema({ · 11 unmodified lines
129 organizationId: v.id("organizations"),150 organizationId: v.id("organizations"),
130 stripeSubscriptionId: v.string(),151 stripeSubscriptionId: v.string(),
131 stripePriceId: v.string(),152 stripePriceId: v.string(),
132 status: v.string(),153 status: billingSubscriptionStatus,
133 currentPeriodStart: v.optional(v.number()),154 currentPeriodStart: v.optional(v.number()),
134 currentPeriodEnd: v.optional(v.number()),155 currentPeriodEnd: v.optional(v.number()),
135 cancelAtPeriodEnd: v.boolean(),156 cancelAtPeriodEnd: v.boolean(),
@@ -158,7 +179,7 @@ export default defineSchema({ · 22 unmodified lines
158 workspaceId: v.string(),179 workspaceId: v.string(),
159 actorId: v.string(),180 actorId: v.string(),
160 clientId: v.string(),181 clientId: v.string(),
161 status: v.string(),182 status: taskStatus,
162 timeoutMs: v.number(),183 timeoutMs: v.number(),
163 metadata: v.any(),184 metadata: v.any(),
164 error: v.optional(v.string()),185 error: v.optional(v.string()),
@@ -180,7 +201,7 @@ export default defineSchema({ · 15 unmodified lines
180 workspaceId: v.string(),201 workspaceId: v.string(),
181 toolPath: v.string(),202 toolPath: v.string(),
182 input: v.any(),203 input: v.any(),
183 status: v.string(),204 status: approvalStatus,
184 reason: v.optional(v.string()),205 reason: v.optional(v.string()),
185 reviewerId: v.optional(v.string()),206 reviewerId: v.optional(v.string()),
186 createdAt: v.number(),207 createdAt: v.number(),
@@ -208,7 +229,7 @@ export default defineSchema({ · 21 unmodified lines
208 actorId: v.string(),229 actorId: v.string(),
209 clientId: v.string(),230 clientId: v.string(),
210 toolPathPattern: v.string(),231 toolPathPattern: v.string(),
211 decision: v.string(),232 decision: policyDecision,
212 priority: v.number(),233 priority: v.number(),
213 createdAt: v.number(),234 createdAt: v.number(),
214 updatedAt: v.number(),235 updatedAt: v.number(),
@@ -220,7 +241,7 @@ export default defineSchema({ · 5 unmodified lines
220 credentialId: v.string(),241 credentialId: v.string(),
221 workspaceId: v.string(),242 workspaceId: v.string(),
222 sourceKey: v.string(),243 sourceKey: v.string(),
223 scope: v.string(),244 scope: credentialScope,
224 actorId: v.string(),245 actorId: v.string(),
225 secretJson: v.any(),246 secretJson: v.any(),
226 createdAt: v.number(),247 createdAt: v.number(),
@@ -234,7 +255,7 @@ export default defineSchema({ · 7 unmodified lines
234 sourceId: v.string(),255 sourceId: v.string(),
235 workspaceId: v.string(),256 workspaceId: v.string(),
236 name: v.string(),257 name: v.string(),
237 type: v.string(),258 type: toolSourceType,
238 config: v.any(),259 config: v.any(),
239 enabled: v.boolean(),260 enabled: v.boolean(),
240 createdAt: v.number(),261 createdAt: v.number(),
@@ -251,7 +272,7 @@ export default defineSchema({ · 10 unmodified lines
251 requesterId: v.string(),272 requesterId: v.string(),
252 workspaceId: v.string(),273 workspaceId: v.string(),
253 actorId: v.string(),274 actorId: v.string(),
254 status: v.string(), // "running" | "completed" | "failed"275 status: agentTaskStatus,
255 resultText: v.optional(v.string()),276 resultText: v.optional(v.string()),
256 error: v.optional(v.string()),277 error: v.optional(v.string()),
257 codeRuns: v.number(),278 codeRuns: v.number(),