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 | 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 | 37 | - `bun run typecheck` - runs executor + assistant TypeScript checks. |
| 38 | 38 | - `bun run knip` - dead-code/unused-export scan. |
| 39 | 39 | |
| 40 | 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 | 46 | - `bun run test:assistant` - assistant tests (`assistant/packages/core/src`). |
| 44 | 47 | - `bun run typecheck:executor` - `tsc --noEmit -p executor/tsconfig.json`. |
| 45 | 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 | 139 | - Use `bun:test` (`import { test, expect } from "bun:test"`). |
| 137 | 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 | 142 | - When changing Convex auth/access logic, add/adjust permission tests in `executor/packages/convex/access-controls.test.ts`. |
| 139 | 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 | 153 | ## Generated/Build Artifacts |
| 142 | 154 | |
| 143 | 155 | - Do not manually edit generated/build outputs such as: |
| @@ -26,6 +26,10 @@ · 25 unmodified lines | ||
| 26 | 26 | "web": "bun run executor.ts web", |
| 27 | 27 | "deploy": "bunx convex deploy", |
| 28 | 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 | 33 | "test:e2e:install": "bun test scripts/e2e/install.e2e.test.ts", |
| 30 | 34 | "test:e2e:install:sandbox": "bun test scripts/e2e/install.sandbox.e2e.test.ts", |
| 31 | 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 | 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 | 10 | export type WorkspaceAccess = { |
| 23 | 11 | account: Doc<"accounts">; |
| 24 | 12 | workspace: Doc<"workspaces">; |
| @@ -80,10 +68,22 @@ export async function getOrganizationMembership( · 55 unmodified lines | ||
| 80 | 68 | organizationId: Id<"organizations">, |
| 81 | 69 | accountId: Id<"accounts">, |
| 82 | 70 | ) { |
| 83 | − return await ctx.db | |
| 71 | + const memberships = await ctx.db | |
| 84 | 72 | .query("organizationMembers") |
| 85 | 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 | 89 | export async function getWorkspaceMembership( |
| @@ -91,10 +91,22 @@ export async function getWorkspaceMembership( · 1 unmodified line | ||
| 91 | 91 | workspaceId: Id<"workspaces">, |
| 92 | 92 | accountId: Id<"accounts">, |
| 93 | 93 | ) { |
| 94 | − return await ctx.db | |
| 94 | + const memberships = await ctx.db | |
| 95 | 95 | .query("workspaceMembers") |
| 96 | 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 | 112 | export async function requireWorkspaceAccessForAccount( |
| @@ -108,32 +120,14 @@ export async function requireWorkspaceAccessForAccount( · 7 unmodified lines | ||
| 108 | 120 | } |
| 109 | 121 | |
| 110 | 122 | const workspaceMembership = await getWorkspaceMembership(ctx, workspace._id, account._id); |
| 111 | − if (workspaceMembership) { | |
| 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") { | |
| 123 | + if (!workspaceMembership || workspaceMembership.status !== "active") { | |
| 125 | 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 | 127 | return { |
| 134 | 128 | account, |
| 135 | 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 | 4 | let fakeHostServer: ReturnType<typeof Bun.serve>; |
| 5 | 5 | let fakeCallbackServer: ReturnType<typeof Bun.serve>; |
| 6 | +let previousConvexUrl: string | undefined; | |
| 6 | 7 | |
| 7 | 8 | const AUTH_TOKEN = "test-sandbox-token"; |
| 8 | 9 | const CALLBACK_TOKEN = "test-callback-token"; |
| @@ -19,6 +20,8 @@ type HostRequestBody = { · 10 unmodified lines | ||
| 19 | 20 | let lastHostRequestBody: HostRequestBody | null = null; |
| 20 | 21 | |
| 21 | 22 | beforeAll(() => { |
| 23 | + previousConvexUrl = process.env.CONVEX_URL; | |
| 24 | + | |
| 22 | 25 | fakeCallbackServer = Bun.serve({ |
| 23 | 26 | port: 0, |
| 24 | 27 | fetch: () => Response.json({ ok: true }), |
| @@ -44,6 +47,7 @@ beforeAll(() => { · 19 unmodified lines | ||
| 44 | 47 | |
| 45 | 48 | process.env.CLOUDFLARE_SANDBOX_RUN_URL = `http://127.0.0.1:${fakeHostServer.port}/v1/runs`; |
| 46 | 49 | process.env.CLOUDFLARE_SANDBOX_AUTH_TOKEN = AUTH_TOKEN; |
| 50 | + delete process.env.CONVEX_URL; | |
| 47 | 51 | process.env.CONVEX_SITE_URL = `http://127.0.0.1:${fakeCallbackServer.port}`; |
| 48 | 52 | process.env.EXECUTOR_INTERNAL_TOKEN = CALLBACK_TOKEN; |
| 49 | 53 | process.env.CLOUDFLARE_SANDBOX_REQUEST_TIMEOUT_MS = "10000"; |
| @@ -54,6 +58,11 @@ afterAll(() => { · 4 unmodified lines | ||
| 54 | 58 | fakeCallbackServer?.stop(true); |
| 55 | 59 | delete process.env.CLOUDFLARE_SANDBOX_RUN_URL; |
| 56 | 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 | 66 | delete process.env.CONVEX_SITE_URL; |
| 58 | 67 | delete process.env.EXECUTOR_INTERNAL_TOKEN; |
| 59 | 68 | delete process.env.CLOUDFLARE_SANDBOX_REQUEST_TIMEOUT_MS; |
| @@ -1,4 +1,3 @@ | ||
| 1 | −import { Result } from "better-result"; | |
| 2 | 1 | import { connectMcp, extractMcpResult } from "../mcp-runtime"; |
| 3 | 2 | import { executePostmanRequest, type PostmanSerializedRunSpec } from "../postman-runtime"; |
| 4 | 3 | import { normalizeGraphqlFieldVariables, selectGraphqlFieldEnvelope } from "../graphql/field-tools"; |
| @@ -129,7 +128,11 @@ export function rehydrateTools( · 124 unmodified lines | ||
| 129 | 128 | return { |
| 130 | 129 | ...base, |
| 131 | 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 | 196 | throw new Error("GraphQL query string is required"); |
| 194 | 197 | } |
| 195 | 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 | 227 | context.credential?.headers, |
| 221 | 228 | ); |
| 222 | 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 | 117 | const now = Date.now(); |
| 118 | 118 | return await t.run(async (ctx) => { |
| 119 | − return await ctx.db.insert("organizationMembers", { | |
| 119 | + const membershipId = await ctx.db.insert("organizationMembers", { | |
| 120 | 120 | organizationId: opts.organizationId, |
| 121 | 121 | accountId: opts.accountId, |
| 122 | 122 | role: opts.role, |
| @@ -126,6 +126,38 @@ async function addOrgMember( · 3 unmodified lines | ||
| 126 | 126 | createdAt: now, |
| 127 | 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 | 176 | const now = Date.now(); |
| 145 | 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 | 192 | return await ctx.db.insert("workspaceMembers", { |
| 147 | 193 | workspaceId: opts.workspaceId, |
| 148 | 194 | accountId: opts.accountId, |
| @@ -262,7 +308,7 @@ describe("workspace access controls", () => { · 113 unmodified lines | ||
| 262 | 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 | 312 | const t = setup(); |
| 267 | 313 | const owner = await seedUser(t, { subject: "ws-owner-org-fallback" }); |
| 268 | 314 | const member = await seedUser(t, { subject: "ws-member-org-fallback" }); |
| @@ -115,7 +115,7 @@ export const ensureAnonymousMcpSession = internalMutation({ · 114 unmodified lines | ||
| 115 | 115 | const membership = await ctx.db |
| 116 | 116 | .query("workspaceMembers") |
| 117 | 117 | .withIndex("by_workspace_account", (q) => q.eq("workspaceId", args.workspaceId).eq("accountId", account._id)) |
| 118 | − .unique(); | |
| 118 | + .first(); | |
| 119 | 119 | if (!membership || membership.status !== "active") { |
| 120 | 120 | throw new Error("Anonymous actor does not have workspace access"); |
| 121 | 121 | } |
| @@ -5,8 +5,8 @@ import { · 4 unmodified lines | ||
| 5 | 5 | mapSource, |
| 6 | 6 | normalizeSourceAuthFingerprint, |
| 7 | 7 | } from "../../src/database/mappers"; |
| 8 | +import { normalizeToolSourceConfig } from "../../src/database/tool_source_config"; | |
| 8 | 9 | import { toolSourceTypeValidator } from "../../src/database/validators"; |
| 9 | −import { asRecord } from "../../src/lib/object"; | |
| 10 | 10 | |
| 11 | 11 | export const upsertToolSource = internalMutation({ |
| 12 | 12 | args: { |
| @@ -20,7 +20,7 @@ export const upsertToolSource = internalMutation({ · 7 unmodified lines | ||
| 20 | 20 | handler: async (ctx, args) => { |
| 21 | 21 | const now = Date.now(); |
| 22 | 22 | const sourceId = args.id ?? `src_${crypto.randomUUID()}`; |
| 23 | − const config = asRecord(args.config); | |
| 23 | + const config = normalizeToolSourceConfig(args.type, args.config); | |
| 24 | 24 | const specHash = computeSourceSpecHash(args.type, config); |
| 25 | 25 | const authFingerprint = normalizeSourceAuthFingerprint(config.auth); |
| 26 | 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 | 2 | import { convexTest } from "convex-test"; |
| 3 | 3 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; |
| 4 | 4 | import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; |
| @@ -6,6 +6,30 @@ import { internal } from "./_generated/api"; · 1 unmodified line | ||
| 6 | 6 | import type { Id } from "./_generated/dataModel.d.ts"; |
| 7 | 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 | 33 | function setup() { |
| 10 | 34 | return convexTest(schema, { |
| 11 | 35 | "./database.ts": () => import("./database"), |
| @@ -13,6 +13,7 @@ import { · 12 unmodified lines | ||
| 13 | 13 | workosEnabled, |
| 14 | 14 | } from "../src/invites/workos"; |
| 15 | 15 | import { normalizePersonalOrganizationName } from "../src/invites/normalize"; |
| 16 | +import { safeRunAfter } from "../src/lib/scheduler"; | |
| 16 | 17 | |
| 17 | 18 | const organizationRoleValidator = v.union( |
| 18 | 19 | v.literal("owner"), |
| @@ -99,7 +100,7 @@ export const create = organizationMutation({ · 80 unmodified lines | ||
| 99 | 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 | 104 | inviteId, |
| 104 | 105 | inviterWorkosUserId, |
| 105 | 106 | expiresInDays: args.expiresInDays, |
| @@ -216,7 +217,7 @@ export const revoke = organizationMutation({ · 110 unmodified lines | ||
| 216 | 217 | }); |
| 217 | 218 | |
| 218 | 219 | if (invite.providerInviteId) { |
| 219 | − await ctx.scheduler.runAfter(0, internal.invites.revokeWorkosInvite, { | |
| 220 | + await safeRunAfter(ctx.scheduler, 0, internal.invites.revokeWorkosInvite, { | |
| 220 | 221 | inviteId: invite._id as Id<"invites">, |
| 221 | 222 | providerInviteId: String(invite.providerInviteId), |
| 222 | 223 | }); |
| @@ -2,6 +2,8 @@ import { v } from "convex/values"; · 1 unmodified line | ||
| 2 | 2 | import { internal } from "./_generated/api"; |
| 3 | 3 | import { getOrganizationMembership } from "../../core/src/identity"; |
| 4 | 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 | 8 | const organizationRoleValidator = v.union( |
| 7 | 9 | v.literal("owner"), |
| @@ -52,9 +54,15 @@ export const updateRole = organizationMutation({ · 44 unmodified lines | ||
| 52 | 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 | 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 | 68 | return { ok: true }; |
| @@ -81,7 +89,7 @@ export const updateBillable = organizationMutation({ · 20 unmodified lines | ||
| 81 | 89 | const nextVersion = await ctx.runMutation(internal.billingInternal.bumpSeatSyncVersion, { |
| 82 | 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 | 93 | organizationId: ctx.organizationId, |
| 86 | 94 | expectedVersion: nextVersion, |
| 87 | 95 | }); |
| @@ -101,15 +109,21 @@ export const remove = organizationMutation({ · 13 unmodified lines | ||
| 101 | 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 | 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 | 123 | const nextVersion = await ctx.runMutation(internal.billingInternal.bumpSeatSyncVersion, { |
| 110 | 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 | 127 | organizationId: ctx.organizationId, |
| 114 | 128 | expectedVersion: nextVersion, |
| 115 | 129 | }); |
| @@ -194,21 +194,18 @@ export default defineSchema({ · 193 unmodified lines | ||
| 194 | 194 | // |
| 195 | 195 | // Primary access patterns: |
| 196 | 196 | // - Resolve by organization. |
| 197 | − // - (Potential/expected) resolve by Stripe customer id in webhook reconciliation. | |
| 198 | 197 | billingCustomers: defineTable({ |
| 199 | 198 | organizationId: v.id("organizations"), |
| 200 | 199 | stripeCustomerId: v.string(), // external Stripe customer ID |
| 201 | 200 | createdAt: v.number(), |
| 202 | 201 | updatedAt: v.number(), |
| 203 | 202 | }) |
| 204 | − .index("by_org", ["organizationId"]) | |
| 205 | − .index("by_stripe_customer_id", ["stripeCustomerId"]), | |
| 203 | + .index("by_org", ["organizationId"]), | |
| 206 | 204 | |
| 207 | 205 | // Stripe subscription state for an organization. |
| 208 | 206 | // |
| 209 | 207 | // Primary access patterns: |
| 210 | 208 | // - List subscriptions for an org. |
| 211 | − // - (Potential/expected) resolve by subscription id and filter by status. | |
| 212 | 209 | billingSubscriptions: defineTable({ |
| 213 | 210 | organizationId: v.id("organizations"), |
| 214 | 211 | stripeSubscriptionId: v.string(), // external Stripe subscription ID |
| @@ -221,9 +218,7 @@ export default defineSchema({ · 6 unmodified lines | ||
| 221 | 218 | createdAt: v.number(), |
| 222 | 219 | updatedAt: v.number(), |
| 223 | 220 | }) |
| 224 | − .index("by_org", ["organizationId"]) | |
| 225 | − .index("by_org_status", ["organizationId", "status"]) | |
| 226 | − .index("by_stripe_subscription_id", ["stripeSubscriptionId"]), | |
| 221 | + .index("by_org", ["organizationId"]), | |
| 227 | 222 | |
| 228 | 223 | // Seat syncing bookkeeping (eg Stripe per-seat quantity). |
| 229 | 224 | // Stored separately from subscription records so sync logic can be retried/idempotent. |
| @@ -294,7 +289,6 @@ export default defineSchema({ · 64 unmodified lines | ||
| 294 | 289 | // Primary access patterns: |
| 295 | 290 | // - Get a specific call by (taskId, callId). |
| 296 | 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 | 292 | toolCalls: defineTable({ |
| 299 | 293 | taskId: v.string(), |
| 300 | 294 | callId: v.string(), |
| @@ -309,8 +303,7 @@ export default defineSchema({ · 8 unmodified lines | ||
| 309 | 303 | }) |
| 310 | 304 | .index("by_task_call", ["taskId", "callId"]) |
| 311 | 305 | .index("by_task_created", ["taskId", "createdAt"]) |
| 312 | − .index("by_workspace_created", ["workspaceId", "createdAt"]) | |
| 313 | − .index("by_approval_id", ["approvalId"]), | |
| 306 | + .index("by_workspace_created", ["workspaceId", "createdAt"]), | |
| 314 | 307 | |
| 315 | 308 | // Append-only event log for a task. |
| 316 | 309 | // `sequence` is monotonically increasing per task (used for ordered replay). |
| @@ -482,8 +475,7 @@ export default defineSchema({ · 165 unmodified lines | ||
| 482 | 475 | samplePaths: v.array(v.string()), |
| 483 | 476 | createdAt: v.number(), |
| 484 | 477 | }) |
| 485 | − .index("by_workspace_build", ["workspaceId", "buildId"]) | |
| 486 | − .index("by_workspace_build_namespace", ["workspaceId", "buildId", "namespace"]), | |
| 478 | + .index("by_workspace_build", ["workspaceId", "buildId"]), | |
| 487 | 479 | |
| 488 | 480 | // Anonymous session linkage. |
| 489 | 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 | 3 | import { getOrganizationByWorkosOrgId } from "./db_queries"; |
| 4 | 4 | import { getAuthKitUserProfile, resolveIdentityProfile } from "./identity"; |
| 5 | 5 | import { |
| 6 | − ensureWorkspaceMembership, | |
| 7 | 6 | mapOrganizationRoleToWorkspaceRole, |
| 8 | 7 | markPendingInvitesAcceptedByEmail, |
| 9 | 8 | upsertOrganizationMembership, |
| 10 | 9 | } from "./memberships"; |
| 11 | 10 | import { ensurePersonalWorkspace, refreshGeneratedPersonalWorkspaceNames } from "./personal_workspace"; |
| 11 | +import { projectAccountOrganizationMembershipsToWorkspaceMembers } from "./workspace_membership_projection"; | |
| 12 | 12 | import type { AccountId } from "./types"; |
| 13 | 13 | |
| 14 | 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 | 54 | async function ensureAtLeastOneWorkspaceMembership(ctx: MutationCtx, args: { accountId: AccountId }) { |
| 80 | 55 | const activeWorkspaceMembership = await ctx.db |
| 81 | 56 | .query("workspaceMembers") |
| @@ -86,6 +61,18 @@ async function ensureAtLeastOneWorkspaceMembership(ctx: MutationCtx, args: { acc · 4 unmodified lines | ||
| 86 | 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 | 76 | export async function bootstrapCurrentWorkosAccountImpl(ctx: MutationCtx) { |
| 90 | 77 | const identity = await ctx.auth.getUserIdentity(); |
| 91 | 78 | if (!identity) return null; |
| @@ -125,14 +112,16 @@ export async function bootstrapCurrentWorkosAccountImpl(ctx: MutationCtx) { · 33 unmodified lines | ||
| 125 | 112 | now, |
| 126 | 113 | }); |
| 127 | 114 | |
| 128 | − await syncWorkspaceMembershipsForOrganizations(ctx, { | |
| 115 | + let hasActiveWorkspaceMembership = await ensureAtLeastOneWorkspaceMembership(ctx, { | |
| 129 | 116 | accountId: account._id, |
| 130 | − now, | |
| 131 | 117 | }); |
| 132 | 118 | |
| 133 | − const hasActiveWorkspaceMembership = await ensureAtLeastOneWorkspaceMembership(ctx, { | |
| 134 | − accountId: account._id, | |
| 135 | − }); | |
| 119 | + if (!hasActiveWorkspaceMembership) { | |
| 120 | + hasActiveWorkspaceMembership = await backfillWorkspaceMembershipsIfMissing(ctx, { | |
| 121 | + accountId: account._id, | |
| 122 | + now, | |
| 123 | + }); | |
| 124 | + } | |
| 136 | 125 | |
| 137 | 126 | if (!hasActiveWorkspaceMembership) { |
| 138 | 127 | await ensurePersonalWorkspace(ctx, account._id, { |
| @@ -10,11 +10,12 @@ import { · 9 unmodified lines | ||
| 10 | 10 | } from "./db_queries"; |
| 11 | 11 | import { |
| 12 | 12 | deriveOrganizationMembershipState, |
| 13 | − deriveWorkspaceMembershipRole, | |
| 13 | + mapOrganizationRoleToWorkspaceRole, | |
| 14 | 14 | markPendingInvitesAcceptedByEmail, |
| 15 | 15 | upsertOrganizationMembership, |
| 16 | 16 | } from "./memberships"; |
| 17 | 17 | import { ensureUniqueOrganizationSlug } from "./naming"; |
| 18 | +import { seedWorkspaceMembersFromOrganization } from "./workspace_membership_projection"; | |
| 18 | 19 | |
| 19 | 20 | type WorkosMembershipEventData = { |
| 20 | 21 | id: string; |
| @@ -26,53 +27,63 @@ type WorkosMembershipEventData = { · 5 unmodified lines | ||
| 26 | 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 | 35 | ctx: Pick<MutationCtx, "db">, |
| 31 | 36 | data: WorkosMembershipEventData, |
| 32 | 37 | ) { |
| 33 | − let membership = await ctx.db | |
| 34 | − .query("workspaceMembers") | |
| 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; | |
| 38 | + const workosUserId = data.user_id ?? data.userId; | |
| 39 | + const workosOrgId = data.organization_id ?? data.organizationId; | |
| 40 | 40 | |
| 41 | − if (!membership) { | |
| 42 | − const workosUserId = data.user_id ?? data.userId; | |
| 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([ | |
| 41 | + if (workosUserId && workosOrgId) { | |
| 42 | + const [account, organization] = await Promise.all([ | |
| 49 | 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 | |
| 60 | − .query("workspaceMembers") | |
| 61 | − .withIndex("by_workspace_account", (q) => q.eq("workspaceId", workspaceId).eq("accountId", accountId)) | |
| 62 | − .unique(); | |
| 63 | − if (!membership) { | |
| 64 | − return { membership: null, account: null, workspace: null }; | |
| 47 | + if (account && organization) { | |
| 48 | + return { | |
| 49 | + account, | |
| 50 | + organization, | |
| 51 | + fallbackRole: undefined as Doc<"workspaceMembers">["role"] | undefined, | |
| 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 | 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 | 89 | export const workosEventHandlers = { |
| @@ -158,6 +169,13 @@ export const workosEventHandlers = { · 79 unmodified lines | ||
| 158 | 169 | workosOrgId: event.data.id, |
| 159 | 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 | 179 | return; |
| 162 | 180 | } |
| 163 | 181 | |
| @@ -167,6 +185,13 @@ export const workosEventHandlers = { · 3 unmodified lines | ||
| 167 | 185 | workosOrgId: event.data.id, |
| 168 | 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 | 195 | return; |
| 171 | 196 | } |
| 172 | 197 | |
| @@ -179,6 +204,16 @@ export const workosEventHandlers = { · 6 unmodified lines | ||
| 179 | 204 | createdAt: now, |
| 180 | 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 | 219 | "organization.updated": async (ctx, event) => { |
| @@ -217,119 +252,83 @@ export const workosEventHandlers = { · 32 unmodified lines | ||
| 217 | 252 | "organization_membership.created": async (ctx, event) => { |
| 218 | 253 | const now = Date.now(); |
| 219 | 254 | const data = event.data as WorkosMembershipEventData; |
| 220 | − const workosUserId = data.user_id ?? data.userId; | |
| 221 | − const workosOrgId = data.organization_id ?? data.organizationId; | |
| 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; | |
| 255 | + const { account, organization } = await resolveMembershipAccountAndOrganization(ctx, data); | |
| 256 | + if (!account || !organization) return; | |
| 229 | 257 | |
| 230 | − const existing = await ctx.db | |
| 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); | |
| 258 | + const role = deriveOrganizationRoleFromWorkosSlug(data.role?.slug); | |
| 236 | 259 | const status = deriveOrganizationMembershipState(data.status); |
| 237 | 260 | |
| 238 | 261 | await upsertOrganizationMembership(ctx, { |
| 239 | − organizationId: workspace.organizationId, | |
| 262 | + organizationId: organization._id, | |
| 240 | 263 | accountId: account._id, |
| 241 | 264 | role, |
| 242 | 265 | status, |
| 243 | 266 | billable: status === "active", |
| 267 | + workosOrgMembershipId: data.id, | |
| 244 | 268 | now, |
| 245 | 269 | }); |
| 246 | 270 | |
| 247 | 271 | if (status === "active") { |
| 248 | 272 | await markPendingInvitesAcceptedByEmail(ctx, { |
| 249 | − organizationId: workspace.organizationId, | |
| 273 | + organizationId: organization._id, | |
| 250 | 274 | email: account.email, |
| 251 | 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 | 280 | "organization_membership.updated": async (ctx, event) => { |
| 277 | 281 | const now = Date.now(); |
| 278 | 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 | 286 | return; |
| 283 | 287 | } |
| 284 | 288 | |
| 285 | − const role = deriveWorkspaceMembershipRole(data.role?.slug); | |
| 289 | + const role = deriveOrganizationRoleFromWorkosSlug(data.role?.slug); | |
| 286 | 290 | const status = deriveOrganizationMembershipState(data.status); |
| 287 | 291 | |
| 288 | 292 | await upsertOrganizationMembership(ctx, { |
| 289 | − organizationId: workspace.organizationId, | |
| 293 | + organizationId: organization._id, | |
| 290 | 294 | accountId: account._id, |
| 291 | 295 | role, |
| 292 | 296 | status, |
| 293 | 297 | billable: status === "active", |
| 298 | + workosOrgMembershipId: data.id, | |
| 294 | 299 | now, |
| 295 | 300 | }); |
| 296 | 301 | |
| 297 | 302 | if (status === "active") { |
| 298 | 303 | await markPendingInvitesAcceptedByEmail(ctx, { |
| 299 | − organizationId: workspace.organizationId, | |
| 304 | + organizationId: organization._id, | |
| 300 | 305 | email: account.email, |
| 301 | 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 | 311 | "organization_membership.deleted": async (ctx, event) => { |
| 314 | 312 | const now = Date.now(); |
| 315 | − const membership = await ctx.db | |
| 316 | − .query("workspaceMembers") | |
| 317 | − .withIndex("by_workos_membership_id", (q) => q.eq("workosOrgMembershipId", event.data.id)) | |
| 318 | − .unique(); | |
| 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 | − }); | |
| 313 | + const data = event.data as WorkosMembershipEventData; | |
| 314 | + const { account, organization, fallbackRole } = await resolveMembershipAccountAndOrganization(ctx, data); | |
| 315 | + if (!account || !organization) { | |
| 316 | + return; | |
| 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 | 334 | } satisfies Partial<Parameters<AuthKit<DataModel>["events"]>[0]>; |
| @@ -1,13 +1,12 @@ | ||
| 1 | −import type { Doc } from "../../convex/_generated/dataModel.d.ts"; | |
| 2 | 1 | import type { |
| 3 | 2 | AccountId, |
| 4 | 3 | DbCtx, |
| 5 | 4 | OrganizationId, |
| 6 | 5 | OrganizationMemberStatus, |
| 7 | 6 | OrganizationRole, |
| 8 | − WorkspaceId, | |
| 9 | 7 | WorkspaceMemberRole, |
| 10 | 8 | } from "./types"; |
| 9 | +import { projectOrganizationMembershipToWorkspaceMembers } from "./workspace_membership_projection"; | |
| 11 | 10 | |
| 12 | 11 | export function mapOrganizationRoleToWorkspaceRole(role: OrganizationRole): WorkspaceMemberRole { |
| 13 | 12 | if (role === "owner") { |
| @@ -28,13 +27,14 @@ export async function upsertOrganizationMembership( · 14 unmodified lines | ||
| 28 | 27 | status: OrganizationMemberStatus; |
| 29 | 28 | billable: boolean; |
| 30 | 29 | invitedByAccountId?: AccountId; |
| 30 | + workosOrgMembershipId?: string; | |
| 31 | 31 | now: number; |
| 32 | 32 | }, |
| 33 | 33 | ) { |
| 34 | 34 | const existing = await ctx.db |
| 35 | 35 | .query("organizationMembers") |
| 36 | 36 | .withIndex("by_org_account", (q) => q.eq("organizationId", args.organizationId).eq("accountId", args.accountId)) |
| 37 | − .unique(); | |
| 37 | + .first(); | |
| 38 | 38 | |
| 39 | 39 | if (existing) { |
| 40 | 40 | await ctx.db.patch(existing._id, { |
| @@ -42,56 +42,32 @@ export async function upsertOrganizationMembership( · 1 unmodified line | ||
| 42 | 42 | status: args.status, |
| 43 | 43 | billable: args.billable, |
| 44 | 44 | invitedByAccountId: args.invitedByAccountId, |
| 45 | + workosOrgMembershipId: args.workosOrgMembershipId, | |
| 45 | 46 | joinedAt: args.status === "active" ? (existing.joinedAt ?? args.now) : existing.joinedAt, |
| 46 | 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 | 65 | organizationId: args.organizationId, |
| 53 | 66 | accountId: args.accountId, |
| 54 | − role: args.role, | |
| 67 | + role: mapOrganizationRoleToWorkspaceRole(args.role), | |
| 55 | 68 | status: args.status, |
| 56 | − billable: args.billable, | |
| 57 | − invitedByAccountId: args.invitedByAccountId, | |
| 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, | |
| 69 | + now: args.now, | |
| 70 | + workosOrgMembershipId: args.workosOrgMembershipId, | |
| 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 | 103 | export function deriveOrganizationMembershipState(status?: string): OrganizationMemberStatus { |
| 132 | 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 | 2 | import type { Id } from "../../convex/_generated/dataModel.d.ts"; |
| 3 | 3 | import type { ActionCtx, MutationCtx, QueryCtx } from "../../convex/_generated/server"; |
| 4 | 4 | import { canManageBilling, isAdminRole } from "../../../core/src/identity"; |
| 5 | +import { safeRunAfter } from "../lib/scheduler"; | |
| 5 | 6 | |
| 6 | 7 | type Internal = typeof import("../../convex/_generated/api").internal; |
| 7 | 8 | type Components = typeof import("../../convex/_generated/api").components; |
| @@ -209,7 +210,7 @@ export async function retrySeatSyncHandler( · 201 unmodified lines | ||
| 209 | 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 | 214 | organizationId: typedCtx.organizationId, |
| 214 | 215 | expectedVersion: nextVersion, |
| 215 | 216 | }); |
| @@ -1,6 +1,10 @@ | ||
| 1 | 1 | import { WorkOS } from "@workos-inc/node"; |
| 2 | 2 | import type { Id } from "../../convex/_generated/dataModel.d.ts"; |
| 3 | 3 | import type { ActionCtx } from "../../convex/_generated/server"; |
| 4 | +import { | |
| 5 | + assertMatchesCanonicalActorId, | |
| 6 | + canonicalActorIdForWorkspaceAccess, | |
| 7 | +} from "../auth/actor_identity"; | |
| 4 | 8 | import { asRecord } from "../lib/object"; |
| 5 | 9 | |
| 6 | 10 | type Internal = typeof import("../../convex/_generated/api").internal; |
| @@ -170,12 +174,9 @@ export async function upsertCredentialHandler( · 163 unmodified lines | ||
| 170 | 174 | workspaceId: args.workspaceId, |
| 171 | 175 | sessionId: args.sessionId, |
| 172 | 176 | }); |
| 173 | − const canonicalActorId = access.provider === "anonymous" | |
| 174 | − ? access.providerAccountId | |
| 175 | − : access.accountId; | |
| 176 | − | |
| 177 | − if (args.scope === "actor" && args.actorId && args.actorId !== canonicalActorId) { | |
| 178 | − throw new Error("actorId must match the authenticated workspace actor"); | |
| 177 | + const canonicalActorId = canonicalActorIdForWorkspaceAccess(access); | |
| 178 | + if (args.scope === "actor") { | |
| 179 | + assertMatchesCanonicalActorId(args.actorId, canonicalActorId); | |
| 179 | 180 | } |
| 180 | 181 | |
| 181 | 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 | 2 | import type { MutationCtx } from "../../convex/_generated/server"; |
| 3 | 3 | import { slugify } from "../../../core/src/identity"; |
| 4 | 4 | import { ensureUniqueSlug } from "../../../core/src/slug"; |
| 5 | − | |
| 6 | −type OrganizationRole = "owner" | "admin" | "member" | "billing_admin"; | |
| 7 | −type OrganizationMemberStatus = "active" | "pending" | "removed"; | |
| 5 | +import { upsertOrganizationMembership } from "../auth/memberships"; | |
| 8 | 6 | |
| 9 | 7 | async function ensureUniqueOrganizationSlug(ctx: Pick<MutationCtx, "db">, baseName: string): Promise<string> { |
| 10 | 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 | 19 | export async function ensureAnonymousIdentity( |
| 60 | 20 | ctx: MutationCtx, |
| @@ -136,7 +96,7 @@ export async function ensureAnonymousIdentity( · 75 unmodified lines | ||
| 136 | 96 | let user = await ctx.db |
| 137 | 97 | .query("workspaceMembers") |
| 138 | 98 | .withIndex("by_workspace_account", (q) => q.eq("workspaceId", workspace._id).eq("accountId", account._id)) |
| 139 | − .unique(); | |
| 99 | + .first(); | |
| 140 | 100 | |
| 141 | 101 | if (!user) { |
| 142 | 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 | 3 | import { actorIdForAccount } from "../../../core/src/identity"; |
| 4 | 4 | import { defaultRuntimeId, isKnownRuntimeId, isRuntimeEnabled } from "../../../core/src/runtimes/runtime-catalog"; |
| 5 | 5 | import type { ApprovalRecord, TaskExecutionOutcome, TaskRecord } from "../../../core/src/types"; |
| 6 | +import { | |
| 7 | + assertMatchesCanonicalActorId, | |
| 8 | + canonicalActorIdForWorkspaceAccess, | |
| 9 | +} from "../auth/actor_identity"; | |
| 6 | 10 | import { DEFAULT_TASK_TIMEOUT_MS } from "../task/constants"; |
| 7 | 11 | import { createTaskEvent } from "../task/events"; |
| 8 | 12 | import { markTaskFinished } from "../task/finish"; |
| @@ -164,15 +168,8 @@ export async function createTaskHandler( · 155 unmodified lines | ||
| 164 | 168 | sessionId: args.sessionId, |
| 165 | 169 | }); |
| 166 | 170 | |
| 167 | − const canonicalActorId = actorIdForAccount({ | |
| 168 | − _id: access.accountId, | |
| 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 | − } | |
| 171 | + const canonicalActorId = canonicalActorIdForWorkspaceAccess(access); | |
| 172 | + assertMatchesCanonicalActorId(args.actorId, canonicalActorId); | |
| 176 | 173 | |
| 177 | 174 | const waitForResult = args.waitForResult ?? false; |
| 178 | 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 | 242 | const canonicalActorId = actorIdForAccount(typedCtx.account); |
| 246 | − if (args.reviewerId && args.reviewerId !== canonicalActorId) { | |
| 247 | − throw new Error("reviewerId must match the authenticated workspace actor"); | |
| 248 | − } | |
| 243 | + assertMatchesCanonicalActorId(args.reviewerId, canonicalActorId, "reviewerId"); | |
| 249 | 244 | |
| 250 | 245 | return await resolveApprovalRecord(typedCtx, internal, { |
| 251 | 246 | ...args, |
| @@ -2,8 +2,21 @@ type SchedulerLike = { · 1 unmodified line | ||
| 2 | 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 | 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 | 22 | export async function safeRunAfter( |
| @@ -2,6 +2,11 @@ import type { Doc, Id } from "../../convex/_generated/dataModel.d.ts"; · 1 unmodified line | ||
| 2 | 2 | import type { MutationCtx, QueryCtx } from "../../convex/_generated/server"; |
| 3 | 3 | import { getOrganizationMembership, slugify } from "../../../core/src/identity"; |
| 4 | 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 | 11 | type WorkspaceSummary = { |
| 7 | 12 | id: Id<"workspaces">; |
| @@ -64,15 +69,13 @@ export async function createOrganizationHandler(ctx: unknown, args: { name: stri · 56 unmodified lines | ||
| 64 | 69 | updatedAt: now, |
| 65 | 70 | }); |
| 66 | 71 | |
| 67 | − await typedCtx.db.insert("organizationMembers", { | |
| 72 | + await upsertOrganizationMembership(typedCtx, { | |
| 68 | 73 | organizationId, |
| 69 | 74 | accountId: account._id, |
| 70 | 75 | role: "owner", |
| 71 | 76 | status: "active", |
| 72 | 77 | billable: true, |
| 73 | − joinedAt: now, | |
| 74 | − createdAt: now, | |
| 75 | − updatedAt: now, | |
| 78 | + now, | |
| 76 | 79 | }); |
| 77 | 80 | |
| 78 | 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 | 87 | updatedAt: now, |
| 85 | 88 | }); |
| 86 | 89 | |
| 90 | + await seedWorkspaceMembersFromOrganization(typedCtx, { | |
| 91 | + organizationId, | |
| 92 | + workspaceId, | |
| 93 | + now, | |
| 94 | + mapRole: mapOrganizationRoleToWorkspaceRole, | |
| 95 | + }); | |
| 96 | + | |
| 87 | 97 | const organization = await typedCtx.db.get(organizationId); |
| 88 | 98 | const workspace = await typedCtx.db.get(workspaceId); |
| 89 | 99 | if (!organization || !workspace) { |
| @@ -3,7 +3,10 @@ · 2 unmodified lines | ||
| 3 | 3 | import type { ActionCtx } from "../../convex/_generated/server"; |
| 4 | 4 | import type { Id } from "../../convex/_generated/dataModel.d.ts"; |
| 5 | 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 | 11 | export async function requireCanonicalActor( |
| 9 | 12 | ctx: ActionCtx, |
| @@ -17,13 +20,7 @@ export async function requireCanonicalActor( · 7 unmodified lines | ||
| 17 | 20 | workspaceId: args.workspaceId, |
| 18 | 21 | sessionId: args.sessionId, |
| 19 | 22 | }); |
| 20 | − const canonicalActorId = actorIdForAccount({ | |
| 21 | − _id: access.accountId, | |
| 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 | − } | |
| 23 | + const canonicalActorId = canonicalActorIdForWorkspaceAccess(access); | |
| 24 | + assertMatchesCanonicalActorId(args.actorId, canonicalActorId); | |
| 28 | 25 | return canonicalActorId; |
| 29 | 26 | } |
| @@ -6,9 +6,9 @@ import { parseGraphqlOperationPaths } from "../../../core/src/graphql/operation- · 5 unmodified lines | ||
| 6 | 6 | import type { AccessPolicyRecord, PolicyDecision, TaskRecord, ToolDefinition } from "../../../core/src/types"; |
| 7 | 7 | import { rehydrateTools, type SerializedTool } from "../../../core/src/tool/source-serialization"; |
| 8 | 8 | import { getDecisionForContext, getToolDecision } from "./policy"; |
| 9 | +import { getReadyRegistryBuildId } from "./tool_registry_state"; | |
| 9 | 10 | import { normalizeToolPathForLookup, toPreferredToolPath } from "./tool_paths"; |
| 10 | 11 | import { baseTools } from "./workspace_tools"; |
| 11 | −import { sourceSignature } from "./tool_source_loading"; | |
| 12 | 12 | |
| 13 | 13 | export function getGraphqlDecision( |
| 14 | 14 | task: TaskRecord, |
| @@ -66,29 +66,6 @@ export function getGraphqlDecision( · 51 unmodified lines | ||
| 66 | 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 | 69 | async function suggestFromRegistry( |
| 93 | 70 | ctx: ActionCtx, |
| 94 | 71 | workspaceId: TaskRecord["workspaceId"], |
| @@ -127,7 +104,12 @@ export async function resolveToolForCall( · 32 unmodified lines | ||
| 127 | 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 | 114 | let resolvedToolPath = toolPath; |
| 133 | 115 | let entry = await ctx.runQuery(internal.toolRegistry.getToolByPath, { |
| @@ -21,7 +21,7 @@ import { publishTaskEvent } from "./events"; · 20 unmodified lines | ||
| 21 | 21 | import { completeToolCall, denyToolCall, failToolCall } from "./tool_call_lifecycle"; |
| 22 | 22 | import { assertPersistedCallRunnable, resolveCredentialHeaders } from "./tool_call_credentials"; |
| 23 | 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 | 26 | function createApprovalId(): string { |
| 27 | 27 | return `approval_${crypto.randomUUID()}`; |
| @@ -62,19 +62,12 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 34 unmodified lines | ||
| 62 | 62 | // Fast system tools are handled server-side from the registry. |
| 63 | 63 | if (toolPath === "discover" || toolPath === "catalog.namespaces" || toolPath === "catalog.tools") { |
| 64 | 64 | const toolRegistry = internal.toolRegistry; |
| 65 | − const [state, sources] = await Promise.all([ | |
| 66 | − ctx.runQuery(toolRegistry.getState, { workspaceId: task.workspaceId }) as Promise<null | { signature: string; readyBuildId?: string }>, | |
| 67 | − ctx.runQuery(internal.database.listToolSources, { workspaceId: task.workspaceId }) as Promise<Array<{ id: string; updatedAt: number; enabled: boolean }>>, | |
| 68 | − ]); | |
| 69 | − const enabledSources = sources.filter((source) => source.enabled); | |
| 70 | − const signature = sourceSignature(task.workspaceId, enabledSources); | |
| 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 | − } | |
| 65 | + const buildId = await getReadyRegistryBuildId(ctx, { | |
| 66 | + workspaceId: task.workspaceId, | |
| 67 | + actorId: task.actorId, | |
| 68 | + clientId: task.clientId, | |
| 69 | + refreshOnStale: true, | |
| 70 | + }); | |
| 78 | 71 | |
| 79 | 72 | const payload = typeof input === "string" |
| 80 | 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 | 20 | PreparedOpenApiSpec, |
| 21 | 21 | } from "../../../core/src/tool/source-types"; |
| 22 | 22 | import type { ToolSourceRecord } from "../../../core/src/types"; |
| 23 | +import { normalizeToolSourceConfig } from "../database/tool_source_config"; | |
| 23 | 24 | import { asPayload } from "../lib/object"; |
| 24 | 25 | |
| 25 | 26 | const OPENAPI_SPEC_CACHE_TTL_MS = 5 * 60 * 60_000; |
| @@ -40,40 +41,15 @@ export function normalizeExternalToolSource(raw: { · 14 unmodified lines | ||
| 40 | 41 | name: string; |
| 41 | 42 | config: Record<string, unknown>; |
| 42 | 43 | }): ExternalToolSourceConfig { |
| 43 | − const config = raw.config; | |
| 44 | + const config = normalizeToolSourceConfig(raw.type, raw.config); | |
| 44 | 45 | |
| 45 | 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 | 47 | const result: McpToolSourceConfig = { |
| 72 | 48 | type: "mcp", |
| 73 | 49 | name: raw.name, |
| 74 | 50 | sourceId: raw.id, |
| 75 | 51 | sourceKey: `source:${raw.id}`, |
| 76 | − url: config.url, | |
| 52 | + url: String(config.url), | |
| 77 | 53 | auth: config.auth as McpToolSourceConfig["auth"], |
| 78 | 54 | transport: config.transport as McpToolSourceConfig["transport"], |
| 79 | 55 | queryParams: config.queryParams as McpToolSourceConfig["queryParams"], |
| @@ -84,16 +60,12 @@ export function normalizeExternalToolSource(raw: { · 4 unmodified lines | ||
| 84 | 60 | } |
| 85 | 61 | |
| 86 | 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 | 63 | const result: GraphqlToolSourceConfig = { |
| 92 | 64 | type: "graphql", |
| 93 | 65 | name: raw.name, |
| 94 | 66 | sourceId: raw.id, |
| 95 | 67 | sourceKey: `source:${raw.id}`, |
| 96 | − endpoint: config.endpoint, | |
| 68 | + endpoint: String(config.endpoint), | |
| 97 | 69 | schema: config.schema as GraphqlToolSourceConfig["schema"], |
| 98 | 70 | auth: config.auth as GraphqlToolSourceConfig["auth"], |
| 99 | 71 | defaultQueryApproval: config.defaultQueryApproval as GraphqlToolSourceConfig["defaultQueryApproval"], |
| @@ -103,17 +75,12 @@ export function normalizeExternalToolSource(raw: { · 3 unmodified lines | ||
| 103 | 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 | 78 | const result: OpenApiToolSourceConfig = { |
| 112 | 79 | type: "openapi", |
| 113 | 80 | name: raw.name, |
| 114 | 81 | sourceId: raw.id, |
| 115 | 82 | sourceKey: `source:${raw.id}`, |
| 116 | − spec: spec as OpenApiToolSourceConfig["spec"], | |
| 83 | + spec: config.spec as OpenApiToolSourceConfig["spec"], | |
| 117 | 84 | baseUrl: config.baseUrl as OpenApiToolSourceConfig["baseUrl"], |
| 118 | 85 | auth: config.auth as OpenApiToolSourceConfig["auth"], |
| 119 | 86 | defaultReadApproval: config.defaultReadApproval as OpenApiToolSourceConfig["defaultReadApproval"], |
| @@ -23,6 +23,7 @@ import type { · 22 unmodified lines | ||
| 23 | 23 | } from "../../../core/src/types"; |
| 24 | 24 | import { computeOpenApiSourceQuality, listVisibleToolDescriptors } from "./tool_descriptors"; |
| 25 | 25 | import { loadSourceArtifact, normalizeExternalToolSource, sourceSignature } from "./tool_source_loading"; |
| 26 | +import { registrySignatureForWorkspace } from "./tool_registry_state"; | |
| 26 | 27 | import { normalizeToolPathForLookup } from "./tool_paths"; |
| 27 | 28 | |
| 28 | 29 | const baseTools = new Map<string, ToolDefinition>(); |
| @@ -516,7 +517,7 @@ export async function getWorkspaceTools( · 487 unmodified lines | ||
| 516 | 517 | traceStep("listToolSources", listSourcesStartedAt); |
| 517 | 518 | const hasOpenApiSource = sources.some((source: { type: string }) => source.type === "openapi"); |
| 518 | 519 | const signature = sourceSignature(workspaceId, sources); |
| 519 | − const registrySignature = `toolreg_v2|${signature}`; | |
| 520 | + const registrySignature = registrySignatureForWorkspace(workspaceId, sources); | |
| 520 | 521 | const debugBase: Omit<WorkspaceToolsDebug, "mode" | "normalizedSourceCount" | "cacheHit" | "cacheFresh" | "timedOutSources" | "durationMs" | "trace"> = { |
| 521 | 522 | includeDts, |
| 522 | 523 | sourceTimeoutMs: sourceTimeoutMs ?? null, |
| @@ -2,6 +2,11 @@ import type { Doc, Id } from "../../convex/_generated/dataModel.d.ts"; · 1 unmodified line | ||
| 2 | 2 | import type { MutationCtx, QueryCtx } from "../../convex/_generated/server"; |
| 3 | 3 | import { getOrganizationMembership, slugify } from "../../../core/src/identity"; |
| 4 | 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 | 11 | type WorkspaceResult = { |
| 7 | 12 | id: Id<"workspaces">; |
| @@ -175,15 +180,13 @@ export async function createWorkspaceHandler( · 167 unmodified lines | ||
| 175 | 180 | updatedAt: now, |
| 176 | 181 | }); |
| 177 | 182 | |
| 178 | − await typedCtx.db.insert("organizationMembers", { | |
| 183 | + await upsertOrganizationMembership(typedCtx, { | |
| 179 | 184 | organizationId, |
| 180 | 185 | accountId: account._id, |
| 181 | 186 | role: "owner", |
| 182 | 187 | status: "active", |
| 183 | 188 | billable: true, |
| 184 | − joinedAt: now, | |
| 185 | − createdAt: now, | |
| 186 | − updatedAt: now, | |
| 189 | + now, | |
| 187 | 190 | }); |
| 188 | 191 | } |
| 189 | 192 | |
| @@ -209,6 +212,13 @@ export async function createWorkspaceHandler( · 19 unmodified lines | ||
| 209 | 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 | 222 | await cleanupEmptyStarterWorkspace(typedCtx, organizationId, workspaceId); |
| 213 | 223 | |
| 214 | 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 | 24 | "dev:assistant": "bun run --cwd assistant/packages/server dev", |
| 25 | 25 | "dev:bot": "bun run --cwd assistant/packages/bot dev", |
| 26 | 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 | 31 | "test:assistant": "bun run --cwd assistant test", |
| 29 | 32 | "typecheck": "bun run typecheck:executor && bun run typecheck:assistant", |
| 30 | 33 | "typecheck:executor": "bunx tsc --noEmit -p executor/tsconfig.json", |