History

Simplify executor data invariants and split test suites

Move membership and actor handling toward write-time consistency, centralize tool registry/config validation, and reduce schema/index complexity to cut runtime edge cases. Also split executor tests into fast/integration/e2e buckets and make scheduler behavior test-safe by default.

Rhys Sullivan committed 8 months ago

Commit 271dc0e1 parent 3b5b63e33 files · +909 −408Browse files

Unified · Split

33 files
  1. CLAUDE.md +14 −2
  2. executor/package.json +4 −0
  3. executor/packages/core/src/identity.ts +30 −36
  4. executor/packages/core/src/runtimes/cloudflare/worker/loader-runtime.test.ts +9 −0
  5. executor/packages/core/src/tool/source-serialization.ts +12 −5
  6. executor/packages/database/convex/access-controls.test.ts +48 −2
  7. executor/packages/database/convex/database/anonymous_session.ts +1 −1
  8. executor/packages/database/convex/database/tool_sources.ts +2 −2
  9. executor/packages/database/convex/executor-mcp.e2e.test.ts +25 −1
  10. executor/packages/database/convex/invites.ts +3 −2
  11. executor/packages/database/convex/organizationMembers.ts +20 −6
  12. executor/packages/database/convex/schema.ts +4 −12
  13. executor/packages/database/src/auth/actor_identity.ts +31 −0
  14. executor/packages/database/src/auth/bootstrap.ts +20 −31
  15. executor/packages/database/src/auth/event_handlers.ts +100 −101
  16. executor/packages/database/src/auth/memberships.ts +21 −49
  17. executor/packages/database/src/auth/workspace_membership_projection.ts +126 −0
  18. executor/packages/database/src/billing/public-handlers.ts +2 −1
  19. executor/packages/database/src/credentials-node/upsert-credential.ts +7 −6
  20. executor/packages/database/src/database/anonymous.ts +2 −42
  21. executor/packages/database/src/database/tool_source_config.ts +179 −0
  22. executor/packages/database/src/executor/handlers.ts +7 −12
  23. executor/packages/database/src/lib/scheduler.ts +14 −1
  24. executor/packages/database/src/organizations/handlers.ts +14 −4
  25. executor/packages/database/src/runtime/actor_auth.ts +6 −9
  26. executor/packages/database/src/runtime/tool_call_resolution.ts +7 −25
  27. executor/packages/database/src/runtime/tool_invocation.ts +7 −14
  28. executor/packages/database/src/runtime/tool_registry_state.ts +77 −0
  29. executor/packages/database/src/runtime/tool_source_loading.ts +5 −38
  30. executor/packages/database/src/runtime/workspace_tools.ts +2 −1
  31. executor/packages/database/src/workspaces/handlers.ts +14 −4
  32. executor/scripts/test/run-suite.ts +92 −0
  33. package.json +4 −1
CLAUDE.md+14−2
@@ -33,13 +33,16 @@ Run from repo root unless noted. · 32 unmodified lines
33 33
34### Main quality commands34### Main quality commands
35 35
36- `bun run test` - runs executor + assistant test suites.36- `bun run test` - runs executor fast suite + assistant tests.
37- `bun run typecheck` - runs executor + assistant TypeScript checks.37- `bun run typecheck` - runs executor + assistant TypeScript checks.
38- `bun run knip` - dead-code/unused-export scan.38- `bun run knip` - dead-code/unused-export scan.
39 39
40### Package-specific quality commands40### Package-specific quality commands
41 41
42- `bun run test:executor` - `bun test --cwd executor`.42- `bun run test:executor` - executor fast/normal tests (`bun run --cwd executor test`).
43- `bun run test:executor:integration` - executor integration tests (`bun run --cwd executor test:integration`).
44- `bun run test:executor:e2e` - executor e2e tests (`bun run --cwd executor test:e2e`).
45- `bun run test:executor:all` - all executor tests (`bun run --cwd executor test:all`).
43- `bun run test:assistant` - assistant tests (`assistant/packages/core/src`).46- `bun run test:assistant` - assistant tests (`assistant/packages/core/src`).
44- `bun run typecheck:executor` - `tsc --noEmit -p executor/tsconfig.json`.47- `bun run typecheck:executor` - `tsc --noEmit -p executor/tsconfig.json`.
45- `bun run typecheck:assistant` - checks server + bot tsconfig files.48- `bun run typecheck:assistant` - checks server + bot tsconfig files.
@@ -135,9 +138,18 @@ Run from repo root unless noted. · 89 unmodified lines
135 138
136- Use `bun:test` (`import { test, expect } from "bun:test"`).139- Use `bun:test` (`import { test, expect } from "bun:test"`).
137- Keep tests close to code with `.test.ts` / `.e2e.test.ts` naming.140- Keep tests close to code with `.test.ts` / `.e2e.test.ts` naming.
141- Keep scheduler behavior test-safe by default; do not require `DISABLE_CONVEX_SCHEDULER=1` to run the normal fast suite.
138- When changing Convex auth/access logic, add/adjust permission tests in `executor/packages/convex/access-controls.test.ts`.142- When changing Convex auth/access logic, add/adjust permission tests in `executor/packages/convex/access-controls.test.ts`.
139- When changing tool discovery/typing behavior, update tests in `executor/packages/core/src/*.test.ts`.143- When changing tool discovery/typing behavior, update tests in `executor/packages/core/src/*.test.ts`.
140 144
145## User Preferences (Observed)
146
147- Preserve local/cloud parity: prefer the same architecture and runtime behavior in both environments.
148- Reduce complexity by enforcing write-time invariants over read-time repair (`ensure*` backfills should be minimal and clearly scoped).
149- For greenfield iterations, prefer clearing/resetting the DB over adding one-off migrations.
150- Keep default feedback loops fast: split tests into fast, integration, and e2e buckets and make fast the default.
151- Favor practical simplification over theoretical abstractions when refactoring data models and auth flows.
152
141## Generated/Build Artifacts153## Generated/Build Artifacts
142 154
143- Do not manually edit generated/build outputs such as:155- Do not manually edit generated/build outputs such as:
executor/package.json+4−0
@@ -26,6 +26,10 @@ · 25 unmodified lines
26 "web": "bun run executor.ts web",26 "web": "bun run executor.ts web",
27 "deploy": "bunx convex deploy",27 "deploy": "bunx convex deploy",
28 "codegen": "bunx convex codegen",28 "codegen": "bunx convex codegen",
29 "test": "bun run scripts/test/run-suite.ts fast",
30 "test:integration": "bun run scripts/test/run-suite.ts integration",
31 "test:e2e": "bun run scripts/test/run-suite.ts e2e",
32 "test:all": "bun run scripts/test/run-suite.ts all",
29 "test:e2e:install": "bun test scripts/e2e/install.e2e.test.ts",33 "test:e2e:install": "bun test scripts/e2e/install.e2e.test.ts",
30 "test:e2e:install:sandbox": "bun test scripts/e2e/install.sandbox.e2e.test.ts",34 "test:e2e:install:sandbox": "bun test scripts/e2e/install.sandbox.e2e.test.ts",
31 "sandbox:install:session": "bun run scripts/e2e/install.sandbox.session.ts",35 "sandbox:install:session": "bun run scripts/e2e/install.sandbox.session.ts",
executor/packages/core/src/identity.ts+30−36
@@ -7,18 +7,6 @@ type MembershipCtx = Pick<QueryCtx, "db"> | Pick<MutationCtx, "db">; · 6 unmodified lines
7 7
8type WorkspaceAccessMembership = Pick<Doc<"workspaceMembers">, "role" | "status">;8type WorkspaceAccessMembership = Pick<Doc<"workspaceMembers">, "role" | "status">;
9 9
10function mapOrganizationRoleToWorkspaceRole(
11 role: Doc<"organizationMembers">["role"],
12): Doc<"workspaceMembers">["role"] {
13 if (role === "owner") {
14 return "owner";
15 }
16 if (role === "admin") {
17 return "admin";
18 }
19 return "member";
20}
21
22export type WorkspaceAccess = {10export type WorkspaceAccess = {
23 account: Doc<"accounts">;11 account: Doc<"accounts">;
24 workspace: Doc<"workspaces">;12 workspace: Doc<"workspaces">;
@@ -80,10 +68,22 @@ export async function getOrganizationMembership( · 55 unmodified lines
80 organizationId: Id<"organizations">,68 organizationId: Id<"organizations">,
81 accountId: Id<"accounts">,69 accountId: Id<"accounts">,
82) {70) {
83 return await ctx.db71 const memberships = await ctx.db
84 .query("organizationMembers")72 .query("organizationMembers")
85 .withIndex("by_org_account", (q) => q.eq("organizationId", organizationId).eq("accountId", accountId))73 .withIndex("by_org_account", (q) => q.eq("organizationId", organizationId).eq("accountId", accountId))
86 .unique();74 .collect();
75
76 if (memberships.length === 0) {
77 return null;
78 }
79
80 memberships.sort((a, b) => {
81 const statusRankA = a.status === "active" ? 2 : a.status === "pending" ? 1 : 0;
82 const statusRankB = b.status === "active" ? 2 : b.status === "pending" ? 1 : 0;
83 return statusRankB - statusRankA || b.updatedAt - a.updatedAt;
84 });
85
86 return memberships[0]!;
87}87}
88 88
89export async function getWorkspaceMembership(89export async function getWorkspaceMembership(
@@ -91,10 +91,22 @@ export async function getWorkspaceMembership( · 1 unmodified line
91 workspaceId: Id<"workspaces">,91 workspaceId: Id<"workspaces">,
92 accountId: Id<"accounts">,92 accountId: Id<"accounts">,
93) {93) {
94 return await ctx.db94 const memberships = await ctx.db
95 .query("workspaceMembers")95 .query("workspaceMembers")
96 .withIndex("by_workspace_account", (q) => q.eq("workspaceId", workspaceId).eq("accountId", accountId))96 .withIndex("by_workspace_account", (q) => q.eq("workspaceId", workspaceId).eq("accountId", accountId))
97 .unique();97 .collect();
98
99 if (memberships.length === 0) {
100 return null;
101 }
102
103 memberships.sort((a, b) => {
104 const statusRankA = a.status === "active" ? 2 : a.status === "pending" ? 1 : 0;
105 const statusRankB = b.status === "active" ? 2 : b.status === "pending" ? 1 : 0;
106 return statusRankB - statusRankA || b.updatedAt - a.updatedAt;
107 });
108
109 return memberships[0]!;
98}110}
99 111
100export async function requireWorkspaceAccessForAccount(112export async function requireWorkspaceAccessForAccount(
@@ -108,32 +120,14 @@ export async function requireWorkspaceAccessForAccount( · 7 unmodified lines
108 }120 }
109 121
110 const workspaceMembership = await getWorkspaceMembership(ctx, workspace._id, account._id);122 const workspaceMembership = await getWorkspaceMembership(ctx, workspace._id, account._id);
111 if (workspaceMembership) {123 if (!workspaceMembership || workspaceMembership.status !== "active") {
112 if (workspaceMembership.status !== "active") {
113 throw new Error("You are not a member of this workspace");
114 }
115
116 return {
117 account,
118 workspace,
119 workspaceMembership,
120 };
121 }
122
123 const organizationMembership = await getOrganizationMembership(ctx, workspace.organizationId, account._id);
124 if (!organizationMembership || organizationMembership.status !== "active") {
125 throw new Error("You are not a member of this workspace");124 throw new Error("You are not a member of this workspace");
126 }125 }
127 126
128 const derivedWorkspaceMembership: WorkspaceAccessMembership = {
129 role: mapOrganizationRoleToWorkspaceRole(organizationMembership.role),
130 status: "active",
131 };
132
133 return {127 return {
134 account,128 account,
135 workspace,129 workspace,
136 workspaceMembership: derivedWorkspaceMembership,130 workspaceMembership,
137 };131 };
138}132}
139 133
executor/packages/core/src/runtimes/cloudflare/worker/loader-runtime.test.ts+9−0
@@ -3,6 +3,7 @@ import { dispatchCodeWithCloudflareWorkerLoader } from "./loader-runtime"; · 2 unmodified lines
3 3
4let fakeHostServer: ReturnType<typeof Bun.serve>;4let fakeHostServer: ReturnType<typeof Bun.serve>;
5let fakeCallbackServer: ReturnType<typeof Bun.serve>;5let fakeCallbackServer: ReturnType<typeof Bun.serve>;
6let previousConvexUrl: string | undefined;
6 7
7const AUTH_TOKEN = "test-sandbox-token";8const AUTH_TOKEN = "test-sandbox-token";
8const CALLBACK_TOKEN = "test-callback-token";9const CALLBACK_TOKEN = "test-callback-token";
@@ -19,6 +20,8 @@ type HostRequestBody = { · 10 unmodified lines
19let lastHostRequestBody: HostRequestBody | null = null;20let lastHostRequestBody: HostRequestBody | null = null;
20 21
21beforeAll(() => {22beforeAll(() => {
23 previousConvexUrl = process.env.CONVEX_URL;
24
22 fakeCallbackServer = Bun.serve({25 fakeCallbackServer = Bun.serve({
23 port: 0,26 port: 0,
24 fetch: () => Response.json({ ok: true }),27 fetch: () => Response.json({ ok: true }),
@@ -44,6 +47,7 @@ beforeAll(() => { · 19 unmodified lines
44 47
45 process.env.CLOUDFLARE_SANDBOX_RUN_URL = `http://127.0.0.1:${fakeHostServer.port}/v1/runs`;48 process.env.CLOUDFLARE_SANDBOX_RUN_URL = `http://127.0.0.1:${fakeHostServer.port}/v1/runs`;
46 process.env.CLOUDFLARE_SANDBOX_AUTH_TOKEN = AUTH_TOKEN;49 process.env.CLOUDFLARE_SANDBOX_AUTH_TOKEN = AUTH_TOKEN;
50 delete process.env.CONVEX_URL;
47 process.env.CONVEX_SITE_URL = `http://127.0.0.1:${fakeCallbackServer.port}`;51 process.env.CONVEX_SITE_URL = `http://127.0.0.1:${fakeCallbackServer.port}`;
48 process.env.EXECUTOR_INTERNAL_TOKEN = CALLBACK_TOKEN;52 process.env.EXECUTOR_INTERNAL_TOKEN = CALLBACK_TOKEN;
49 process.env.CLOUDFLARE_SANDBOX_REQUEST_TIMEOUT_MS = "10000";53 process.env.CLOUDFLARE_SANDBOX_REQUEST_TIMEOUT_MS = "10000";
@@ -54,6 +58,11 @@ afterAll(() => { · 4 unmodified lines
54 fakeCallbackServer?.stop(true);58 fakeCallbackServer?.stop(true);
55 delete process.env.CLOUDFLARE_SANDBOX_RUN_URL;59 delete process.env.CLOUDFLARE_SANDBOX_RUN_URL;
56 delete process.env.CLOUDFLARE_SANDBOX_AUTH_TOKEN;60 delete process.env.CLOUDFLARE_SANDBOX_AUTH_TOKEN;
61 if (previousConvexUrl === undefined) {
62 delete process.env.CONVEX_URL;
63 } else {
64 process.env.CONVEX_URL = previousConvexUrl;
65 }
57 delete process.env.CONVEX_SITE_URL;66 delete process.env.CONVEX_SITE_URL;
58 delete process.env.EXECUTOR_INTERNAL_TOKEN;67 delete process.env.EXECUTOR_INTERNAL_TOKEN;
59 delete process.env.CLOUDFLARE_SANDBOX_REQUEST_TIMEOUT_MS;68 delete process.env.CLOUDFLARE_SANDBOX_REQUEST_TIMEOUT_MS;
executor/packages/core/src/tool/source-serialization.ts+12−5
@@ -1,4 +1,3 @@
1import { Result } from "better-result";
2import { connectMcp, extractMcpResult } from "../mcp-runtime";1import { connectMcp, extractMcpResult } from "../mcp-runtime";
3import { executePostmanRequest, type PostmanSerializedRunSpec } from "../postman-runtime";2import { executePostmanRequest, type PostmanSerializedRunSpec } from "../postman-runtime";
4import { normalizeGraphqlFieldVariables, selectGraphqlFieldEnvelope } from "../graphql/field-tools";3import { normalizeGraphqlFieldVariables, selectGraphqlFieldEnvelope } from "../graphql/field-tools";
@@ -129,7 +128,11 @@ export function rehydrateTools( · 124 unmodified lines
129 return {128 return {
130 ...base,129 ...base,
131 run: async (input: unknown, context) => {130 run: async (input: unknown, context) => {
132 return await executeOpenApiRequest(runSpec, input, context.credential?.headers);131 const response = await executeOpenApiRequest(runSpec, input, context.credential?.headers);
132 if (response.isErr()) {
133 throw new Error(response.error.message);
134 }
135 return response.value;
133 },136 },
134 };137 };
135 }138 }
@@ -193,7 +196,11 @@ export function rehydrateTools( · 57 unmodified lines
193 throw new Error("GraphQL query string is required");196 throw new Error("GraphQL query string is required");
194 }197 }
195 const variables = payload.variables;198 const variables = payload.variables;
196 return await executeGraphqlRequest(endpoint, authHeaders, query, variables, context.credential?.headers);199 const response = await executeGraphqlRequest(endpoint, authHeaders, query, variables, context.credential?.headers);
200 if (response.isErr()) {
201 throw new Error(response.error.message);
202 }
203 return response.value;
197 },204 },
198 };205 };
199 }206 }
@@ -220,10 +227,10 @@ export function rehydrateTools( · 20 unmodified lines
220 context.credential?.headers,227 context.credential?.headers,
221 );228 );
222 if (envelopeResult.isErr()) {229 if (envelopeResult.isErr()) {
223 return envelopeResult;230 throw new Error(envelopeResult.error.message);
224 }231 }
225 232
226 return Result.ok(selectGraphqlFieldEnvelope(envelopeResult.value, operationName));233 return selectGraphqlFieldEnvelope(envelopeResult.value, operationName);
227 },234 },
228 };235 };
229 }236 }
executor/packages/database/convex/access-controls.test.ts+48−2
@@ -116,7 +116,7 @@ async function addOrgMember( · 115 unmodified lines
116) {116) {
117 const now = Date.now();117 const now = Date.now();
118 return await t.run(async (ctx) => {118 return await t.run(async (ctx) => {
119 return await ctx.db.insert("organizationMembers", {119 const membershipId = await ctx.db.insert("organizationMembers", {
120 organizationId: opts.organizationId,120 organizationId: opts.organizationId,
121 accountId: opts.accountId,121 accountId: opts.accountId,
122 role: opts.role,122 role: opts.role,
@@ -126,6 +126,38 @@ async function addOrgMember( · 3 unmodified lines
126 createdAt: now,126 createdAt: now,
127 updatedAt: now,127 updatedAt: now,
128 });128 });
129
130 const role = opts.role === "owner" || opts.role === "admin" ? opts.role : "member";
131 const workspaces = await ctx.db
132 .query("workspaces")
133 .withIndex("by_organization_created", (q) => q.eq("organizationId", opts.organizationId))
134 .collect();
135
136 for (const workspace of workspaces) {
137 const existing = await ctx.db
138 .query("workspaceMembers")
139 .withIndex("by_workspace_account", (q) => q.eq("workspaceId", workspace._id).eq("accountId", opts.accountId))
140 .first();
141
142 if (existing) {
143 await ctx.db.patch(existing._id, {
144 role,
145 status: opts.status ?? "active",
146 updatedAt: now,
147 });
148 } else {
149 await ctx.db.insert("workspaceMembers", {
150 workspaceId: workspace._id,
151 accountId: opts.accountId,
152 role,
153 status: opts.status ?? "active",
154 createdAt: now,
155 updatedAt: now,
156 });
157 }
158 }
159
160 return membershipId;
129 });161 });
130}162}
131 163
@@ -143,6 +175,20 @@ async function addWorkspaceMember( · 11 unmodified lines
143) {175) {
144 const now = Date.now();176 const now = Date.now();
145 return await t.run(async (ctx) => {177 return await t.run(async (ctx) => {
178 const existing = await ctx.db
179 .query("workspaceMembers")
180 .withIndex("by_workspace_account", (q) => q.eq("workspaceId", opts.workspaceId).eq("accountId", opts.accountId))
181 .first();
182
183 if (existing) {
184 await ctx.db.patch(existing._id, {
185 role: opts.role,
186 status: opts.status ?? "active",
187 updatedAt: now,
188 });
189 return existing._id;
190 }
191
146 return await ctx.db.insert("workspaceMembers", {192 return await ctx.db.insert("workspaceMembers", {
147 workspaceId: opts.workspaceId,193 workspaceId: opts.workspaceId,
148 accountId: opts.accountId,194 accountId: opts.accountId,
@@ -262,7 +308,7 @@ describe("workspace access controls", () => { · 113 unmodified lines
262 expect(tasks).toEqual([]);308 expect(tasks).toEqual([]);
263 });309 });
264 310
265 test("org member without explicit workspace membership can read tasks", async () => {311 test("org membership projection grants workspace read access", async () => {
266 const t = setup();312 const t = setup();
267 const owner = await seedUser(t, { subject: "ws-owner-org-fallback" });313 const owner = await seedUser(t, { subject: "ws-owner-org-fallback" });
268 const member = await seedUser(t, { subject: "ws-member-org-fallback" });314 const member = await seedUser(t, { subject: "ws-member-org-fallback" });
executor/packages/database/convex/database/anonymous_session.ts+1−1
@@ -115,7 +115,7 @@ export const ensureAnonymousMcpSession = internalMutation({ · 114 unmodified lines
115 const membership = await ctx.db115 const membership = await ctx.db
116 .query("workspaceMembers")116 .query("workspaceMembers")
117 .withIndex("by_workspace_account", (q) => q.eq("workspaceId", args.workspaceId).eq("accountId", account._id))117 .withIndex("by_workspace_account", (q) => q.eq("workspaceId", args.workspaceId).eq("accountId", account._id))
118 .unique();118 .first();
119 if (!membership || membership.status !== "active") {119 if (!membership || membership.status !== "active") {
120 throw new Error("Anonymous actor does not have workspace access");120 throw new Error("Anonymous actor does not have workspace access");
121 }121 }
executor/packages/database/convex/database/tool_sources.ts+2−2
@@ -5,8 +5,8 @@ import { · 4 unmodified lines
5 mapSource,5 mapSource,
6 normalizeSourceAuthFingerprint,6 normalizeSourceAuthFingerprint,
7} from "../../src/database/mappers";7} from "../../src/database/mappers";
8import { normalizeToolSourceConfig } from "../../src/database/tool_source_config";
8import { toolSourceTypeValidator } from "../../src/database/validators";9import { toolSourceTypeValidator } from "../../src/database/validators";
9import { asRecord } from "../../src/lib/object";
10 10
11export const upsertToolSource = internalMutation({11export const upsertToolSource = internalMutation({
12 args: {12 args: {
@@ -20,7 +20,7 @@ export const upsertToolSource = internalMutation({ · 7 unmodified lines
20 handler: async (ctx, args) => {20 handler: async (ctx, args) => {
21 const now = Date.now();21 const now = Date.now();
22 const sourceId = args.id ?? `src_${crypto.randomUUID()}`;22 const sourceId = args.id ?? `src_${crypto.randomUUID()}`;
23 const config = asRecord(args.config);23 const config = normalizeToolSourceConfig(args.type, args.config);
24 const specHash = computeSourceSpecHash(args.type, config);24 const specHash = computeSourceSpecHash(args.type, config);
25 const authFingerprint = normalizeSourceAuthFingerprint(config.auth);25 const authFingerprint = normalizeSourceAuthFingerprint(config.auth);
26 const [existing, conflict] = await Promise.all([26 const [existing, conflict] = await Promise.all([
executor/packages/database/convex/executor-mcp.e2e.test.ts+25−1
@@ -1,4 +1,4 @@
1import { expect, test } from "bun:test";1import { afterAll, beforeAll, expect, test } from "bun:test";
2import { convexTest } from "convex-test";2import { convexTest } from "convex-test";
3import { Client } from "@modelcontextprotocol/sdk/client/index.js";3import { Client } from "@modelcontextprotocol/sdk/client/index.js";
4import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";4import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
@@ -6,6 +6,30 @@ import { internal } from "./_generated/api"; · 1 unmodified line
6import type { Id } from "./_generated/dataModel.d.ts";6import type { Id } from "./_generated/dataModel.d.ts";
7import schema from "./schema";7import schema from "./schema";
8 8
9let previousAnonymousAuthPrivateKeyPem: string | undefined;
10let previousAnonymousAuthPublicKeyPem: string | undefined;
11
12beforeAll(() => {
13 previousAnonymousAuthPrivateKeyPem = process.env.ANONYMOUS_AUTH_PRIVATE_KEY_PEM;
14 previousAnonymousAuthPublicKeyPem = process.env.ANONYMOUS_AUTH_PUBLIC_KEY_PEM;
15 delete process.env.ANONYMOUS_AUTH_PRIVATE_KEY_PEM;
16 delete process.env.ANONYMOUS_AUTH_PUBLIC_KEY_PEM;
17});
18
19afterAll(() => {
20 if (previousAnonymousAuthPrivateKeyPem === undefined) {
21 delete process.env.ANONYMOUS_AUTH_PRIVATE_KEY_PEM;
22 } else {
23 process.env.ANONYMOUS_AUTH_PRIVATE_KEY_PEM = previousAnonymousAuthPrivateKeyPem;
24 }
25
26 if (previousAnonymousAuthPublicKeyPem === undefined) {
27 delete process.env.ANONYMOUS_AUTH_PUBLIC_KEY_PEM;
28 } else {
29 process.env.ANONYMOUS_AUTH_PUBLIC_KEY_PEM = previousAnonymousAuthPublicKeyPem;
30 }
31});
32
9function setup() {33function setup() {
10 return convexTest(schema, {34 return convexTest(schema, {
11 "./database.ts": () => import("./database"),35 "./database.ts": () => import("./database"),
executor/packages/database/convex/invites.ts+3−2
@@ -13,6 +13,7 @@ import { · 12 unmodified lines
13 workosEnabled,13 workosEnabled,
14} from "../src/invites/workos";14} from "../src/invites/workos";
15import { normalizePersonalOrganizationName } from "../src/invites/normalize";15import { normalizePersonalOrganizationName } from "../src/invites/normalize";
16import { safeRunAfter } from "../src/lib/scheduler";
16 17
17const organizationRoleValidator = v.union(18const organizationRoleValidator = v.union(
18 v.literal("owner"),19 v.literal("owner"),
@@ -99,7 +100,7 @@ export const create = organizationMutation({ · 80 unmodified lines
99 updatedAt: now,100 updatedAt: now,
100 });101 });
101 102
102 await ctx.scheduler.runAfter(0, internal.invites.deliverWorkosInvite, {103 await safeRunAfter(ctx.scheduler, 0, internal.invites.deliverWorkosInvite, {
103 inviteId,104 inviteId,
104 inviterWorkosUserId,105 inviterWorkosUserId,
105 expiresInDays: args.expiresInDays,106 expiresInDays: args.expiresInDays,
@@ -216,7 +217,7 @@ export const revoke = organizationMutation({ · 110 unmodified lines
216 });217 });
217 218
218 if (invite.providerInviteId) {219 if (invite.providerInviteId) {
219 await ctx.scheduler.runAfter(0, internal.invites.revokeWorkosInvite, {220 await safeRunAfter(ctx.scheduler, 0, internal.invites.revokeWorkosInvite, {
220 inviteId: invite._id as Id<"invites">,221 inviteId: invite._id as Id<"invites">,
221 providerInviteId: String(invite.providerInviteId),222 providerInviteId: String(invite.providerInviteId),
222 });223 });
executor/packages/database/convex/organizationMembers.ts+20−6
@@ -2,6 +2,8 @@ import { v } from "convex/values"; · 1 unmodified line
2import { internal } from "./_generated/api";2import { internal } from "./_generated/api";
3import { getOrganizationMembership } from "../../core/src/identity";3import { getOrganizationMembership } from "../../core/src/identity";
4import { organizationMutation, organizationQuery } from "../../core/src/function-builders";4import { organizationMutation, organizationQuery } from "../../core/src/function-builders";
5import { upsertOrganizationMembership } from "../src/auth/memberships";
6import { safeRunAfter } from "../src/lib/scheduler";
5 7
6const organizationRoleValidator = v.union(8const organizationRoleValidator = v.union(
7 v.literal("owner"),9 v.literal("owner"),
@@ -52,9 +54,15 @@ export const updateRole = organizationMutation({ · 44 unmodified lines
52 throw new Error("Organization member not found");54 throw new Error("Organization member not found");
53 }55 }
54 56
55 await ctx.db.patch(membership._id, {57 await upsertOrganizationMembership(ctx, {
58 organizationId: ctx.organizationId,
59 accountId: args.accountId,
56 role: args.role,60 role: args.role,
57 updatedAt: Date.now(),61 status: membership.status,
62 billable: membership.billable,
63 invitedByAccountId: membership.invitedByAccountId,
64 workosOrgMembershipId: membership.workosOrgMembershipId,
65 now: Date.now(),
58 });66 });
59 67
60 return { ok: true };68 return { ok: true };
@@ -81,7 +89,7 @@ export const updateBillable = organizationMutation({ · 20 unmodified lines
81 const nextVersion = await ctx.runMutation(internal.billingInternal.bumpSeatSyncVersion, {89 const nextVersion = await ctx.runMutation(internal.billingInternal.bumpSeatSyncVersion, {
82 organizationId: ctx.organizationId,90 organizationId: ctx.organizationId,
83 });91 });
84 await ctx.scheduler.runAfter(0, internal.billingSync.syncSeatQuantity, {92 await safeRunAfter(ctx.scheduler, 0, internal.billingSync.syncSeatQuantity, {
85 organizationId: ctx.organizationId,93 organizationId: ctx.organizationId,
86 expectedVersion: nextVersion,94 expectedVersion: nextVersion,
87 });95 });
@@ -101,15 +109,21 @@ export const remove = organizationMutation({ · 13 unmodified lines
101 throw new Error("Organization member not found");109 throw new Error("Organization member not found");
102 }110 }
103 111
104 await ctx.db.patch(membership._id, {112 await upsertOrganizationMembership(ctx, {
113 organizationId: ctx.organizationId,
114 accountId: args.accountId,
115 role: membership.role,
105 status: "removed",116 status: "removed",
106 updatedAt: Date.now(),117 billable: false,
118 invitedByAccountId: membership.invitedByAccountId,
119 workosOrgMembershipId: membership.workosOrgMembershipId,
120 now: Date.now(),
107 });121 });
108 122
109 const nextVersion = await ctx.runMutation(internal.billingInternal.bumpSeatSyncVersion, {123 const nextVersion = await ctx.runMutation(internal.billingInternal.bumpSeatSyncVersion, {
110 organizationId: ctx.organizationId,124 organizationId: ctx.organizationId,
111 });125 });
112 await ctx.scheduler.runAfter(0, internal.billingSync.syncSeatQuantity, {126 await safeRunAfter(ctx.scheduler, 0, internal.billingSync.syncSeatQuantity, {
113 organizationId: ctx.organizationId,127 organizationId: ctx.organizationId,
114 expectedVersion: nextVersion,128 expectedVersion: nextVersion,
115 });129 });
executor/packages/database/convex/schema.ts+4−12
@@ -194,21 +194,18 @@ export default defineSchema({ · 193 unmodified lines
194 //194 //
195 // Primary access patterns:195 // Primary access patterns:
196 // - Resolve by organization.196 // - Resolve by organization.
197 // - (Potential/expected) resolve by Stripe customer id in webhook reconciliation.
198 billingCustomers: defineTable({197 billingCustomers: defineTable({
199 organizationId: v.id("organizations"),198 organizationId: v.id("organizations"),
200 stripeCustomerId: v.string(), // external Stripe customer ID199 stripeCustomerId: v.string(), // external Stripe customer ID
201 createdAt: v.number(),200 createdAt: v.number(),
202 updatedAt: v.number(),201 updatedAt: v.number(),
203 })202 })
204 .index("by_org", ["organizationId"])203 .index("by_org", ["organizationId"]),
205 .index("by_stripe_customer_id", ["stripeCustomerId"]),
206 204
207 // Stripe subscription state for an organization.205 // Stripe subscription state for an organization.
208 //206 //
209 // Primary access patterns:207 // Primary access patterns:
210 // - List subscriptions for an org.208 // - List subscriptions for an org.
211 // - (Potential/expected) resolve by subscription id and filter by status.
212 billingSubscriptions: defineTable({209 billingSubscriptions: defineTable({
213 organizationId: v.id("organizations"),210 organizationId: v.id("organizations"),
214 stripeSubscriptionId: v.string(), // external Stripe subscription ID211 stripeSubscriptionId: v.string(), // external Stripe subscription ID
@@ -221,9 +218,7 @@ export default defineSchema({ · 6 unmodified lines
221 createdAt: v.number(),218 createdAt: v.number(),
222 updatedAt: v.number(),219 updatedAt: v.number(),
223 })220 })
224 .index("by_org", ["organizationId"])221 .index("by_org", ["organizationId"]),
225 .index("by_org_status", ["organizationId", "status"])
226 .index("by_stripe_subscription_id", ["stripeSubscriptionId"]),
227 222
228 // Seat syncing bookkeeping (eg Stripe per-seat quantity).223 // Seat syncing bookkeeping (eg Stripe per-seat quantity).
229 // Stored separately from subscription records so sync logic can be retried/idempotent.224 // Stored separately from subscription records so sync logic can be retried/idempotent.
@@ -294,7 +289,6 @@ export default defineSchema({ · 64 unmodified lines
294 // Primary access patterns:289 // Primary access patterns:
295 // - Get a specific call by (taskId, callId).290 // - Get a specific call by (taskId, callId).
296 // - List calls for a task ordered by creation time.291 // - List calls for a task ordered by creation time.
297 // - (Potential/expected) resolve via approval id to tie approvals to tool call rows.
298 toolCalls: defineTable({292 toolCalls: defineTable({
299 taskId: v.string(),293 taskId: v.string(),
300 callId: v.string(),294 callId: v.string(),
@@ -309,8 +303,7 @@ export default defineSchema({ · 8 unmodified lines
309 })303 })
310 .index("by_task_call", ["taskId", "callId"])304 .index("by_task_call", ["taskId", "callId"])
311 .index("by_task_created", ["taskId", "createdAt"])305 .index("by_task_created", ["taskId", "createdAt"])
312 .index("by_workspace_created", ["workspaceId", "createdAt"])306 .index("by_workspace_created", ["workspaceId", "createdAt"]),
313 .index("by_approval_id", ["approvalId"]),
314 307
315 // Append-only event log for a task.308 // Append-only event log for a task.
316 // `sequence` is monotonically increasing per task (used for ordered replay).309 // `sequence` is monotonically increasing per task (used for ordered replay).
@@ -482,8 +475,7 @@ export default defineSchema({ · 165 unmodified lines
482 samplePaths: v.array(v.string()),475 samplePaths: v.array(v.string()),
483 createdAt: v.number(),476 createdAt: v.number(),
484 })477 })
485 .index("by_workspace_build", ["workspaceId", "buildId"])478 .index("by_workspace_build", ["workspaceId", "buildId"]),
486 .index("by_workspace_build_namespace", ["workspaceId", "buildId", "namespace"]),
487 479
488 // Anonymous session linkage.480 // Anonymous session linkage.
489 // Used to map an unauthenticated/anonymous actor to a backing `accounts` row and a481 // Used to map an unauthenticated/anonymous actor to a backing `accounts` row and a
executor/packages/database/src/auth/actor_identity.ts+31−0
@@ -0,0 +1,31 @@
1import { actorIdForAccount } from "../../../core/src/identity";
2
3type WorkspaceAccessIdentity = {
4 actorId?: string;
5 accountId: string;
6 provider: string;
7 providerAccountId: string;
8};
9
10export function canonicalActorIdForWorkspaceAccess(access: WorkspaceAccessIdentity): string {
11 const existingActorId = typeof access.actorId === "string" ? access.actorId.trim() : "";
12 if (existingActorId.length > 0) {
13 return existingActorId;
14 }
15
16 return actorIdForAccount({
17 _id: access.accountId,
18 provider: access.provider,
19 providerAccountId: access.providerAccountId,
20 });
21}
22
23export function assertMatchesCanonicalActorId(
24 providedActorId: string | undefined,
25 canonicalActorId: string,
26 fieldName = "actorId",
27): void {
28 if (providedActorId && providedActorId !== canonicalActorId) {
29 throw new Error(`${fieldName} must match the authenticated workspace actor`);
30 }
31}
executor/packages/database/src/auth/bootstrap.ts+20−31
@@ -3,12 +3,12 @@ import { upsertWorkosAccount } from "./accounts"; · 2 unmodified lines
3import { getOrganizationByWorkosOrgId } from "./db_queries";3import { getOrganizationByWorkosOrgId } from "./db_queries";
4import { getAuthKitUserProfile, resolveIdentityProfile } from "./identity";4import { getAuthKitUserProfile, resolveIdentityProfile } from "./identity";
5import {5import {
6 ensureWorkspaceMembership,
7 mapOrganizationRoleToWorkspaceRole,6 mapOrganizationRoleToWorkspaceRole,
8 markPendingInvitesAcceptedByEmail,7 markPendingInvitesAcceptedByEmail,
9 upsertOrganizationMembership,8 upsertOrganizationMembership,
10} from "./memberships";9} from "./memberships";
11import { ensurePersonalWorkspace, refreshGeneratedPersonalWorkspaceNames } from "./personal_workspace";10import { ensurePersonalWorkspace, refreshGeneratedPersonalWorkspaceNames } from "./personal_workspace";
11import { projectAccountOrganizationMembershipsToWorkspaceMembers } from "./workspace_membership_projection";
12import type { AccountId } from "./types";12import type { AccountId } from "./types";
13 13
14async function seedHintedOrganizationMembership(14async function seedHintedOrganizationMembership(
@@ -51,31 +51,6 @@ async function seedHintedOrganizationMembership( · 36 unmodified lines
51 });51 });
52}52}
53 53
54async function syncWorkspaceMembershipsForOrganizations(ctx: MutationCtx, args: { accountId: AccountId; now: number }) {
55 const activeOrganizationMemberships = await ctx.db
56 .query("organizationMembers")
57 .withIndex("by_account", (q) => q.eq("accountId", args.accountId))
58 .filter((q) => q.eq(q.field("status"), "active"))
59 .collect();
60
61 for (const membership of activeOrganizationMemberships) {
62 const orgWorkspaces = await ctx.db
63 .query("workspaces")
64 .withIndex("by_organization_created", (q) => q.eq("organizationId", membership.organizationId))
65 .collect();
66
67 const workspaceRole = mapOrganizationRoleToWorkspaceRole(membership.role);
68 for (const workspace of orgWorkspaces) {
69 await ensureWorkspaceMembership(ctx, {
70 workspaceId: workspace._id,
71 accountId: args.accountId,
72 role: workspaceRole,
73 now: args.now,
74 });
75 }
76 }
77}
78
79async function ensureAtLeastOneWorkspaceMembership(ctx: MutationCtx, args: { accountId: AccountId }) {54async function ensureAtLeastOneWorkspaceMembership(ctx: MutationCtx, args: { accountId: AccountId }) {
80 const activeWorkspaceMembership = await ctx.db55 const activeWorkspaceMembership = await ctx.db
81 .query("workspaceMembers")56 .query("workspaceMembers")
@@ -86,6 +61,18 @@ async function ensureAtLeastOneWorkspaceMembership(ctx: MutationCtx, args: { acc · 4 unmodified lines
86 return Boolean(activeWorkspaceMembership);61 return Boolean(activeWorkspaceMembership);
87}62}
88 63
64async function backfillWorkspaceMembershipsIfMissing(ctx: MutationCtx, args: { accountId: AccountId; now: number }) {
65 await projectAccountOrganizationMembershipsToWorkspaceMembers(ctx, {
66 accountId: args.accountId,
67 now: args.now,
68 mapRole: mapOrganizationRoleToWorkspaceRole,
69 });
70
71 return await ensureAtLeastOneWorkspaceMembership(ctx, {
72 accountId: args.accountId,
73 });
74}
75
89export async function bootstrapCurrentWorkosAccountImpl(ctx: MutationCtx) {76export async function bootstrapCurrentWorkosAccountImpl(ctx: MutationCtx) {
90 const identity = await ctx.auth.getUserIdentity();77 const identity = await ctx.auth.getUserIdentity();
91 if (!identity) return null;78 if (!identity) return null;
@@ -125,14 +112,16 @@ export async function bootstrapCurrentWorkosAccountImpl(ctx: MutationCtx) { · 33 unmodified lines
125 now,112 now,
126 });113 });
127 114
128 await syncWorkspaceMembershipsForOrganizations(ctx, {115 let hasActiveWorkspaceMembership = await ensureAtLeastOneWorkspaceMembership(ctx, {
129 accountId: account._id,116 accountId: account._id,
130 now,
131 });117 });
132 118
133 const hasActiveWorkspaceMembership = await ensureAtLeastOneWorkspaceMembership(ctx, {119 if (!hasActiveWorkspaceMembership) {
134 accountId: account._id,120 hasActiveWorkspaceMembership = await backfillWorkspaceMembershipsIfMissing(ctx, {
135 });121 accountId: account._id,
122 now,
123 });
124 }
136 125
137 if (!hasActiveWorkspaceMembership) {126 if (!hasActiveWorkspaceMembership) {
138 await ensurePersonalWorkspace(ctx, account._id, {127 await ensurePersonalWorkspace(ctx, account._id, {
executor/packages/database/src/auth/event_handlers.ts+100−101
@@ -10,11 +10,12 @@ import { · 9 unmodified lines
10} from "./db_queries";10} from "./db_queries";
11import {11import {
12 deriveOrganizationMembershipState,12 deriveOrganizationMembershipState,
13 deriveWorkspaceMembershipRole,13 mapOrganizationRoleToWorkspaceRole,
14 markPendingInvitesAcceptedByEmail,14 markPendingInvitesAcceptedByEmail,
15 upsertOrganizationMembership,15 upsertOrganizationMembership,
16} from "./memberships";16} from "./memberships";
17import { ensureUniqueOrganizationSlug } from "./naming";17import { ensureUniqueOrganizationSlug } from "./naming";
18import { seedWorkspaceMembersFromOrganization } from "./workspace_membership_projection";
18 19
19type WorkosMembershipEventData = {20type WorkosMembershipEventData = {
20 id: string;21 id: string;
@@ -26,53 +27,63 @@ type WorkosMembershipEventData = { · 5 unmodified lines
26 status?: string;27 status?: string;
27};28};
28 29
29async function resolveMembershipRecord(30function deriveOrganizationRoleFromWorkosSlug(workosRoleSlug?: string): Doc<"organizationMembers">["role"] {
31 return workosRoleSlug === "admin" ? "admin" : "member";
32}
33
34async function resolveMembershipAccountAndOrganization(
30 ctx: Pick<MutationCtx, "db">,35 ctx: Pick<MutationCtx, "db">,
31 data: WorkosMembershipEventData,36 data: WorkosMembershipEventData,
32) {37) {
33 let membership = await ctx.db38 const workosUserId = data.user_id ?? data.userId;
34 .query("workspaceMembers")39 const workosOrgId = data.organization_id ?? data.organizationId;
35 .withIndex("by_workos_membership_id", (q) => q.eq("workosOrgMembershipId", data.id))
36 .unique();
37
38 let account: Doc<"accounts"> | null = null;
39 let workspace: Doc<"workspaces"> | null = null;
40 40
41 if (!membership) {41 if (workosUserId && workosOrgId) {
42 const workosUserId = data.user_id ?? data.userId;42 const [account, organization] = await Promise.all([
43 const workosOrgId = data.organization_id ?? data.organizationId;
44 if (!workosUserId || !workosOrgId) {
45 return { membership: null, account: null, workspace: null };
46 }
47
48 [account, workspace] = await Promise.all([
49 getAccountByWorkosId(ctx, workosUserId),43 getAccountByWorkosId(ctx, workosUserId),
50 getWorkspaceByWorkosOrgId(ctx, workosOrgId),44 getOrganizationByWorkosOrgId(ctx, workosOrgId),
51 ]);45 ]);
52 if (!account || !workspace) {
53 return { membership: null, account: null, workspace: null };
54 }
55
56 const workspaceId = workspace._id;
57 const accountId = account._id;
58 46
59 membership = await ctx.db47 if (account && organization) {
60 .query("workspaceMembers")48 return {
61 .withIndex("by_workspace_account", (q) => q.eq("workspaceId", workspaceId).eq("accountId", accountId))49 account,
62 .unique();50 organization,
63 if (!membership) {51 fallbackRole: undefined as Doc<"workspaceMembers">["role"] | undefined,
64 return { membership: null, account: null, workspace: null };52 };
65 }53 }
66 } else {
67 account = await ctx.db.get(membership.accountId);
68 workspace = await ctx.db.get(membership.workspaceId);
69 }54 }
70 55
56 const linkedMemberships = await ctx.db
57 .query("workspaceMembers")
58 .withIndex("by_workos_membership_id", (q) => q.eq("workosOrgMembershipId", data.id))
59 .collect();
60 if (linkedMemberships.length === 0) {
61 return {
62 account: null,
63 organization: null,
64 fallbackRole: undefined as Doc<"workspaceMembers">["role"] | undefined,
65 };
66 }
67
68 const linkedMembership = linkedMemberships[0]!;
69 const [account, workspace] = await Promise.all([
70 ctx.db.get(linkedMembership.accountId),
71 ctx.db.get(linkedMembership.workspaceId),
72 ]);
71 if (!account || !workspace) {73 if (!account || !workspace) {
72 return { membership: null, account: null, workspace: null };74 return {
75 account: null,
76 organization: null,
77 fallbackRole: linkedMembership.role,
78 };
73 }79 }
74 80
75 return { membership, account, workspace };81 const organization = await ctx.db.get(workspace.organizationId);
82 return {
83 account,
84 organization,
85 fallbackRole: linkedMembership.role,
86 };
76}87}
77 88
78export const workosEventHandlers = {89export const workosEventHandlers = {
@@ -158,6 +169,13 @@ export const workosEventHandlers = { · 79 unmodified lines
158 workosOrgId: event.data.id,169 workosOrgId: event.data.id,
159 updatedAt: now,170 updatedAt: now,
160 });171 });
172
173 await seedWorkspaceMembersFromOrganization(ctx, {
174 organizationId: organization._id,
175 workspaceId: existingWorkspace._id,
176 now,
177 mapRole: mapOrganizationRoleToWorkspaceRole,
178 });
161 return;179 return;
162 }180 }
163 181
@@ -167,6 +185,13 @@ export const workosEventHandlers = { · 3 unmodified lines
167 workosOrgId: event.data.id,185 workosOrgId: event.data.id,
168 updatedAt: now,186 updatedAt: now,
169 });187 });
188
189 await seedWorkspaceMembersFromOrganization(ctx, {
190 organizationId: organization._id,
191 workspaceId: organizationWorkspace._id,
192 now,
193 mapRole: mapOrganizationRoleToWorkspaceRole,
194 });
170 return;195 return;
171 }196 }
172 197
@@ -179,6 +204,16 @@ export const workosEventHandlers = { · 6 unmodified lines
179 createdAt: now,204 createdAt: now,
180 updatedAt: now,205 updatedAt: now,
181 });206 });
207
208 const createdWorkspace = await getWorkspaceByWorkosOrgId(ctx, event.data.id);
209 if (createdWorkspace) {
210 await seedWorkspaceMembersFromOrganization(ctx, {
211 organizationId: organization._id,
212 workspaceId: createdWorkspace._id,
213 now,
214 mapRole: mapOrganizationRoleToWorkspaceRole,
215 });
216 }
182 },217 },
183 218
184 "organization.updated": async (ctx, event) => {219 "organization.updated": async (ctx, event) => {
@@ -217,119 +252,83 @@ export const workosEventHandlers = { · 32 unmodified lines
217 "organization_membership.created": async (ctx, event) => {252 "organization_membership.created": async (ctx, event) => {
218 const now = Date.now();253 const now = Date.now();
219 const data = event.data as WorkosMembershipEventData;254 const data = event.data as WorkosMembershipEventData;
220 const workosUserId = data.user_id ?? data.userId;255 const { account, organization } = await resolveMembershipAccountAndOrganization(ctx, data);
221 const workosOrgId = data.organization_id ?? data.organizationId;256 if (!account || !organization) return;
222 if (!workosUserId || !workosOrgId) return;
223
224 const [account, workspace] = await Promise.all([
225 getAccountByWorkosId(ctx, workosUserId),
226 getWorkspaceByWorkosOrgId(ctx, workosOrgId),
227 ]);
228 if (!account || !workspace) return;
229 257
230 const existing = await ctx.db258 const role = deriveOrganizationRoleFromWorkosSlug(data.role?.slug);
231 .query("workspaceMembers")
232 .withIndex("by_workspace_account", (q) => q.eq("workspaceId", workspace._id).eq("accountId", account._id))
233 .unique();
234
235 const role = deriveWorkspaceMembershipRole(data.role?.slug);
236 const status = deriveOrganizationMembershipState(data.status);259 const status = deriveOrganizationMembershipState(data.status);
237 260
238 await upsertOrganizationMembership(ctx, {261 await upsertOrganizationMembership(ctx, {
239 organizationId: workspace.organizationId,262 organizationId: organization._id,
240 accountId: account._id,263 accountId: account._id,
241 role,264 role,
242 status,265 status,
243 billable: status === "active",266 billable: status === "active",
267 workosOrgMembershipId: data.id,
244 now,268 now,
245 });269 });
246 270
247 if (status === "active") {271 if (status === "active") {
248 await markPendingInvitesAcceptedByEmail(ctx, {272 await markPendingInvitesAcceptedByEmail(ctx, {
249 organizationId: workspace.organizationId,273 organizationId: organization._id,
250 email: account.email,274 email: account.email,
251 acceptedAt: now,275 acceptedAt: now,
252 });276 });
253 }277 }
254
255 if (existing) {
256 await ctx.db.patch(existing._id, {
257 workosOrgMembershipId: event.data.id,
258 role,
259 status,
260 updatedAt: now,
261 });
262 return;
263 }
264
265 await ctx.db.insert("workspaceMembers", {
266 workspaceId: workspace._id,
267 accountId: account._id,
268 workosOrgMembershipId: event.data.id,
269 role,
270 status,
271 createdAt: now,
272 updatedAt: now,
273 });
274 },278 },
275 279
276 "organization_membership.updated": async (ctx, event) => {280 "organization_membership.updated": async (ctx, event) => {
277 const now = Date.now();281 const now = Date.now();
278 const data = event.data as WorkosMembershipEventData;282 const data = event.data as WorkosMembershipEventData;
279 const { membership, account, workspace } = await resolveMembershipRecord(ctx, data);283 const { account, organization } = await resolveMembershipAccountAndOrganization(ctx, data);
280 284
281 if (!membership || !account || !workspace) {285 if (!account || !organization) {
282 return;286 return;
283 }287 }
284 288
285 const role = deriveWorkspaceMembershipRole(data.role?.slug);289 const role = deriveOrganizationRoleFromWorkosSlug(data.role?.slug);
286 const status = deriveOrganizationMembershipState(data.status);290 const status = deriveOrganizationMembershipState(data.status);
287 291
288 await upsertOrganizationMembership(ctx, {292 await upsertOrganizationMembership(ctx, {
289 organizationId: workspace.organizationId,293 organizationId: organization._id,
290 accountId: account._id,294 accountId: account._id,
291 role,295 role,
292 status,296 status,
293 billable: status === "active",297 billable: status === "active",
298 workosOrgMembershipId: data.id,
294 now,299 now,
295 });300 });
296 301
297 if (status === "active") {302 if (status === "active") {
298 await markPendingInvitesAcceptedByEmail(ctx, {303 await markPendingInvitesAcceptedByEmail(ctx, {
299 organizationId: workspace.organizationId,304 organizationId: organization._id,
300 email: account.email,305 email: account.email,
301 acceptedAt: now,306 acceptedAt: now,
302 });307 });
303 }308 }
304
305 await ctx.db.patch(membership._id, {
306 workosOrgMembershipId: data.id,
307 role,
308 status,
309 updatedAt: now,
310 });
311 },309 },
312 310
313 "organization_membership.deleted": async (ctx, event) => {311 "organization_membership.deleted": async (ctx, event) => {
314 const now = Date.now();312 const now = Date.now();
315 const membership = await ctx.db313 const data = event.data as WorkosMembershipEventData;
316 .query("workspaceMembers")314 const { account, organization, fallbackRole } = await resolveMembershipAccountAndOrganization(ctx, data);
317 .withIndex("by_workos_membership_id", (q) => q.eq("workosOrgMembershipId", event.data.id))315 if (!account || !organization) {
318 .unique();316 return;
319 if (!membership) return;
320
321 const workspace = await ctx.db.get(membership.workspaceId);
322 if (workspace?.organizationId) {
323 await upsertOrganizationMembership(ctx, {
324 organizationId: workspace.organizationId,
325 accountId: membership.accountId,
326 role: membership.role,
327 status: "removed",
328 billable: false,
329 now,
330 });
331 }317 }
332 318
333 await ctx.db.delete(membership._id);319 const existingOrgMembership = await ctx.db
320 .query("organizationMembers")
321 .withIndex("by_org_account", (q) => q.eq("organizationId", organization._id).eq("accountId", account._id))
322 .first();
323
324 await upsertOrganizationMembership(ctx, {
325 organizationId: organization._id,
326 accountId: account._id,
327 role: existingOrgMembership?.role ?? (fallbackRole === "admin" ? "admin" : "member"),
328 status: "removed",
329 billable: false,
330 workosOrgMembershipId: data.id,
331 now,
332 });
334 },333 },
335} satisfies Partial<Parameters<AuthKit<DataModel>["events"]>[0]>;334} satisfies Partial<Parameters<AuthKit<DataModel>["events"]>[0]>;
executor/packages/database/src/auth/memberships.ts+21−49
@@ -1,13 +1,12 @@
1import type { Doc } from "../../convex/_generated/dataModel.d.ts";
2import type {1import type {
3 AccountId,2 AccountId,
4 DbCtx,3 DbCtx,
5 OrganizationId,4 OrganizationId,
6 OrganizationMemberStatus,5 OrganizationMemberStatus,
7 OrganizationRole,6 OrganizationRole,
8 WorkspaceId,
9 WorkspaceMemberRole,7 WorkspaceMemberRole,
10} from "./types";8} from "./types";
9import { projectOrganizationMembershipToWorkspaceMembers } from "./workspace_membership_projection";
11 10
12export function mapOrganizationRoleToWorkspaceRole(role: OrganizationRole): WorkspaceMemberRole {11export function mapOrganizationRoleToWorkspaceRole(role: OrganizationRole): WorkspaceMemberRole {
13 if (role === "owner") {12 if (role === "owner") {
@@ -28,13 +27,14 @@ export async function upsertOrganizationMembership( · 14 unmodified lines
28 status: OrganizationMemberStatus;27 status: OrganizationMemberStatus;
29 billable: boolean;28 billable: boolean;
30 invitedByAccountId?: AccountId;29 invitedByAccountId?: AccountId;
30 workosOrgMembershipId?: string;
31 now: number;31 now: number;
32 },32 },
33) {33) {
34 const existing = await ctx.db34 const existing = await ctx.db
35 .query("organizationMembers")35 .query("organizationMembers")
36 .withIndex("by_org_account", (q) => q.eq("organizationId", args.organizationId).eq("accountId", args.accountId))36 .withIndex("by_org_account", (q) => q.eq("organizationId", args.organizationId).eq("accountId", args.accountId))
37 .unique();37 .first();
38 38
39 if (existing) {39 if (existing) {
40 await ctx.db.patch(existing._id, {40 await ctx.db.patch(existing._id, {
@@ -42,56 +42,32 @@ export async function upsertOrganizationMembership( · 1 unmodified line
42 status: args.status,42 status: args.status,
43 billable: args.billable,43 billable: args.billable,
44 invitedByAccountId: args.invitedByAccountId,44 invitedByAccountId: args.invitedByAccountId,
45 workosOrgMembershipId: args.workosOrgMembershipId,
45 joinedAt: args.status === "active" ? (existing.joinedAt ?? args.now) : existing.joinedAt,46 joinedAt: args.status === "active" ? (existing.joinedAt ?? args.now) : existing.joinedAt,
46 updatedAt: args.now,47 updatedAt: args.now,
47 });48 });
48 return;49 } else {
50 await ctx.db.insert("organizationMembers", {
51 organizationId: args.organizationId,
52 accountId: args.accountId,
53 role: args.role,
54 status: args.status,
55 billable: args.billable,
56 invitedByAccountId: args.invitedByAccountId,
57 workosOrgMembershipId: args.workosOrgMembershipId,
58 joinedAt: args.status === "active" ? args.now : undefined,
59 createdAt: args.now,
60 updatedAt: args.now,
61 });
49 }62 }
50 63
51 await ctx.db.insert("organizationMembers", {64 await projectOrganizationMembershipToWorkspaceMembers(ctx, {
52 organizationId: args.organizationId,65 organizationId: args.organizationId,
53 accountId: args.accountId,66 accountId: args.accountId,
54 role: args.role,67 role: mapOrganizationRoleToWorkspaceRole(args.role),
55 status: args.status,68 status: args.status,
56 billable: args.billable,69 now: args.now,
57 invitedByAccountId: args.invitedByAccountId,70 workosOrgMembershipId: args.workosOrgMembershipId,
58 joinedAt: args.status === "active" ? args.now : undefined,
59 createdAt: args.now,
60 updatedAt: args.now,
61 });
62}
63
64export async function ensureWorkspaceMembership(
65 ctx: DbCtx,
66 args: {
67 workspaceId: WorkspaceId;
68 accountId: AccountId;
69 role: WorkspaceMemberRole;
70 now: number;
71 },
72) {
73 const existing = await ctx.db
74 .query("workspaceMembers")
75 .withIndex("by_workspace_account", (q) => q.eq("workspaceId", args.workspaceId).eq("accountId", args.accountId))
76 .unique();
77
78 if (existing) {
79 if (existing.status === "active") {
80 await ctx.db.patch(existing._id, {
81 role: args.role,
82 updatedAt: args.now,
83 });
84 }
85 return;
86 }
87
88 await ctx.db.insert("workspaceMembers", {
89 workspaceId: args.workspaceId,
90 accountId: args.accountId,
91 role: args.role,
92 status: "active",
93 createdAt: args.now,
94 updatedAt: args.now,
95 });71 });
96}72}
97 73
@@ -124,10 +100,6 @@ export async function markPendingInvitesAcceptedByEmail( · 26 unmodified lines
124 }100 }
125}101}
126 102
127export function deriveWorkspaceMembershipRole(workosRoleSlug?: string): Doc<"workspaceMembers">["role"] {
128 return workosRoleSlug === "admin" ? "admin" : "member";
129}
130
131export function deriveOrganizationMembershipState(status?: string): OrganizationMemberStatus {103export function deriveOrganizationMembershipState(status?: string): OrganizationMemberStatus {
132 return status === "active" ? "active" : "pending";104 return status === "active" ? "active" : "pending";
133}105}
executor/packages/database/src/auth/workspace_membership_projection.ts+126−0
@@ -0,0 +1,126 @@
1import type { Doc } from "../../convex/_generated/dataModel.d.ts";
2import type { AccountId, DbCtx, OrganizationId, WorkspaceMemberRole } from "./types";
3
4type WorkspaceMemberStatus = Doc<"workspaceMembers">["status"];
5
6type ProjectOrganizationMembershipArgs = {
7 organizationId: OrganizationId;
8 accountId: AccountId;
9 role: WorkspaceMemberRole;
10 status: WorkspaceMemberStatus;
11 now: number;
12 workosOrgMembershipId?: string;
13};
14
15async function upsertWorkspaceMembership(
16 ctx: DbCtx,
17 args: {
18 workspaceId: Doc<"workspaces">["_id"];
19 accountId: AccountId;
20 role: WorkspaceMemberRole;
21 status: WorkspaceMemberStatus;
22 now: number;
23 workosOrgMembershipId?: string;
24 },
25): Promise<void> {
26 const existing = await ctx.db
27 .query("workspaceMembers")
28 .withIndex("by_workspace_account", (q) => q.eq("workspaceId", args.workspaceId).eq("accountId", args.accountId))
29 .first();
30
31 if (existing) {
32 await ctx.db.patch(existing._id, {
33 role: args.role,
34 status: args.status,
35 workosOrgMembershipId: args.workosOrgMembershipId,
36 updatedAt: args.now,
37 });
38 return;
39 }
40
41 await ctx.db.insert("workspaceMembers", {
42 workspaceId: args.workspaceId,
43 accountId: args.accountId,
44 role: args.role,
45 status: args.status,
46 workosOrgMembershipId: args.workosOrgMembershipId,
47 createdAt: args.now,
48 updatedAt: args.now,
49 });
50}
51
52export async function projectOrganizationMembershipToWorkspaceMembers(
53 ctx: DbCtx,
54 args: ProjectOrganizationMembershipArgs,
55): Promise<void> {
56 const workspaces = await ctx.db
57 .query("workspaces")
58 .withIndex("by_organization_created", (q) => q.eq("organizationId", args.organizationId))
59 .collect();
60
61 for (const workspace of workspaces) {
62 await upsertWorkspaceMembership(ctx, {
63 workspaceId: workspace._id,
64 accountId: args.accountId,
65 role: args.role,
66 status: args.status,
67 now: args.now,
68 workosOrgMembershipId: args.workosOrgMembershipId,
69 });
70 }
71}
72
73export async function projectAccountOrganizationMembershipsToWorkspaceMembers(
74 ctx: DbCtx,
75 args: { accountId: AccountId; now: number; mapRole: (role: Doc<"organizationMembers">["role"]) => WorkspaceMemberRole },
76): Promise<void> {
77 const memberships = await ctx.db
78 .query("organizationMembers")
79 .withIndex("by_account", (q) => q.eq("accountId", args.accountId))
80 .collect();
81
82 for (const membership of memberships) {
83 if (membership.status === "removed") {
84 continue;
85 }
86
87 await projectOrganizationMembershipToWorkspaceMembers(ctx, {
88 organizationId: membership.organizationId,
89 accountId: membership.accountId,
90 role: args.mapRole(membership.role),
91 status: membership.status,
92 now: args.now,
93 workosOrgMembershipId: membership.workosOrgMembershipId,
94 });
95 }
96}
97
98export async function seedWorkspaceMembersFromOrganization(
99 ctx: DbCtx,
100 args: {
101 organizationId: OrganizationId;
102 workspaceId: Doc<"workspaces">["_id"];
103 now: number;
104 mapRole: (role: Doc<"organizationMembers">["role"]) => WorkspaceMemberRole;
105 },
106): Promise<void> {
107 const orgMemberships = await ctx.db
108 .query("organizationMembers")
109 .withIndex("by_org", (q) => q.eq("organizationId", args.organizationId))
110 .collect();
111
112 for (const membership of orgMemberships) {
113 if (membership.status === "removed") {
114 continue;
115 }
116
117 await upsertWorkspaceMembership(ctx, {
118 workspaceId: args.workspaceId,
119 accountId: membership.accountId,
120 role: args.mapRole(membership.role),
121 status: membership.status,
122 now: args.now,
123 workosOrgMembershipId: membership.workosOrgMembershipId,
124 });
125 }
126}
executor/packages/database/src/billing/public-handlers.ts+2−1
@@ -2,6 +2,7 @@ import type { StripeSubscriptions } from "@convex-dev/stripe"; · 1 unmodified line
2import type { Id } from "../../convex/_generated/dataModel.d.ts";2import type { Id } from "../../convex/_generated/dataModel.d.ts";
3import type { ActionCtx, MutationCtx, QueryCtx } from "../../convex/_generated/server";3import type { ActionCtx, MutationCtx, QueryCtx } from "../../convex/_generated/server";
4import { canManageBilling, isAdminRole } from "../../../core/src/identity";4import { canManageBilling, isAdminRole } from "../../../core/src/identity";
5import { safeRunAfter } from "../lib/scheduler";
5 6
6type Internal = typeof import("../../convex/_generated/api").internal;7type Internal = typeof import("../../convex/_generated/api").internal;
7type Components = typeof import("../../convex/_generated/api").components;8type Components = typeof import("../../convex/_generated/api").components;
@@ -209,7 +210,7 @@ export async function retrySeatSyncHandler( · 201 unmodified lines
209 organizationId: typedCtx.organizationId,210 organizationId: typedCtx.organizationId,
210 });211 });
211 212
212 await typedCtx.scheduler.runAfter(0, internal.billingSync.syncSeatQuantity, {213 await safeRunAfter(typedCtx.scheduler, 0, internal.billingSync.syncSeatQuantity, {
213 organizationId: typedCtx.organizationId,214 organizationId: typedCtx.organizationId,
214 expectedVersion: nextVersion,215 expectedVersion: nextVersion,
215 });216 });
executor/packages/database/src/credentials-node/upsert-credential.ts+7−6
@@ -1,6 +1,10 @@
1import { WorkOS } from "@workos-inc/node";1import { WorkOS } from "@workos-inc/node";
2import type { Id } from "../../convex/_generated/dataModel.d.ts";2import type { Id } from "../../convex/_generated/dataModel.d.ts";
3import type { ActionCtx } from "../../convex/_generated/server";3import type { ActionCtx } from "../../convex/_generated/server";
4import {
5 assertMatchesCanonicalActorId,
6 canonicalActorIdForWorkspaceAccess,
7} from "../auth/actor_identity";
4import { asRecord } from "../lib/object";8import { asRecord } from "../lib/object";
5 9
6type Internal = typeof import("../../convex/_generated/api").internal;10type Internal = typeof import("../../convex/_generated/api").internal;
@@ -170,12 +174,9 @@ export async function upsertCredentialHandler( · 163 unmodified lines
170 workspaceId: args.workspaceId,174 workspaceId: args.workspaceId,
171 sessionId: args.sessionId,175 sessionId: args.sessionId,
172 });176 });
173 const canonicalActorId = access.provider === "anonymous"177 const canonicalActorId = canonicalActorIdForWorkspaceAccess(access);
174 ? access.providerAccountId178 if (args.scope === "actor") {
175 : access.accountId;179 assertMatchesCanonicalActorId(args.actorId, canonicalActorId);
176
177 if (args.scope === "actor" && args.actorId && args.actorId !== canonicalActorId) {
178 throw new Error("actorId must match the authenticated workspace actor");
179 }180 }
180 181
181 const actorId = normalizedActorId(args.scope, args.actorId ?? canonicalActorId);182 const actorId = normalizedActorId(args.scope, args.actorId ?? canonicalActorId);
executor/packages/database/src/database/anonymous.ts+2−42
@@ -2,9 +2,7 @@ import type { Doc } from "../../convex/_generated/dataModel.d.ts"; · 1 unmodified line
2import type { MutationCtx } from "../../convex/_generated/server";2import type { MutationCtx } from "../../convex/_generated/server";
3import { slugify } from "../../../core/src/identity";3import { slugify } from "../../../core/src/identity";
4import { ensureUniqueSlug } from "../../../core/src/slug";4import { ensureUniqueSlug } from "../../../core/src/slug";
5 5import { upsertOrganizationMembership } from "../auth/memberships";
6type OrganizationRole = "owner" | "admin" | "member" | "billing_admin";
7type OrganizationMemberStatus = "active" | "pending" | "removed";
8 6
9async function ensureUniqueOrganizationSlug(ctx: Pick<MutationCtx, "db">, baseName: string): Promise<string> {7async function ensureUniqueOrganizationSlug(ctx: Pick<MutationCtx, "db">, baseName: string): Promise<string> {
10 const baseSlug = slugify(baseName, "workspace");8 const baseSlug = slugify(baseName, "workspace");
@@ -17,44 +15,6 @@ async function ensureUniqueOrganizationSlug(ctx: Pick<MutationCtx, "db">, baseNa · 6 unmodified lines
17 });15 });
18}16}
19 17
20async function upsertOrganizationMembership(
21 ctx: Pick<MutationCtx, "db">,
22 args: {
23 organizationId: Doc<"organizations">["_id"];
24 accountId: Doc<"accounts">["_id"];
25 role: OrganizationRole;
26 status: OrganizationMemberStatus;
27 billable: boolean;
28 now: number;
29 },
30) {
31 const existing = await ctx.db
32 .query("organizationMembers")
33 .withIndex("by_org_account", (q) => q.eq("organizationId", args.organizationId).eq("accountId", args.accountId))
34 .unique();
35
36 if (existing) {
37 await ctx.db.patch(existing._id, {
38 role: args.role,
39 status: args.status,
40 billable: args.billable,
41 joinedAt: args.status === "active" ? (existing.joinedAt ?? args.now) : existing.joinedAt,
42 updatedAt: args.now,
43 });
44 return;
45 }
46
47 await ctx.db.insert("organizationMembers", {
48 organizationId: args.organizationId,
49 accountId: args.accountId,
50 role: args.role,
51 status: args.status,
52 billable: args.billable,
53 joinedAt: args.status === "active" ? args.now : undefined,
54 createdAt: args.now,
55 updatedAt: args.now,
56 });
57}
58 18
59export async function ensureAnonymousIdentity(19export async function ensureAnonymousIdentity(
60 ctx: MutationCtx,20 ctx: MutationCtx,
@@ -136,7 +96,7 @@ export async function ensureAnonymousIdentity( · 75 unmodified lines
136 let user = await ctx.db96 let user = await ctx.db
137 .query("workspaceMembers")97 .query("workspaceMembers")
138 .withIndex("by_workspace_account", (q) => q.eq("workspaceId", workspace._id).eq("accountId", account._id))98 .withIndex("by_workspace_account", (q) => q.eq("workspaceId", workspace._id).eq("accountId", account._id))
139 .unique();99 .first();
140 100
141 if (!user) {101 if (!user) {
142 const userId = await ctx.db.insert("workspaceMembers", {102 const userId = await ctx.db.insert("workspaceMembers", {
executor/packages/database/src/database/tool_source_config.ts+179−0
@@ -0,0 +1,179 @@
1import type { OpenApiAuth } from "../../../core/src/tool/source-types";
2import type { ToolApprovalMode } from "../../../core/src/types";
3import { asRecord } from "../lib/object";
4
5type ToolSourceType = "mcp" | "openapi" | "graphql";
6
7function optionalTrimmedString(value: unknown): string | undefined {
8 if (typeof value !== "string") return undefined;
9 const trimmed = value.trim();
10 return trimmed.length > 0 ? trimmed : undefined;
11}
12
13function requiredTrimmedString(value: unknown, fieldName: string): string {
14 const trimmed = optionalTrimmedString(value);
15 if (!trimmed) {
16 throw new Error(`Tool source ${fieldName} is required`);
17 }
18 return trimmed;
19}
20
21function normalizeStringMap(value: unknown, fieldName: string): Record<string, string> | undefined {
22 if (value === undefined) return undefined;
23 const record = asRecord(value);
24 if (Object.keys(record).length === 0) {
25 return undefined;
26 }
27
28 const normalized: Record<string, string> = {};
29 for (const [key, rawValue] of Object.entries(record)) {
30 const normalizedKey = key.trim();
31 if (!normalizedKey) continue;
32 if (typeof rawValue !== "string") {
33 throw new Error(`Tool source ${fieldName}.${normalizedKey} must be a string`);
34 }
35 normalized[normalizedKey] = rawValue;
36 }
37
38 return Object.keys(normalized).length > 0 ? normalized : undefined;
39}
40
41function normalizeApprovalMode(value: unknown, fieldName: string): ToolApprovalMode | undefined {
42 if (value === undefined) return undefined;
43 if (value === "auto" || value === "required") {
44 return value;
45 }
46 throw new Error(`Tool source ${fieldName} must be 'auto' or 'required'`);
47}
48
49function normalizeOverrides(value: unknown, fieldName: string): Record<string, { approval?: ToolApprovalMode }> | undefined {
50 if (value === undefined) return undefined;
51 const raw = asRecord(value);
52 if (Object.keys(raw).length === 0) {
53 return undefined;
54 }
55
56 const normalized: Record<string, { approval?: ToolApprovalMode }> = {};
57 for (const [rawKey, rawValue] of Object.entries(raw)) {
58 const key = rawKey.trim();
59 if (!key) continue;
60 const entry = asRecord(rawValue);
61 const approval = normalizeApprovalMode(entry.approval, `${fieldName}.${key}.approval`);
62 normalized[key] = approval ? { approval } : {};
63 }
64
65 return Object.keys(normalized).length > 0 ? normalized : undefined;
66}
67
68function normalizeAuthMode(value: unknown): "static" | "workspace" | "actor" | undefined {
69 if (value === undefined) return undefined;
70 if (value === "static" || value === "workspace" || value === "actor") {
71 return value;
72 }
73 throw new Error("Tool source auth.mode must be 'static', 'workspace', or 'actor'");
74}
75
76function normalizeAuth(value: unknown): OpenApiAuth | undefined {
77 if (value === undefined) return undefined;
78
79 const auth = asRecord(value);
80 const authType = optionalTrimmedString(auth.type);
81 if (!authType) {
82 throw new Error("Tool source auth.type is required when auth is provided");
83 }
84
85 if (authType === "none") {
86 return { type: "none" };
87 }
88
89 if (authType === "basic") {
90 return {
91 type: "basic",
92 mode: normalizeAuthMode(auth.mode),
93 username: optionalTrimmedString(auth.username),
94 password: optionalTrimmedString(auth.password),
95 };
96 }
97
98 if (authType === "bearer") {
99 return {
100 type: "bearer",
101 mode: normalizeAuthMode(auth.mode),
102 token: optionalTrimmedString(auth.token),
103 };
104 }
105
106 if (authType === "apiKey") {
107 const header = requiredTrimmedString(auth.header, "auth.header");
108 return {
109 type: "apiKey",
110 mode: normalizeAuthMode(auth.mode),
111 header,
112 value: optionalTrimmedString(auth.value),
113 };
114 }
115
116 throw new Error(`Unsupported tool source auth.type '${authType}'`);
117}
118
119function normalizeSpec(value: unknown): string | Record<string, unknown> {
120 if (typeof value === "string") {
121 const trimmed = value.trim();
122 if (!trimmed) {
123 throw new Error("Tool source spec is required");
124 }
125 return trimmed;
126 }
127
128 const specObject = asRecord(value);
129 if (Object.keys(specObject).length === 0) {
130 throw new Error("Tool source spec must be a non-empty string or object");
131 }
132 return specObject;
133}
134
135export function normalizeToolSourceConfig(type: ToolSourceType, rawConfig: unknown): Record<string, unknown> {
136 const config = asRecord(rawConfig);
137
138 if (type === "mcp") {
139 const url = requiredTrimmedString(config.url, "url");
140 const transport = config.transport;
141 if (transport !== undefined && transport !== "sse" && transport !== "streamable-http") {
142 throw new Error("Tool source transport must be 'sse' or 'streamable-http'");
143 }
144
145 return {
146 url,
147 transport,
148 auth: normalizeAuth(config.auth),
149 queryParams: normalizeStringMap(config.queryParams, "queryParams"),
150 discoveryHeaders: normalizeStringMap(config.discoveryHeaders, "discoveryHeaders"),
151 defaultApproval: normalizeApprovalMode(config.defaultApproval, "defaultApproval"),
152 overrides: normalizeOverrides(config.overrides, "overrides"),
153 };
154 }
155
156 if (type === "graphql") {
157 const endpoint = requiredTrimmedString(config.endpoint, "endpoint");
158
159 return {
160 endpoint,
161 schema: Object.keys(asRecord(config.schema)).length > 0 ? asRecord(config.schema) : undefined,
162 auth: normalizeAuth(config.auth),
163 defaultQueryApproval: normalizeApprovalMode(config.defaultQueryApproval, "defaultQueryApproval"),
164 defaultMutationApproval: normalizeApprovalMode(config.defaultMutationApproval, "defaultMutationApproval"),
165 overrides: normalizeOverrides(config.overrides, "overrides"),
166 };
167 }
168
169 return {
170 spec: normalizeSpec(config.spec),
171 collectionUrl: optionalTrimmedString(config.collectionUrl),
172 postmanProxyUrl: optionalTrimmedString(config.postmanProxyUrl),
173 baseUrl: optionalTrimmedString(config.baseUrl),
174 auth: normalizeAuth(config.auth),
175 defaultReadApproval: normalizeApprovalMode(config.defaultReadApproval, "defaultReadApproval"),
176 defaultWriteApproval: normalizeApprovalMode(config.defaultWriteApproval, "defaultWriteApproval"),
177 overrides: normalizeOverrides(config.overrides, "overrides"),
178 };
179}
executor/packages/database/src/executor/handlers.ts+7−12
@@ -3,6 +3,10 @@ import type { ActionCtx, MutationCtx } from "../../convex/_generated/server"; · 2 unmodified lines
3import { actorIdForAccount } from "../../../core/src/identity";3import { actorIdForAccount } from "../../../core/src/identity";
4import { defaultRuntimeId, isKnownRuntimeId, isRuntimeEnabled } from "../../../core/src/runtimes/runtime-catalog";4import { defaultRuntimeId, isKnownRuntimeId, isRuntimeEnabled } from "../../../core/src/runtimes/runtime-catalog";
5import type { ApprovalRecord, TaskExecutionOutcome, TaskRecord } from "../../../core/src/types";5import type { ApprovalRecord, TaskExecutionOutcome, TaskRecord } from "../../../core/src/types";
6import {
7 assertMatchesCanonicalActorId,
8 canonicalActorIdForWorkspaceAccess,
9} from "../auth/actor_identity";
6import { DEFAULT_TASK_TIMEOUT_MS } from "../task/constants";10import { DEFAULT_TASK_TIMEOUT_MS } from "../task/constants";
7import { createTaskEvent } from "../task/events";11import { createTaskEvent } from "../task/events";
8import { markTaskFinished } from "../task/finish";12import { markTaskFinished } from "../task/finish";
@@ -164,15 +168,8 @@ export async function createTaskHandler( · 155 unmodified lines
164 sessionId: args.sessionId,168 sessionId: args.sessionId,
165 });169 });
166 170
167 const canonicalActorId = actorIdForAccount({171 const canonicalActorId = canonicalActorIdForWorkspaceAccess(access);
168 _id: access.accountId,172 assertMatchesCanonicalActorId(args.actorId, canonicalActorId);
169 provider: access.provider,
170 providerAccountId: access.providerAccountId,
171 });
172
173 if (args.actorId && args.actorId !== canonicalActorId) {
174 throw new Error("actorId must match the authenticated workspace actor");
175 }
176 173
177 const waitForResult = args.waitForResult ?? false;174 const waitForResult = args.waitForResult ?? false;
178 const created = await ctx.runMutation(internal.executor.createTaskInternal, {175 const created = await ctx.runMutation(internal.executor.createTaskInternal, {
@@ -243,9 +240,7 @@ export async function resolveApprovalHandler( · 64 unmodified lines
243 };240 };
244 241
245 const canonicalActorId = actorIdForAccount(typedCtx.account);242 const canonicalActorId = actorIdForAccount(typedCtx.account);
246 if (args.reviewerId && args.reviewerId !== canonicalActorId) {243 assertMatchesCanonicalActorId(args.reviewerId, canonicalActorId, "reviewerId");
247 throw new Error("reviewerId must match the authenticated workspace actor");
248 }
249 244
250 return await resolveApprovalRecord(typedCtx, internal, {245 return await resolveApprovalRecord(typedCtx, internal, {
251 ...args,246 ...args,
executor/packages/database/src/lib/scheduler.ts+14−1
@@ -2,8 +2,21 @@ type SchedulerLike = { · 1 unmodified line
2 runAfter: (delayMs: number, functionReference: any, args: any) => Promise<any>;2 runAfter: (delayMs: number, functionReference: any, args: any) => Promise<any>;
3};3};
4 4
5function isTestProcess(): boolean {
6 if (process.env.NODE_ENV === "test") {
7 return true;
8 }
9
10 if (process.env.BUN_TEST === "1") {
11 return true;
12 }
13
14 const argv = process.argv.join(" ");
15 return argv.includes(".test.ts");
16}
17
5export function isSchedulerDisabled(): boolean {18export function isSchedulerDisabled(): boolean {
6 return process.env.DISABLE_CONVEX_SCHEDULER === "1";19 return process.env.DISABLE_CONVEX_SCHEDULER === "1" || isTestProcess();
7}20}
8 21
9export async function safeRunAfter(22export async function safeRunAfter(
executor/packages/database/src/organizations/handlers.ts+14−4
@@ -2,6 +2,11 @@ import type { Doc, Id } from "../../convex/_generated/dataModel.d.ts"; · 1 unmodified line
2import type { MutationCtx, QueryCtx } from "../../convex/_generated/server";2import type { MutationCtx, QueryCtx } from "../../convex/_generated/server";
3import { getOrganizationMembership, slugify } from "../../../core/src/identity";3import { getOrganizationMembership, slugify } from "../../../core/src/identity";
4import { ensureUniqueSlug } from "../../../core/src/slug";4import { ensureUniqueSlug } from "../../../core/src/slug";
5import {
6 mapOrganizationRoleToWorkspaceRole,
7 upsertOrganizationMembership,
8} from "../auth/memberships";
9import { seedWorkspaceMembersFromOrganization } from "../auth/workspace_membership_projection";
5 10
6type WorkspaceSummary = {11type WorkspaceSummary = {
7 id: Id<"workspaces">;12 id: Id<"workspaces">;
@@ -64,15 +69,13 @@ export async function createOrganizationHandler(ctx: unknown, args: { name: stri · 56 unmodified lines
64 updatedAt: now,69 updatedAt: now,
65 });70 });
66 71
67 await typedCtx.db.insert("organizationMembers", {72 await upsertOrganizationMembership(typedCtx, {
68 organizationId,73 organizationId,
69 accountId: account._id,74 accountId: account._id,
70 role: "owner",75 role: "owner",
71 status: "active",76 status: "active",
72 billable: true,77 billable: true,
73 joinedAt: now,78 now,
74 createdAt: now,
75 updatedAt: now,
76 });79 });
77 80
78 const workspaceId = await typedCtx.db.insert("workspaces", {81 const workspaceId = await typedCtx.db.insert("workspaces", {
@@ -84,6 +87,13 @@ export async function createOrganizationHandler(ctx: unknown, args: { name: stri · 5 unmodified lines
84 updatedAt: now,87 updatedAt: now,
85 });88 });
86 89
90 await seedWorkspaceMembersFromOrganization(typedCtx, {
91 organizationId,
92 workspaceId,
93 now,
94 mapRole: mapOrganizationRoleToWorkspaceRole,
95 });
96
87 const organization = await typedCtx.db.get(organizationId);97 const organization = await typedCtx.db.get(organizationId);
88 const workspace = await typedCtx.db.get(workspaceId);98 const workspace = await typedCtx.db.get(workspaceId);
89 if (!organization || !workspace) {99 if (!organization || !workspace) {
executor/packages/database/src/runtime/actor_auth.ts+6−9
@@ -3,7 +3,10 @@ · 2 unmodified lines
3import type { ActionCtx } from "../../convex/_generated/server";3import type { ActionCtx } from "../../convex/_generated/server";
4import type { Id } from "../../convex/_generated/dataModel.d.ts";4import type { Id } from "../../convex/_generated/dataModel.d.ts";
5import { internal } from "../../convex/_generated/api";5import { internal } from "../../convex/_generated/api";
6import { actorIdForAccount } from "../../../core/src/identity";6import {
7 assertMatchesCanonicalActorId,
8 canonicalActorIdForWorkspaceAccess,
9} from "../auth/actor_identity";
7 10
8export async function requireCanonicalActor(11export async function requireCanonicalActor(
9 ctx: ActionCtx,12 ctx: ActionCtx,
@@ -17,13 +20,7 @@ export async function requireCanonicalActor( · 7 unmodified lines
17 workspaceId: args.workspaceId,20 workspaceId: args.workspaceId,
18 sessionId: args.sessionId,21 sessionId: args.sessionId,
19 });22 });
20 const canonicalActorId = actorIdForAccount({23 const canonicalActorId = canonicalActorIdForWorkspaceAccess(access);
21 _id: access.accountId,24 assertMatchesCanonicalActorId(args.actorId, canonicalActorId);
22 provider: access.provider,
23 providerAccountId: access.providerAccountId,
24 });
25 if (args.actorId && args.actorId !== canonicalActorId) {
26 throw new Error("actorId must match the authenticated workspace actor");
27 }
28 return canonicalActorId;25 return canonicalActorId;
29}26}
executor/packages/database/src/runtime/tool_call_resolution.ts+7−25
@@ -6,9 +6,9 @@ import { parseGraphqlOperationPaths } from "../../../core/src/graphql/operation- · 5 unmodified lines
6import type { AccessPolicyRecord, PolicyDecision, TaskRecord, ToolDefinition } from "../../../core/src/types";6import type { AccessPolicyRecord, PolicyDecision, TaskRecord, ToolDefinition } from "../../../core/src/types";
7import { rehydrateTools, type SerializedTool } from "../../../core/src/tool/source-serialization";7import { rehydrateTools, type SerializedTool } from "../../../core/src/tool/source-serialization";
8import { getDecisionForContext, getToolDecision } from "./policy";8import { getDecisionForContext, getToolDecision } from "./policy";
9import { getReadyRegistryBuildId } from "./tool_registry_state";
9import { normalizeToolPathForLookup, toPreferredToolPath } from "./tool_paths";10import { normalizeToolPathForLookup, toPreferredToolPath } from "./tool_paths";
10import { baseTools } from "./workspace_tools";11import { baseTools } from "./workspace_tools";
11import { sourceSignature } from "./tool_source_loading";
12 12
13export function getGraphqlDecision(13export function getGraphqlDecision(
14 task: TaskRecord,14 task: TaskRecord,
@@ -66,29 +66,6 @@ export function getGraphqlDecision( · 51 unmodified lines
66 return { decision: worstDecision, effectivePaths: fieldPaths };66 return { decision: worstDecision, effectivePaths: fieldPaths };
67}67}
68 68
69async function resolveRegistryBuildId(
70 ctx: ActionCtx,
71 workspaceId: TaskRecord["workspaceId"],
72): Promise<string> {
73 const [state, sources] = await Promise.all([
74 ctx.runQuery(internal.toolRegistry.getState, { workspaceId }) as Promise<null | { signature: string; readyBuildId?: string }>,
75 ctx.runQuery(internal.database.listToolSources, { workspaceId }) as Promise<Array<{ id: string; updatedAt: number; enabled: boolean }>>,
76 ]);
77
78 const enabledSources = sources.filter((source) => source.enabled);
79 const signature = sourceSignature(workspaceId, enabledSources);
80 const expectedSignature = `toolreg_v2|${signature}`;
81 const buildId = state?.readyBuildId;
82
83 if (!buildId || state.signature !== expectedSignature) {
84 throw new Error(
85 "Tool registry is not ready (or is stale). Open Tools to refresh, or call listToolsWithWarnings to rebuild.",
86 );
87 }
88
89 return buildId;
90}
91
92async function suggestFromRegistry(69async function suggestFromRegistry(
93 ctx: ActionCtx,70 ctx: ActionCtx,
94 workspaceId: TaskRecord["workspaceId"],71 workspaceId: TaskRecord["workspaceId"],
@@ -127,7 +104,12 @@ export async function resolveToolForCall( · 32 unmodified lines
127 return { tool: builtin, resolvedToolPath: toolPath };104 return { tool: builtin, resolvedToolPath: toolPath };
128 }105 }
129 106
130 const buildId = await resolveRegistryBuildId(ctx, task.workspaceId);107 const buildId = await getReadyRegistryBuildId(ctx, {
108 workspaceId: task.workspaceId,
109 actorId: task.actorId,
110 clientId: task.clientId,
111 refreshOnStale: true,
112 });
131 113
132 let resolvedToolPath = toolPath;114 let resolvedToolPath = toolPath;
133 let entry = await ctx.runQuery(internal.toolRegistry.getToolByPath, {115 let entry = await ctx.runQuery(internal.toolRegistry.getToolByPath, {
executor/packages/database/src/runtime/tool_invocation.ts+7−14
@@ -21,7 +21,7 @@ import { publishTaskEvent } from "./events"; · 20 unmodified lines
21import { completeToolCall, denyToolCall, failToolCall } from "./tool_call_lifecycle";21import { completeToolCall, denyToolCall, failToolCall } from "./tool_call_lifecycle";
22import { assertPersistedCallRunnable, resolveCredentialHeaders } from "./tool_call_credentials";22import { assertPersistedCallRunnable, resolveCredentialHeaders } from "./tool_call_credentials";
23import { getGraphqlDecision, resolveToolForCall } from "./tool_call_resolution";23import { getGraphqlDecision, resolveToolForCall } from "./tool_call_resolution";
24import { sourceSignature } from "./tool_source_loading";24import { getReadyRegistryBuildId } from "./tool_registry_state";
25 25
26function createApprovalId(): string {26function createApprovalId(): string {
27 return `approval_${crypto.randomUUID()}`;27 return `approval_${crypto.randomUUID()}`;
@@ -62,19 +62,12 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 34 unmodified lines
62 // Fast system tools are handled server-side from the registry.62 // Fast system tools are handled server-side from the registry.
63 if (toolPath === "discover" || toolPath === "catalog.namespaces" || toolPath === "catalog.tools") {63 if (toolPath === "discover" || toolPath === "catalog.namespaces" || toolPath === "catalog.tools") {
64 const toolRegistry = internal.toolRegistry;64 const toolRegistry = internal.toolRegistry;
65 const [state, sources] = await Promise.all([65 const buildId = await getReadyRegistryBuildId(ctx, {
66 ctx.runQuery(toolRegistry.getState, { workspaceId: task.workspaceId }) as Promise<null | { signature: string; readyBuildId?: string }>,66 workspaceId: task.workspaceId,
67 ctx.runQuery(internal.database.listToolSources, { workspaceId: task.workspaceId }) as Promise<Array<{ id: string; updatedAt: number; enabled: boolean }>>,67 actorId: task.actorId,
68 ]);68 clientId: task.clientId,
69 const enabledSources = sources.filter((source) => source.enabled);69 refreshOnStale: true,
70 const signature = sourceSignature(task.workspaceId, enabledSources);70 });
71 const expectedSignature = `toolreg_v2|${signature}`;
72 const buildId = state?.readyBuildId;
73 if (!buildId || state.signature !== expectedSignature) {
74 throw new Error(
75 "Tool registry is not ready (or is stale). Open Tools to refresh, or call listToolsWithWarnings to rebuild.",
76 );
77 }
78 71
79 const payload = typeof input === "string"72 const payload = typeof input === "string"
80 ? { query: input }73 ? { query: input }
executor/packages/database/src/runtime/tool_registry_state.ts+77−0
@@ -0,0 +1,77 @@
1"use node";
2
3import type { ActionCtx } from "../../convex/_generated/server";
4import type { Id } from "../../convex/_generated/dataModel.d.ts";
5import { internal } from "../../convex/_generated/api";
6import { sourceSignature } from "./tool_source_loading";
7
8export const TOOL_REGISTRY_SIGNATURE_PREFIX = "toolreg_v2|";
9
10export function registrySignatureForWorkspace(
11 workspaceId: Id<"workspaces">,
12 sources: Array<{ id: string; updatedAt: number; enabled: boolean }>,
13): string {
14 const enabledSources = sources.filter((source) => source.enabled);
15 return `${TOOL_REGISTRY_SIGNATURE_PREFIX}${sourceSignature(workspaceId, enabledSources)}`;
16}
17
18type RegistryState = {
19 signature: string;
20 readyBuildId?: string;
21} | null;
22
23type ToolSourceState = {
24 id: string;
25 updatedAt: number;
26 enabled: boolean;
27};
28
29async function readRegistryState(
30 ctx: Pick<ActionCtx, "runQuery">,
31 workspaceId: Id<"workspaces">,
32): Promise<{ buildId?: string; isReady: boolean }> {
33 const [state, sources] = await Promise.all([
34 ctx.runQuery(internal.toolRegistry.getState, { workspaceId }) as Promise<RegistryState>,
35 ctx.runQuery(internal.database.listToolSources, { workspaceId }) as Promise<ToolSourceState[]>,
36 ]);
37
38 const expectedSignature = registrySignatureForWorkspace(workspaceId, sources);
39 const buildId = state?.readyBuildId;
40
41 return {
42 buildId,
43 isReady: Boolean(buildId && state?.signature === expectedSignature),
44 };
45}
46
47export async function getReadyRegistryBuildId(
48 ctx: Pick<ActionCtx, "runQuery" | "runAction">,
49 args: {
50 workspaceId: Id<"workspaces">;
51 actorId?: string;
52 clientId?: string;
53 refreshOnStale?: boolean;
54 },
55): Promise<string> {
56 const initial = await readRegistryState(ctx, args.workspaceId);
57 if (initial.isReady && initial.buildId) {
58 return initial.buildId;
59 }
60
61 if (args.refreshOnStale) {
62 await ctx.runAction(internal.executorNode.listToolsWithWarningsInternal, {
63 workspaceId: args.workspaceId,
64 actorId: args.actorId,
65 clientId: args.clientId,
66 });
67
68 const refreshed = await readRegistryState(ctx, args.workspaceId);
69 if (refreshed.isReady && refreshed.buildId) {
70 return refreshed.buildId;
71 }
72 }
73
74 throw new Error(
75 "Tool registry is not ready (or is stale). Open Tools to refresh, or call listToolsWithWarnings to rebuild.",
76 );
77}
executor/packages/database/src/runtime/tool_source_loading.ts+5−38
@@ -20,6 +20,7 @@ import type { · 19 unmodified lines
20 PreparedOpenApiSpec,20 PreparedOpenApiSpec,
21} from "../../../core/src/tool/source-types";21} from "../../../core/src/tool/source-types";
22import type { ToolSourceRecord } from "../../../core/src/types";22import type { ToolSourceRecord } from "../../../core/src/types";
23import { normalizeToolSourceConfig } from "../database/tool_source_config";
23import { asPayload } from "../lib/object";24import { asPayload } from "../lib/object";
24 25
25const OPENAPI_SPEC_CACHE_TTL_MS = 5 * 60 * 60_000;26const OPENAPI_SPEC_CACHE_TTL_MS = 5 * 60 * 60_000;
@@ -40,40 +41,15 @@ export function normalizeExternalToolSource(raw: { · 14 unmodified lines
40 name: string;41 name: string;
41 config: Record<string, unknown>;42 config: Record<string, unknown>;
42}): ExternalToolSourceConfig {43}): ExternalToolSourceConfig {
43 const config = raw.config;44 const config = normalizeToolSourceConfig(raw.type, raw.config);
44 45
45 if (raw.type === "mcp") {46 if (raw.type === "mcp") {
46 if (typeof config.url !== "string" || config.url.trim().length === 0) {
47 throw new Error(`MCP source '${raw.name}' missing url`);
48 }
49
50 if (
51 config.transport !== undefined
52 && config.transport !== "sse"
53 && config.transport !== "streamable-http"
54 ) {
55 throw new Error(`MCP source '${raw.name}' has invalid transport`);
56 }
57
58 if (config.queryParams !== undefined) {
59 const queryParams = config.queryParams;
60 if (!queryParams || typeof queryParams !== "object" || Array.isArray(queryParams)) {
61 throw new Error(`MCP source '${raw.name}' queryParams must be an object`);
62 }
63
64 for (const value of Object.values(queryParams as Record<string, unknown>)) {
65 if (typeof value !== "string") {
66 throw new Error(`MCP source '${raw.name}' queryParams values must be strings`);
67 }
68 }
69 }
70
71 const result: McpToolSourceConfig = {47 const result: McpToolSourceConfig = {
72 type: "mcp",48 type: "mcp",
73 name: raw.name,49 name: raw.name,
74 sourceId: raw.id,50 sourceId: raw.id,
75 sourceKey: `source:${raw.id}`,51 sourceKey: `source:${raw.id}`,
76 url: config.url,52 url: String(config.url),
77 auth: config.auth as McpToolSourceConfig["auth"],53 auth: config.auth as McpToolSourceConfig["auth"],
78 transport: config.transport as McpToolSourceConfig["transport"],54 transport: config.transport as McpToolSourceConfig["transport"],
79 queryParams: config.queryParams as McpToolSourceConfig["queryParams"],55 queryParams: config.queryParams as McpToolSourceConfig["queryParams"],
@@ -84,16 +60,12 @@ export function normalizeExternalToolSource(raw: { · 4 unmodified lines
84 }60 }
85 61
86 if (raw.type === "graphql") {62 if (raw.type === "graphql") {
87 if (typeof config.endpoint !== "string" || config.endpoint.trim().length === 0) {
88 throw new Error(`GraphQL source '${raw.name}' missing endpoint`);
89 }
90
91 const result: GraphqlToolSourceConfig = {63 const result: GraphqlToolSourceConfig = {
92 type: "graphql",64 type: "graphql",
93 name: raw.name,65 name: raw.name,
94 sourceId: raw.id,66 sourceId: raw.id,
95 sourceKey: `source:${raw.id}`,67 sourceKey: `source:${raw.id}`,
96 endpoint: config.endpoint,68 endpoint: String(config.endpoint),
97 schema: config.schema as GraphqlToolSourceConfig["schema"],69 schema: config.schema as GraphqlToolSourceConfig["schema"],
98 auth: config.auth as GraphqlToolSourceConfig["auth"],70 auth: config.auth as GraphqlToolSourceConfig["auth"],
99 defaultQueryApproval: config.defaultQueryApproval as GraphqlToolSourceConfig["defaultQueryApproval"],71 defaultQueryApproval: config.defaultQueryApproval as GraphqlToolSourceConfig["defaultQueryApproval"],
@@ -103,17 +75,12 @@ export function normalizeExternalToolSource(raw: { · 3 unmodified lines
103 return result;75 return result;
104 }76 }
105 77
106 const spec = config.spec;
107 if (typeof spec !== "string" && (typeof spec !== "object" || spec === null)) {
108 throw new Error(`OpenAPI source '${raw.name}' missing spec`);
109 }
110
111 const result: OpenApiToolSourceConfig = {78 const result: OpenApiToolSourceConfig = {
112 type: "openapi",79 type: "openapi",
113 name: raw.name,80 name: raw.name,
114 sourceId: raw.id,81 sourceId: raw.id,
115 sourceKey: `source:${raw.id}`,82 sourceKey: `source:${raw.id}`,
116 spec: spec as OpenApiToolSourceConfig["spec"],83 spec: config.spec as OpenApiToolSourceConfig["spec"],
117 baseUrl: config.baseUrl as OpenApiToolSourceConfig["baseUrl"],84 baseUrl: config.baseUrl as OpenApiToolSourceConfig["baseUrl"],
118 auth: config.auth as OpenApiToolSourceConfig["auth"],85 auth: config.auth as OpenApiToolSourceConfig["auth"],
119 defaultReadApproval: config.defaultReadApproval as OpenApiToolSourceConfig["defaultReadApproval"],86 defaultReadApproval: config.defaultReadApproval as OpenApiToolSourceConfig["defaultReadApproval"],
executor/packages/database/src/runtime/workspace_tools.ts+2−1
@@ -23,6 +23,7 @@ import type { · 22 unmodified lines
23} from "../../../core/src/types";23} from "../../../core/src/types";
24import { computeOpenApiSourceQuality, listVisibleToolDescriptors } from "./tool_descriptors";24import { computeOpenApiSourceQuality, listVisibleToolDescriptors } from "./tool_descriptors";
25import { loadSourceArtifact, normalizeExternalToolSource, sourceSignature } from "./tool_source_loading";25import { loadSourceArtifact, normalizeExternalToolSource, sourceSignature } from "./tool_source_loading";
26import { registrySignatureForWorkspace } from "./tool_registry_state";
26import { normalizeToolPathForLookup } from "./tool_paths";27import { normalizeToolPathForLookup } from "./tool_paths";
27 28
28const baseTools = new Map<string, ToolDefinition>();29const baseTools = new Map<string, ToolDefinition>();
@@ -516,7 +517,7 @@ export async function getWorkspaceTools( · 487 unmodified lines
516 traceStep("listToolSources", listSourcesStartedAt);517 traceStep("listToolSources", listSourcesStartedAt);
517 const hasOpenApiSource = sources.some((source: { type: string }) => source.type === "openapi");518 const hasOpenApiSource = sources.some((source: { type: string }) => source.type === "openapi");
518 const signature = sourceSignature(workspaceId, sources);519 const signature = sourceSignature(workspaceId, sources);
519 const registrySignature = `toolreg_v2|${signature}`;520 const registrySignature = registrySignatureForWorkspace(workspaceId, sources);
520 const debugBase: Omit<WorkspaceToolsDebug, "mode" | "normalizedSourceCount" | "cacheHit" | "cacheFresh" | "timedOutSources" | "durationMs" | "trace"> = {521 const debugBase: Omit<WorkspaceToolsDebug, "mode" | "normalizedSourceCount" | "cacheHit" | "cacheFresh" | "timedOutSources" | "durationMs" | "trace"> = {
521 includeDts,522 includeDts,
522 sourceTimeoutMs: sourceTimeoutMs ?? null,523 sourceTimeoutMs: sourceTimeoutMs ?? null,
executor/packages/database/src/workspaces/handlers.ts+14−4
@@ -2,6 +2,11 @@ import type { Doc, Id } from "../../convex/_generated/dataModel.d.ts"; · 1 unmodified line
2import type { MutationCtx, QueryCtx } from "../../convex/_generated/server";2import type { MutationCtx, QueryCtx } from "../../convex/_generated/server";
3import { getOrganizationMembership, slugify } from "../../../core/src/identity";3import { getOrganizationMembership, slugify } from "../../../core/src/identity";
4import { ensureUniqueSlug } from "../../../core/src/slug";4import { ensureUniqueSlug } from "../../../core/src/slug";
5import {
6 mapOrganizationRoleToWorkspaceRole,
7 upsertOrganizationMembership,
8} from "../auth/memberships";
9import { seedWorkspaceMembersFromOrganization } from "../auth/workspace_membership_projection";
5 10
6type WorkspaceResult = {11type WorkspaceResult = {
7 id: Id<"workspaces">;12 id: Id<"workspaces">;
@@ -175,15 +180,13 @@ export async function createWorkspaceHandler( · 167 unmodified lines
175 updatedAt: now,180 updatedAt: now,
176 });181 });
177 182
178 await typedCtx.db.insert("organizationMembers", {183 await upsertOrganizationMembership(typedCtx, {
179 organizationId,184 organizationId,
180 accountId: account._id,185 accountId: account._id,
181 role: "owner",186 role: "owner",
182 status: "active",187 status: "active",
183 billable: true,188 billable: true,
184 joinedAt: now,189 now,
185 createdAt: now,
186 updatedAt: now,
187 });190 });
188 }191 }
189 192
@@ -209,6 +212,13 @@ export async function createWorkspaceHandler( · 19 unmodified lines
209 throw new Error("Failed to create workspace");212 throw new Error("Failed to create workspace");
210 }213 }
211 214
215 await seedWorkspaceMembersFromOrganization(typedCtx, {
216 organizationId,
217 workspaceId,
218 now,
219 mapRole: mapOrganizationRoleToWorkspaceRole,
220 });
221
212 await cleanupEmptyStarterWorkspace(typedCtx, organizationId, workspaceId);222 await cleanupEmptyStarterWorkspace(typedCtx, organizationId, workspaceId);
213 223
214 return await toWorkspaceResult(typedCtx, workspace);224 return await toWorkspaceResult(typedCtx, workspace);
executor/scripts/test/run-suite.ts+92−0
@@ -0,0 +1,92 @@
1#!/usr/bin/env bun
2
3export {};
4
5const INTEGRATION_TESTS = new Set([
6 "packages/core/src/openapi/real-specs.test.ts",
7]);
8
9type SuiteMode = "fast" | "integration" | "e2e" | "all";
10
11function normalizeMode(raw: string | undefined): SuiteMode {
12 if (raw === "integration" || raw === "e2e" || raw === "all") {
13 return raw;
14 }
15 return "fast";
16}
17
18function isE2eTest(filePath: string): boolean {
19 return filePath.endsWith(".e2e.test.ts");
20}
21
22async function discoverAllTestFiles(): Promise<string[]> {
23 const files = new Set<string>();
24 const roots = ["apps", "packages", "scripts"];
25
26 for (const root of roots) {
27 const glob = new Bun.Glob(`${root}/**/*.test.ts`);
28 for await (const match of glob.scan({ cwd: process.cwd() })) {
29 files.add(match);
30 }
31 }
32
33 return [...files].sort();
34}
35
36function selectTests(mode: SuiteMode, allTests: string[]): string[] {
37 if (mode === "all") {
38 return allTests;
39 }
40
41 if (mode === "integration") {
42 return allTests.filter((filePath) => INTEGRATION_TESTS.has(filePath));
43 }
44
45 if (mode === "e2e") {
46 return allTests.filter(isE2eTest);
47 }
48
49 return allTests.filter((filePath) => !isE2eTest(filePath) && !INTEGRATION_TESTS.has(filePath));
50}
51
52async function main(): Promise<void> {
53 const mode = normalizeMode(process.argv[2]);
54 const rawArgs = process.argv.slice(3);
55 const listOnly = rawArgs.includes("--list");
56 const passthroughArgs = rawArgs.filter((arg) => arg !== "--list");
57
58 const allTests = await discoverAllTestFiles();
59 const selectedTests = selectTests(mode, allTests);
60
61 if (selectedTests.length === 0) {
62 console.error(`[test-suite] No tests selected for mode '${mode}'.`);
63 process.exit(1);
64 }
65
66 console.log(`[test-suite] mode=${mode} files=${selectedTests.length}`);
67
68 if (listOnly) {
69 for (const filePath of selectedTests) {
70 console.log(filePath);
71 }
72 return;
73 }
74
75 const child = Bun.spawn([
76 "bun",
77 "test",
78 ...selectedTests,
79 ...passthroughArgs,
80 ], {
81 cwd: process.cwd(),
82 stdin: "inherit",
83 stdout: "inherit",
84 stderr: "inherit",
85 env: process.env,
86 });
87
88 const exitCode = await child.exited;
89 process.exit(exitCode);
90}
91
92await main();
package.json+4−1
@@ -24,7 +24,10 @@ · 23 unmodified lines
24 "dev:assistant": "bun run --cwd assistant/packages/server dev",24 "dev:assistant": "bun run --cwd assistant/packages/server dev",
25 "dev:bot": "bun run --cwd assistant/packages/bot dev",25 "dev:bot": "bun run --cwd assistant/packages/bot dev",
26 "test": "bun run test:executor && bun run test:assistant",26 "test": "bun run test:executor && bun run test:assistant",
27 "test:executor": "DISABLE_CONVEX_SCHEDULER=1 bun test --cwd executor",27 "test:executor": "bun run --cwd executor test",
28 "test:executor:integration": "bun run --cwd executor test:integration",
29 "test:executor:e2e": "bun run --cwd executor test:e2e",
30 "test:executor:all": "bun run --cwd executor test:all",
28 "test:assistant": "bun run --cwd assistant test",31 "test:assistant": "bun run --cwd assistant test",
29 "typecheck": "bun run typecheck:executor && bun run typecheck:assistant",32 "typecheck": "bun run typecheck:executor && bun run typecheck:assistant",
30 "typecheck:executor": "bunx tsc --noEmit -p executor/tsconfig.json",33 "typecheck:executor": "bunx tsc --noEmit -p executor/tsconfig.json",