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.
Unified · Split
| @@ -33,13 +33,16 @@ Run from repo root unless noted. · 32 unmodified lines | |||
| 33 | 33 | ||
| 34 | ### Main quality commands | 34 | ### 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 commands | 40 | ### 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 Artifacts | 153 | ## 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: |
| @@ -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", |
| @@ -7,18 +7,6 @@ type MembershipCtx = Pick<QueryCtx, "db"> | Pick<MutationCtx, "db">; · 6 unmodified lines | |||
| 7 | 7 | ||
| 8 | type WorkspaceAccessMembership = Pick<Doc<"workspaceMembers">, "role" | "status">; | 8 | type WorkspaceAccessMembership = Pick<Doc<"workspaceMembers">, "role" | "status">; |
| 9 | 9 | ||
| 10 | function 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 | |||
| 22 | export type WorkspaceAccess = { | 10 | export 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.db | 71 | 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 | ||
| 89 | export async function getWorkspaceMembership( | 89 | export 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.db | 94 | 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 | ||
| 100 | export async function requireWorkspaceAccessForAccount( | 112 | export 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 | ||
| @@ -3,6 +3,7 @@ import { dispatchCodeWithCloudflareWorkerLoader } from "./loader-runtime"; · 2 unmodified lines | |||
| 3 | 3 | ||
| 4 | let fakeHostServer: ReturnType<typeof Bun.serve>; | 4 | let fakeHostServer: ReturnType<typeof Bun.serve>; |
| 5 | let fakeCallbackServer: ReturnType<typeof Bun.serve>; | 5 | let fakeCallbackServer: ReturnType<typeof Bun.serve>; |
| 6 | let previousConvexUrl: string | undefined; | ||
| 6 | 7 | ||
| 7 | const AUTH_TOKEN = "test-sandbox-token"; | 8 | const AUTH_TOKEN = "test-sandbox-token"; |
| 8 | const CALLBACK_TOKEN = "test-callback-token"; | 9 | const CALLBACK_TOKEN = "test-callback-token"; |
| @@ -19,6 +20,8 @@ type HostRequestBody = { · 10 unmodified lines | |||
| 19 | let lastHostRequestBody: HostRequestBody | null = null; | 20 | let lastHostRequestBody: HostRequestBody | null = null; |
| 20 | 21 | ||
| 21 | beforeAll(() => { | 22 | beforeAll(() => { |
| 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; |
| @@ -1,4 +1,3 @@ | |||
| 1 | import { Result } from "better-result"; | ||
| 2 | import { connectMcp, extractMcpResult } from "../mcp-runtime"; | 1 | import { connectMcp, extractMcpResult } from "../mcp-runtime"; |
| 3 | import { executePostmanRequest, type PostmanSerializedRunSpec } from "../postman-runtime"; | 2 | import { executePostmanRequest, type PostmanSerializedRunSpec } from "../postman-runtime"; |
| 4 | import { normalizeGraphqlFieldVariables, selectGraphqlFieldEnvelope } from "../graphql/field-tools"; | 3 | import { 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 | } |
| @@ -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" }); |
| @@ -115,7 +115,7 @@ export const ensureAnonymousMcpSession = internalMutation({ · 114 unmodified lines | |||
| 115 | const membership = await ctx.db | 115 | 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 | } |
| @@ -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"; |
| 8 | import { normalizeToolSourceConfig } from "../../src/database/tool_source_config"; | ||
| 8 | import { toolSourceTypeValidator } from "../../src/database/validators"; | 9 | import { toolSourceTypeValidator } from "../../src/database/validators"; |
| 9 | import { asRecord } from "../../src/lib/object"; | ||
| 10 | 10 | ||
| 11 | export const upsertToolSource = internalMutation({ | 11 | export 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([ |
| @@ -1,4 +1,4 @@ | |||
| 1 | import { expect, test } from "bun:test"; | 1 | import { afterAll, beforeAll, expect, test } from "bun:test"; |
| 2 | import { convexTest } from "convex-test"; | 2 | import { convexTest } from "convex-test"; |
| 3 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; | 3 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; |
| 4 | import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; | 4 | import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; |
| @@ -6,6 +6,30 @@ import { internal } from "./_generated/api"; · 1 unmodified line | |||
| 6 | import type { Id } from "./_generated/dataModel.d.ts"; | 6 | import type { Id } from "./_generated/dataModel.d.ts"; |
| 7 | import schema from "./schema"; | 7 | import schema from "./schema"; |
| 8 | 8 | ||
| 9 | let previousAnonymousAuthPrivateKeyPem: string | undefined; | ||
| 10 | let previousAnonymousAuthPublicKeyPem: string | undefined; | ||
| 11 | |||
| 12 | beforeAll(() => { | ||
| 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 | |||
| 19 | afterAll(() => { | ||
| 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 | |||
| 9 | function setup() { | 33 | function setup() { |
| 10 | return convexTest(schema, { | 34 | return convexTest(schema, { |
| 11 | "./database.ts": () => import("./database"), | 35 | "./database.ts": () => import("./database"), |
| @@ -13,6 +13,7 @@ import { · 12 unmodified lines | |||
| 13 | workosEnabled, | 13 | workosEnabled, |
| 14 | } from "../src/invites/workos"; | 14 | } from "../src/invites/workos"; |
| 15 | import { normalizePersonalOrganizationName } from "../src/invites/normalize"; | 15 | import { normalizePersonalOrganizationName } from "../src/invites/normalize"; |
| 16 | import { safeRunAfter } from "../src/lib/scheduler"; | ||
| 16 | 17 | ||
| 17 | const organizationRoleValidator = v.union( | 18 | const 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 | }); |
| @@ -2,6 +2,8 @@ import { v } from "convex/values"; · 1 unmodified line | |||
| 2 | import { internal } from "./_generated/api"; | 2 | import { internal } from "./_generated/api"; |
| 3 | import { getOrganizationMembership } from "../../core/src/identity"; | 3 | import { getOrganizationMembership } from "../../core/src/identity"; |
| 4 | import { organizationMutation, organizationQuery } from "../../core/src/function-builders"; | 4 | import { organizationMutation, organizationQuery } from "../../core/src/function-builders"; |
| 5 | import { upsertOrganizationMembership } from "../src/auth/memberships"; | ||
| 6 | import { safeRunAfter } from "../src/lib/scheduler"; | ||
| 5 | 7 | ||
| 6 | const organizationRoleValidator = v.union( | 8 | const 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 | }); |
| @@ -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 ID | 199 | 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 ID | 211 | 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 a | 481 | // Used to map an unauthenticated/anonymous actor to a backing `accounts` row and a |
| @@ -0,0 +1,31 @@ | |||
| 1 | import { actorIdForAccount } from "../../../core/src/identity"; | ||
| 2 | |||
| 3 | type WorkspaceAccessIdentity = { | ||
| 4 | actorId?: string; | ||
| 5 | accountId: string; | ||
| 6 | provider: string; | ||
| 7 | providerAccountId: string; | ||
| 8 | }; | ||
| 9 | |||
| 10 | export 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 | |||
| 23 | export 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 | } | ||
| @@ -3,12 +3,12 @@ import { upsertWorkosAccount } from "./accounts"; · 2 unmodified lines | |||
| 3 | import { getOrganizationByWorkosOrgId } from "./db_queries"; | 3 | import { getOrganizationByWorkosOrgId } from "./db_queries"; |
| 4 | import { getAuthKitUserProfile, resolveIdentityProfile } from "./identity"; | 4 | import { getAuthKitUserProfile, resolveIdentityProfile } from "./identity"; |
| 5 | import { | 5 | import { |
| 6 | ensureWorkspaceMembership, | ||
| 7 | mapOrganizationRoleToWorkspaceRole, | 6 | mapOrganizationRoleToWorkspaceRole, |
| 8 | markPendingInvitesAcceptedByEmail, | 7 | markPendingInvitesAcceptedByEmail, |
| 9 | upsertOrganizationMembership, | 8 | upsertOrganizationMembership, |
| 10 | } from "./memberships"; | 9 | } from "./memberships"; |
| 11 | import { ensurePersonalWorkspace, refreshGeneratedPersonalWorkspaceNames } from "./personal_workspace"; | 10 | import { ensurePersonalWorkspace, refreshGeneratedPersonalWorkspaceNames } from "./personal_workspace"; |
| 11 | import { projectAccountOrganizationMembershipsToWorkspaceMembers } from "./workspace_membership_projection"; | ||
| 12 | import type { AccountId } from "./types"; | 12 | import type { AccountId } from "./types"; |
| 13 | 13 | ||
| 14 | async function seedHintedOrganizationMembership( | 14 | async function seedHintedOrganizationMembership( |
| @@ -51,31 +51,6 @@ async function seedHintedOrganizationMembership( · 36 unmodified lines | |||
| 51 | }); | 51 | }); |
| 52 | } | 52 | } |
| 53 | 53 | ||
| 54 | async 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 | |||
| 79 | async function ensureAtLeastOneWorkspaceMembership(ctx: MutationCtx, args: { accountId: AccountId }) { | 54 | async function ensureAtLeastOneWorkspaceMembership(ctx: MutationCtx, args: { accountId: AccountId }) { |
| 80 | const activeWorkspaceMembership = await ctx.db | 55 | 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 | ||
| 64 | async 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 | |||
| 89 | export async function bootstrapCurrentWorkosAccountImpl(ctx: MutationCtx) { | 76 | export 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, { |
| @@ -10,11 +10,12 @@ import { · 9 unmodified lines | |||
| 10 | } from "./db_queries"; | 10 | } from "./db_queries"; |
| 11 | import { | 11 | import { |
| 12 | deriveOrganizationMembershipState, | 12 | deriveOrganizationMembershipState, |
| 13 | deriveWorkspaceMembershipRole, | 13 | mapOrganizationRoleToWorkspaceRole, |
| 14 | markPendingInvitesAcceptedByEmail, | 14 | markPendingInvitesAcceptedByEmail, |
| 15 | upsertOrganizationMembership, | 15 | upsertOrganizationMembership, |
| 16 | } from "./memberships"; | 16 | } from "./memberships"; |
| 17 | import { ensureUniqueOrganizationSlug } from "./naming"; | 17 | import { ensureUniqueOrganizationSlug } from "./naming"; |
| 18 | import { seedWorkspaceMembersFromOrganization } from "./workspace_membership_projection"; | ||
| 18 | 19 | ||
| 19 | type WorkosMembershipEventData = { | 20 | type 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 | ||
| 29 | async function resolveMembershipRecord( | 30 | function deriveOrganizationRoleFromWorkosSlug(workosRoleSlug?: string): Doc<"organizationMembers">["role"] { |
| 31 | return workosRoleSlug === "admin" ? "admin" : "member"; | ||
| 32 | } | ||
| 33 | |||
| 34 | async 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.db | 38 | 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.db | 47 | 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 | ||
| 78 | export const workosEventHandlers = { | 89 | export 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.db | 258 | 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.db | 313 | 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]>; |
| @@ -1,13 +1,12 @@ | |||
| 1 | import type { Doc } from "../../convex/_generated/dataModel.d.ts"; | ||
| 2 | import type { | 1 | import 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"; |
| 9 | import { projectOrganizationMembershipToWorkspaceMembers } from "./workspace_membership_projection"; | ||
| 11 | 10 | ||
| 12 | export function mapOrganizationRoleToWorkspaceRole(role: OrganizationRole): WorkspaceMemberRole { | 11 | export 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.db | 34 | 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 | |||
| 64 | export 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 | ||
| 127 | export function deriveWorkspaceMembershipRole(workosRoleSlug?: string): Doc<"workspaceMembers">["role"] { | ||
| 128 | return workosRoleSlug === "admin" ? "admin" : "member"; | ||
| 129 | } | ||
| 130 | |||
| 131 | export function deriveOrganizationMembershipState(status?: string): OrganizationMemberStatus { | 103 | export function deriveOrganizationMembershipState(status?: string): OrganizationMemberStatus { |
| 132 | return status === "active" ? "active" : "pending"; | 104 | return status === "active" ? "active" : "pending"; |
| 133 | } | 105 | } |
| @@ -0,0 +1,126 @@ | |||
| 1 | import type { Doc } from "../../convex/_generated/dataModel.d.ts"; | ||
| 2 | import type { AccountId, DbCtx, OrganizationId, WorkspaceMemberRole } from "./types"; | ||
| 3 | |||
| 4 | type WorkspaceMemberStatus = Doc<"workspaceMembers">["status"]; | ||
| 5 | |||
| 6 | type ProjectOrganizationMembershipArgs = { | ||
| 7 | organizationId: OrganizationId; | ||
| 8 | accountId: AccountId; | ||
| 9 | role: WorkspaceMemberRole; | ||
| 10 | status: WorkspaceMemberStatus; | ||
| 11 | now: number; | ||
| 12 | workosOrgMembershipId?: string; | ||
| 13 | }; | ||
| 14 | |||
| 15 | async 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 | |||
| 52 | export 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 | |||
| 73 | export 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 | |||
| 98 | export 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 | } | ||
| @@ -2,6 +2,7 @@ import type { StripeSubscriptions } from "@convex-dev/stripe"; · 1 unmodified line | |||
| 2 | import type { Id } from "../../convex/_generated/dataModel.d.ts"; | 2 | import type { Id } from "../../convex/_generated/dataModel.d.ts"; |
| 3 | import type { ActionCtx, MutationCtx, QueryCtx } from "../../convex/_generated/server"; | 3 | import type { ActionCtx, MutationCtx, QueryCtx } from "../../convex/_generated/server"; |
| 4 | import { canManageBilling, isAdminRole } from "../../../core/src/identity"; | 4 | import { canManageBilling, isAdminRole } from "../../../core/src/identity"; |
| 5 | import { safeRunAfter } from "../lib/scheduler"; | ||
| 5 | 6 | ||
| 6 | type Internal = typeof import("../../convex/_generated/api").internal; | 7 | type Internal = typeof import("../../convex/_generated/api").internal; |
| 7 | type Components = typeof import("../../convex/_generated/api").components; | 8 | type 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 | }); |
| @@ -1,6 +1,10 @@ | |||
| 1 | import { WorkOS } from "@workos-inc/node"; | 1 | import { WorkOS } from "@workos-inc/node"; |
| 2 | import type { Id } from "../../convex/_generated/dataModel.d.ts"; | 2 | import type { Id } from "../../convex/_generated/dataModel.d.ts"; |
| 3 | import type { ActionCtx } from "../../convex/_generated/server"; | 3 | import type { ActionCtx } from "../../convex/_generated/server"; |
| 4 | import { | ||
| 5 | assertMatchesCanonicalActorId, | ||
| 6 | canonicalActorIdForWorkspaceAccess, | ||
| 7 | } from "../auth/actor_identity"; | ||
| 4 | import { asRecord } from "../lib/object"; | 8 | import { asRecord } from "../lib/object"; |
| 5 | 9 | ||
| 6 | type Internal = typeof import("../../convex/_generated/api").internal; | 10 | type 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.providerAccountId | 178 | 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); |
| @@ -2,9 +2,7 @@ import type { Doc } from "../../convex/_generated/dataModel.d.ts"; · 1 unmodified line | |||
| 2 | import type { MutationCtx } from "../../convex/_generated/server"; | 2 | import type { MutationCtx } from "../../convex/_generated/server"; |
| 3 | import { slugify } from "../../../core/src/identity"; | 3 | import { slugify } from "../../../core/src/identity"; |
| 4 | import { ensureUniqueSlug } from "../../../core/src/slug"; | 4 | import { ensureUniqueSlug } from "../../../core/src/slug"; |
| 5 | 5 | import { upsertOrganizationMembership } from "../auth/memberships"; | |
| 6 | type OrganizationRole = "owner" | "admin" | "member" | "billing_admin"; | ||
| 7 | type OrganizationMemberStatus = "active" | "pending" | "removed"; | ||
| 8 | 6 | ||
| 9 | async function ensureUniqueOrganizationSlug(ctx: Pick<MutationCtx, "db">, baseName: string): Promise<string> { | 7 | async 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 | ||
| 20 | async 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 | ||
| 59 | export async function ensureAnonymousIdentity( | 19 | export 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.db | 96 | 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", { |
| @@ -0,0 +1,179 @@ | |||
| 1 | import type { OpenApiAuth } from "../../../core/src/tool/source-types"; | ||
| 2 | import type { ToolApprovalMode } from "../../../core/src/types"; | ||
| 3 | import { asRecord } from "../lib/object"; | ||
| 4 | |||
| 5 | type ToolSourceType = "mcp" | "openapi" | "graphql"; | ||
| 6 | |||
| 7 | function 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 | |||
| 13 | function 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 | |||
| 21 | function 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 | |||
| 41 | function 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 | |||
| 49 | function 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 | |||
| 68 | function 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 | |||
| 76 | function 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 | |||
| 119 | function 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 | |||
| 135 | export 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 | } | ||
| @@ -3,6 +3,10 @@ import type { ActionCtx, MutationCtx } from "../../convex/_generated/server"; · 2 unmodified lines | |||
| 3 | import { actorIdForAccount } from "../../../core/src/identity"; | 3 | import { actorIdForAccount } from "../../../core/src/identity"; |
| 4 | import { defaultRuntimeId, isKnownRuntimeId, isRuntimeEnabled } from "../../../core/src/runtimes/runtime-catalog"; | 4 | import { defaultRuntimeId, isKnownRuntimeId, isRuntimeEnabled } from "../../../core/src/runtimes/runtime-catalog"; |
| 5 | import type { ApprovalRecord, TaskExecutionOutcome, TaskRecord } from "../../../core/src/types"; | 5 | import type { ApprovalRecord, TaskExecutionOutcome, TaskRecord } from "../../../core/src/types"; |
| 6 | import { | ||
| 7 | assertMatchesCanonicalActorId, | ||
| 8 | canonicalActorIdForWorkspaceAccess, | ||
| 9 | } from "../auth/actor_identity"; | ||
| 6 | import { DEFAULT_TASK_TIMEOUT_MS } from "../task/constants"; | 10 | import { DEFAULT_TASK_TIMEOUT_MS } from "../task/constants"; |
| 7 | import { createTaskEvent } from "../task/events"; | 11 | import { createTaskEvent } from "../task/events"; |
| 8 | import { markTaskFinished } from "../task/finish"; | 12 | import { 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, |
| @@ -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 | ||
| 5 | function 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 | |||
| 5 | export function isSchedulerDisabled(): boolean { | 18 | export function isSchedulerDisabled(): boolean { |
| 6 | return process.env.DISABLE_CONVEX_SCHEDULER === "1"; | 19 | return process.env.DISABLE_CONVEX_SCHEDULER === "1" || isTestProcess(); |
| 7 | } | 20 | } |
| 8 | 21 | ||
| 9 | export async function safeRunAfter( | 22 | export async function safeRunAfter( |
| @@ -2,6 +2,11 @@ import type { Doc, Id } from "../../convex/_generated/dataModel.d.ts"; · 1 unmodified line | |||
| 2 | import type { MutationCtx, QueryCtx } from "../../convex/_generated/server"; | 2 | import type { MutationCtx, QueryCtx } from "../../convex/_generated/server"; |
| 3 | import { getOrganizationMembership, slugify } from "../../../core/src/identity"; | 3 | import { getOrganizationMembership, slugify } from "../../../core/src/identity"; |
| 4 | import { ensureUniqueSlug } from "../../../core/src/slug"; | 4 | import { ensureUniqueSlug } from "../../../core/src/slug"; |
| 5 | import { | ||
| 6 | mapOrganizationRoleToWorkspaceRole, | ||
| 7 | upsertOrganizationMembership, | ||
| 8 | } from "../auth/memberships"; | ||
| 9 | import { seedWorkspaceMembersFromOrganization } from "../auth/workspace_membership_projection"; | ||
| 5 | 10 | ||
| 6 | type WorkspaceSummary = { | 11 | type 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) { |
| @@ -3,7 +3,10 @@ · 2 unmodified lines | |||
| 3 | import type { ActionCtx } from "../../convex/_generated/server"; | 3 | import type { ActionCtx } from "../../convex/_generated/server"; |
| 4 | import type { Id } from "../../convex/_generated/dataModel.d.ts"; | 4 | import type { Id } from "../../convex/_generated/dataModel.d.ts"; |
| 5 | import { internal } from "../../convex/_generated/api"; | 5 | import { internal } from "../../convex/_generated/api"; |
| 6 | import { actorIdForAccount } from "../../../core/src/identity"; | 6 | import { |
| 7 | assertMatchesCanonicalActorId, | ||
| 8 | canonicalActorIdForWorkspaceAccess, | ||
| 9 | } from "../auth/actor_identity"; | ||
| 7 | 10 | ||
| 8 | export async function requireCanonicalActor( | 11 | export 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 | } |
| @@ -6,9 +6,9 @@ import { parseGraphqlOperationPaths } from "../../../core/src/graphql/operation- · 5 unmodified lines | |||
| 6 | import type { AccessPolicyRecord, PolicyDecision, TaskRecord, ToolDefinition } from "../../../core/src/types"; | 6 | import type { AccessPolicyRecord, PolicyDecision, TaskRecord, ToolDefinition } from "../../../core/src/types"; |
| 7 | import { rehydrateTools, type SerializedTool } from "../../../core/src/tool/source-serialization"; | 7 | import { rehydrateTools, type SerializedTool } from "../../../core/src/tool/source-serialization"; |
| 8 | import { getDecisionForContext, getToolDecision } from "./policy"; | 8 | import { getDecisionForContext, getToolDecision } from "./policy"; |
| 9 | import { getReadyRegistryBuildId } from "./tool_registry_state"; | ||
| 9 | import { normalizeToolPathForLookup, toPreferredToolPath } from "./tool_paths"; | 10 | import { normalizeToolPathForLookup, toPreferredToolPath } from "./tool_paths"; |
| 10 | import { baseTools } from "./workspace_tools"; | 11 | import { baseTools } from "./workspace_tools"; |
| 11 | import { sourceSignature } from "./tool_source_loading"; | ||
| 12 | 12 | ||
| 13 | export function getGraphqlDecision( | 13 | export 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 | ||
| 69 | async 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 | |||
| 92 | async function suggestFromRegistry( | 69 | async 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, { |
| @@ -21,7 +21,7 @@ import { publishTaskEvent } from "./events"; · 20 unmodified lines | |||
| 21 | import { completeToolCall, denyToolCall, failToolCall } from "./tool_call_lifecycle"; | 21 | import { completeToolCall, denyToolCall, failToolCall } from "./tool_call_lifecycle"; |
| 22 | import { assertPersistedCallRunnable, resolveCredentialHeaders } from "./tool_call_credentials"; | 22 | import { assertPersistedCallRunnable, resolveCredentialHeaders } from "./tool_call_credentials"; |
| 23 | import { getGraphqlDecision, resolveToolForCall } from "./tool_call_resolution"; | 23 | import { getGraphqlDecision, resolveToolForCall } from "./tool_call_resolution"; |
| 24 | import { sourceSignature } from "./tool_source_loading"; | 24 | import { getReadyRegistryBuildId } from "./tool_registry_state"; |
| 25 | 25 | ||
| 26 | function createApprovalId(): string { | 26 | function 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 } |
| @@ -0,0 +1,77 @@ | |||
| 1 | "use node"; | ||
| 2 | |||
| 3 | import type { ActionCtx } from "../../convex/_generated/server"; | ||
| 4 | import type { Id } from "../../convex/_generated/dataModel.d.ts"; | ||
| 5 | import { internal } from "../../convex/_generated/api"; | ||
| 6 | import { sourceSignature } from "./tool_source_loading"; | ||
| 7 | |||
| 8 | export const TOOL_REGISTRY_SIGNATURE_PREFIX = "toolreg_v2|"; | ||
| 9 | |||
| 10 | export 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 | |||
| 18 | type RegistryState = { | ||
| 19 | signature: string; | ||
| 20 | readyBuildId?: string; | ||
| 21 | } | null; | ||
| 22 | |||
| 23 | type ToolSourceState = { | ||
| 24 | id: string; | ||
| 25 | updatedAt: number; | ||
| 26 | enabled: boolean; | ||
| 27 | }; | ||
| 28 | |||
| 29 | async 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 | |||
| 47 | export 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 | } | ||
| @@ -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"; |
| 22 | import type { ToolSourceRecord } from "../../../core/src/types"; | 22 | import type { ToolSourceRecord } from "../../../core/src/types"; |
| 23 | import { normalizeToolSourceConfig } from "../database/tool_source_config"; | ||
| 23 | import { asPayload } from "../lib/object"; | 24 | import { asPayload } from "../lib/object"; |
| 24 | 25 | ||
| 25 | const OPENAPI_SPEC_CACHE_TTL_MS = 5 * 60 * 60_000; | 26 | const 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"], |
| @@ -23,6 +23,7 @@ import type { · 22 unmodified lines | |||
| 23 | } from "../../../core/src/types"; | 23 | } from "../../../core/src/types"; |
| 24 | import { computeOpenApiSourceQuality, listVisibleToolDescriptors } from "./tool_descriptors"; | 24 | import { computeOpenApiSourceQuality, listVisibleToolDescriptors } from "./tool_descriptors"; |
| 25 | import { loadSourceArtifact, normalizeExternalToolSource, sourceSignature } from "./tool_source_loading"; | 25 | import { loadSourceArtifact, normalizeExternalToolSource, sourceSignature } from "./tool_source_loading"; |
| 26 | import { registrySignatureForWorkspace } from "./tool_registry_state"; | ||
| 26 | import { normalizeToolPathForLookup } from "./tool_paths"; | 27 | import { normalizeToolPathForLookup } from "./tool_paths"; |
| 27 | 28 | ||
| 28 | const baseTools = new Map<string, ToolDefinition>(); | 29 | const 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, |
| @@ -2,6 +2,11 @@ import type { Doc, Id } from "../../convex/_generated/dataModel.d.ts"; · 1 unmodified line | |||
| 2 | import type { MutationCtx, QueryCtx } from "../../convex/_generated/server"; | 2 | import type { MutationCtx, QueryCtx } from "../../convex/_generated/server"; |
| 3 | import { getOrganizationMembership, slugify } from "../../../core/src/identity"; | 3 | import { getOrganizationMembership, slugify } from "../../../core/src/identity"; |
| 4 | import { ensureUniqueSlug } from "../../../core/src/slug"; | 4 | import { ensureUniqueSlug } from "../../../core/src/slug"; |
| 5 | import { | ||
| 6 | mapOrganizationRoleToWorkspaceRole, | ||
| 7 | upsertOrganizationMembership, | ||
| 8 | } from "../auth/memberships"; | ||
| 9 | import { seedWorkspaceMembersFromOrganization } from "../auth/workspace_membership_projection"; | ||
| 5 | 10 | ||
| 6 | type WorkspaceResult = { | 11 | type 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); |
| @@ -0,0 +1,92 @@ | |||
| 1 | #!/usr/bin/env bun | ||
| 2 | |||
| 3 | export {}; | ||
| 4 | |||
| 5 | const INTEGRATION_TESTS = new Set([ | ||
| 6 | "packages/core/src/openapi/real-specs.test.ts", | ||
| 7 | ]); | ||
| 8 | |||
| 9 | type SuiteMode = "fast" | "integration" | "e2e" | "all"; | ||
| 10 | |||
| 11 | function normalizeMode(raw: string | undefined): SuiteMode { | ||
| 12 | if (raw === "integration" || raw === "e2e" || raw === "all") { | ||
| 13 | return raw; | ||
| 14 | } | ||
| 15 | return "fast"; | ||
| 16 | } | ||
| 17 | |||
| 18 | function isE2eTest(filePath: string): boolean { | ||
| 19 | return filePath.endsWith(".e2e.test.ts"); | ||
| 20 | } | ||
| 21 | |||
| 22 | async 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 | |||
| 36 | function 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 | |||
| 52 | async 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 | |||
| 92 | await main(); | ||
| @@ -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", |