- Drizzle schema: users, teams, team_members, invitations, sources, tools, secrets, policies, plugin_kv - Implements ToolRegistryService, SecretStoreService, PolicyEngineService - AES-256-GCM encrypted secrets in DB - makePgKv for plugin compatibility via ScopedKv - User/team/invitation CRUD - 17 tests against PGlite - drizzle-orm in root catalog for single workspace copy
Unified · Split
| @@ -39,4 +39,7 @@ executor.har · 38 unmodified lines | ||
| 39 | 39 | .executor/ |
| 40 | 40 | |
| 41 | 41 | # desktop app build artifacts |
| 42 | −apps/desktop/resources/ | |
| 42 | +apps/desktop/resources/ | |
| 43 | + | |
| 44 | +# cloud local dev database | |
| 45 | +.pglite | |
| @@ -41,6 +41,7 @@ · 40 unmodified lines | ||
| 41 | 41 | "@effect/vitest": "^0.27.0", |
| 42 | 42 | "@types/node": "^24.3.1", |
| 43 | 43 | "bun-types": "^1.2.22", |
| 44 | + "drizzle-orm": "^0.44.0", | |
| 44 | 45 | "vitest": "^3.2.4" |
| 45 | 46 | }, |
| 46 | 47 | "scripts": { |
| @@ -0,0 +1,94 @@ | ||
| 1 | +CREATE TABLE "invitations" ( | |
| 2 | + "id" text PRIMARY KEY NOT NULL, | |
| 3 | + "team_id" text NOT NULL, | |
| 4 | + "email" text NOT NULL, | |
| 5 | + "invited_by" text NOT NULL, | |
| 6 | + "status" text NOT NULL, | |
| 7 | + "created_at" timestamp with time zone DEFAULT now() NOT NULL, | |
| 8 | + "expires_at" timestamp with time zone NOT NULL | |
| 9 | +); | |
| 10 | +--> statement-breakpoint | |
| 11 | +CREATE TABLE "plugin_kv" ( | |
| 12 | + "team_id" text NOT NULL, | |
| 13 | + "namespace" text NOT NULL, | |
| 14 | + "key" text NOT NULL, | |
| 15 | + "value" text NOT NULL, | |
| 16 | + CONSTRAINT "plugin_kv_team_id_namespace_key_pk" PRIMARY KEY("team_id","namespace","key") | |
| 17 | +); | |
| 18 | +--> statement-breakpoint | |
| 19 | +CREATE TABLE "policies" ( | |
| 20 | + "id" text NOT NULL, | |
| 21 | + "team_id" text NOT NULL, | |
| 22 | + "name" text NOT NULL, | |
| 23 | + "action" text NOT NULL, | |
| 24 | + "match_tool_pattern" text, | |
| 25 | + "match_source_id" text, | |
| 26 | + "priority" integer NOT NULL, | |
| 27 | + "created_at" timestamp with time zone DEFAULT now() NOT NULL, | |
| 28 | + CONSTRAINT "policies_id_team_id_pk" PRIMARY KEY("id","team_id") | |
| 29 | +); | |
| 30 | +--> statement-breakpoint | |
| 31 | +CREATE TABLE "secrets" ( | |
| 32 | + "id" text NOT NULL, | |
| 33 | + "team_id" text NOT NULL, | |
| 34 | + "name" text NOT NULL, | |
| 35 | + "purpose" text, | |
| 36 | + "encrypted_value" "bytea" NOT NULL, | |
| 37 | + "iv" "bytea" NOT NULL, | |
| 38 | + "created_at" timestamp with time zone DEFAULT now() NOT NULL, | |
| 39 | + CONSTRAINT "secrets_id_team_id_pk" PRIMARY KEY("id","team_id") | |
| 40 | +); | |
| 41 | +--> statement-breakpoint | |
| 42 | +CREATE TABLE "sources" ( | |
| 43 | + "id" text NOT NULL, | |
| 44 | + "team_id" text NOT NULL, | |
| 45 | + "name" text NOT NULL, | |
| 46 | + "kind" text NOT NULL, | |
| 47 | + "config" jsonb NOT NULL, | |
| 48 | + "created_at" timestamp with time zone DEFAULT now() NOT NULL, | |
| 49 | + CONSTRAINT "sources_id_team_id_pk" PRIMARY KEY("id","team_id") | |
| 50 | +); | |
| 51 | +--> statement-breakpoint | |
| 52 | +CREATE TABLE "team_members" ( | |
| 53 | + "team_id" text NOT NULL, | |
| 54 | + "user_id" text NOT NULL, | |
| 55 | + "role" text NOT NULL, | |
| 56 | + "created_at" timestamp with time zone DEFAULT now() NOT NULL, | |
| 57 | + CONSTRAINT "team_members_team_id_user_id_pk" PRIMARY KEY("team_id","user_id") | |
| 58 | +); | |
| 59 | +--> statement-breakpoint | |
| 60 | +CREATE TABLE "teams" ( | |
| 61 | + "id" text PRIMARY KEY NOT NULL, | |
| 62 | + "name" text NOT NULL, | |
| 63 | + "created_at" timestamp with time zone DEFAULT now() NOT NULL | |
| 64 | +); | |
| 65 | +--> statement-breakpoint | |
| 66 | +CREATE TABLE "tool_definitions" ( | |
| 67 | + "name" text NOT NULL, | |
| 68 | + "team_id" text NOT NULL, | |
| 69 | + "schema" jsonb NOT NULL, | |
| 70 | + CONSTRAINT "tool_definitions_name_team_id_pk" PRIMARY KEY("name","team_id") | |
| 71 | +); | |
| 72 | +--> statement-breakpoint | |
| 73 | +CREATE TABLE "tools" ( | |
| 74 | + "id" text NOT NULL, | |
| 75 | + "team_id" text NOT NULL, | |
| 76 | + "source_id" text NOT NULL, | |
| 77 | + "plugin_key" text NOT NULL, | |
| 78 | + "name" text NOT NULL, | |
| 79 | + "description" text, | |
| 80 | + "may_elicit" boolean, | |
| 81 | + "input_schema" jsonb, | |
| 82 | + "output_schema" jsonb, | |
| 83 | + "created_at" timestamp with time zone DEFAULT now() NOT NULL, | |
| 84 | + CONSTRAINT "tools_id_team_id_pk" PRIMARY KEY("id","team_id") | |
| 85 | +); | |
| 86 | +--> statement-breakpoint | |
| 87 | +CREATE TABLE "users" ( | |
| 88 | + "id" text PRIMARY KEY NOT NULL, | |
| 89 | + "email" text NOT NULL, | |
| 90 | + "name" text, | |
| 91 | + "avatar_url" text, | |
| 92 | + "created_at" timestamp with time zone DEFAULT now() NOT NULL, | |
| 93 | + CONSTRAINT "users_email_unique" UNIQUE("email") | |
| 94 | +); | |
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | { |
| 2 | − "id": "36e0d08d-3eb6-467a-8440-e39b7bfee61e", | |
| 2 | + "id": "ee7b4779-1928-46a3-bcb8-1a48898bec19", | |
| 3 | 3 | "prevId": "00000000-0000-0000-0000-000000000000", |
| 4 | 4 | "version": "7", |
| 5 | 5 | "dialect": "postgresql", |
| @@ -240,50 +240,6 @@ · 234 unmodified lines | ||
| 240 | 240 | "checkConstraints": {}, |
| 241 | 241 | "isRLSEnabled": false |
| 242 | 242 | }, |
| 243 | − "public.sessions": { | |
| 244 | − "name": "sessions", | |
| 245 | − "schema": "", | |
| 246 | − "columns": { | |
| 247 | − "id": { | |
| 248 | − "name": "id", | |
| 249 | − "type": "text", | |
| 250 | − "primaryKey": true, | |
| 251 | − "notNull": true | |
| 252 | − }, | |
| 253 | − "user_id": { | |
| 254 | − "name": "user_id", | |
| 255 | − "type": "text", | |
| 256 | − "primaryKey": false, | |
| 257 | − "notNull": true | |
| 258 | − }, | |
| 259 | − "team_id": { | |
| 260 | − "name": "team_id", | |
| 261 | − "type": "text", | |
| 262 | − "primaryKey": false, | |
| 263 | − "notNull": true | |
| 264 | − }, | |
| 265 | − "expires_at": { | |
| 266 | − "name": "expires_at", | |
| 267 | − "type": "timestamp with time zone", | |
| 268 | − "primaryKey": false, | |
| 269 | − "notNull": true | |
| 270 | − }, | |
| 271 | − "created_at": { | |
| 272 | − "name": "created_at", | |
| 273 | − "type": "timestamp with time zone", | |
| 274 | − "primaryKey": false, | |
| 275 | − "notNull": true, | |
| 276 | − "default": "now()" | |
| 277 | − } | |
| 278 | − }, | |
| 279 | − "indexes": {}, | |
| 280 | − "foreignKeys": {}, | |
| 281 | − "compositePrimaryKeys": {}, | |
| 282 | − "uniqueConstraints": {}, | |
| 283 | − "policies": {}, | |
| 284 | − "checkConstraints": {}, | |
| 285 | − "isRLSEnabled": false | |
| 286 | − }, | |
| 287 | 243 | "public.sources": { |
| 288 | 244 | "name": "sources", |
| 289 | 245 | "schema": "", |
| @@ -5,8 +5,8 @@ · 4 unmodified lines | ||
| 5 | 5 | { |
| 6 | 6 | "idx": 0, |
| 7 | 7 | "version": "7", |
| 8 | − "when": 1775542409153, | |
| 9 | − "tag": "0000_great_speedball", | |
| 8 | + "when": 1775552333040, | |
| 9 | + "tag": "0000_spicy_zemo", | |
| 10 | 10 | "breakpoints": true |
| 11 | 11 | } |
| 12 | 12 | ] |
| @@ -10,7 +10,7 @@ · 9 unmodified lines | ||
| 10 | 10 | "@effect/sql": "catalog:", |
| 11 | 11 | "@effect/sql-pg": "^0.28.0", |
| 12 | 12 | "@executor/sdk": "workspace:*", |
| 13 | − "drizzle-orm": "^0.45.2", | |
| 13 | + "drizzle-orm": "catalog:", | |
| 14 | 14 | "effect": "catalog:", |
| 15 | 15 | "pg": "^8.16.0" |
| 16 | 16 | }, |
| @@ -46,7 +46,7 @@ beforeAll(async () => { · 45 unmodified lines | ||
| 46 | 46 | |
| 47 | 47 | beforeEach(async () => { |
| 48 | 48 | await db.execute( |
| 49 | − sql`TRUNCATE plugin_kv, policies, secrets, tool_definitions, tools, sources, sessions, invitations, team_members, teams, users`, | |
| 49 | + sql`TRUNCATE plugin_kv, policies, secrets, tool_definitions, tools, sources, invitations, team_members, teams, users`, | |
| 50 | 50 | ); |
| 51 | 51 | }); |
| 52 | 52 | |
| @@ -408,24 +408,4 @@ describe("UserStore", () => { · 355 unmodified lines | ||
| 408 | 408 | expect(await store.getPendingInvitations("new@example.com")).toHaveLength(0); |
| 409 | 409 | }); |
| 410 | 410 | |
| 411 | − it("session management", async () => { | |
| 412 | − const store = makeUserStore(db); | |
| 413 | − await store.upsertUser({ id: "u1", email: "a@example.com" }); | |
| 414 | − const team = await store.createTeam("Team"); | |
| 415 | − | |
| 416 | − const session = await store.createSession("u1", team.id, new Date(Date.now() + 3600_000)); | |
| 417 | − expect((await store.getSession(session.id))!.userId).toBe("u1"); | |
| 418 | − | |
| 419 | − await store.deleteSession(session.id); | |
| 420 | − expect(await store.getSession(session.id)).toBeNull(); | |
| 421 | − }); | |
| 422 | − | |
| 423 | − it("expired sessions return null", async () => { | |
| 424 | − const store = makeUserStore(db); | |
| 425 | − await store.upsertUser({ id: "u1", email: "a@example.com" }); | |
| 426 | − const team = await store.createTeam("Team"); | |
| 427 | − | |
| 428 | − const session = await store.createSession("u1", team.id, new Date(Date.now() - 1000)); | |
| 429 | − expect(await store.getSession(session.id)).toBeNull(); | |
| 430 | − }); | |
| 431 | 411 | }); |
| @@ -17,9 +17,9 @@ · 16 unmodified lines | ||
| 17 | 17 | // |
| 18 | 18 | // --------------------------------------------------------------------------- |
| 19 | 19 | |
| 20 | −import type { PgDatabase } from "drizzle-orm/pg-core"; | |
| 21 | 20 | import { ScopeId, makeInMemorySourceRegistry } from "@executor/sdk"; |
| 22 | 21 | import type { Scope, ExecutorConfig, ExecutorPlugin } from "@executor/sdk"; |
| 22 | +import type { DrizzleDb } from "./types"; | |
| 23 | 23 | |
| 24 | 24 | import { makePgToolRegistry } from "./tool-registry"; |
| 25 | 25 | import { makePgSecretStore } from "./secret-store"; |
| @@ -30,8 +30,9 @@ export { makePgToolRegistry } from "./tool-registry"; · 4 unmodified lines | ||
| 30 | 30 | export { makePgSecretStore } from "./secret-store"; |
| 31 | 31 | export { makePgPolicyEngine } from "./policy-engine"; |
| 32 | 32 | export { makeUserStore } from "./user-store"; |
| 33 | −export type { User, Team, TeamMember, Invitation, Session } from "./user-store"; | |
| 33 | +export type { User, Team, TeamMember, Invitation } from "./user-store"; | |
| 34 | 34 | export { encrypt, decrypt } from "./crypto"; |
| 35 | +export type { DrizzleDb } from "./types"; | |
| 35 | 36 | export * from "./schema"; |
| 36 | 37 | |
| 37 | 38 | // --------------------------------------------------------------------------- |
| @@ -41,7 +42,7 @@ export * from "./schema"; · 3 unmodified lines | ||
| 41 | 42 | export const makePgConfig = < |
| 42 | 43 | const TPlugins extends readonly ExecutorPlugin<string, object>[] = [], |
| 43 | 44 | >( |
| 44 | − db: PgDatabase<any, any, any>, | |
| 45 | + db: DrizzleDb, | |
| 45 | 46 | options: { |
| 46 | 47 | readonly teamId: string; |
| 47 | 48 | readonly teamName: string; |
| @@ -4,12 +4,12 @@ · 3 unmodified lines | ||
| 4 | 4 | |
| 5 | 5 | import { Effect } from "effect"; |
| 6 | 6 | import { eq, and } from "drizzle-orm"; |
| 7 | −import type { PgDatabase } from "drizzle-orm/pg-core"; | |
| 8 | 7 | import type { Kv } from "@executor/sdk"; |
| 9 | 8 | |
| 10 | 9 | import { pluginKv } from "./schema"; |
| 10 | +import type { DrizzleDb } from "./types"; | |
| 11 | 11 | |
| 12 | −export const makePgKv = (db: PgDatabase<any, any, any>, teamId: string): Kv => ({ | |
| 12 | +export const makePgKv = (db: DrizzleDb, teamId: string): Kv => ({ | |
| 13 | 13 | get: (namespace, key) => |
| 14 | 14 | Effect.tryPromise(async () => { |
| 15 | 15 | const rows = await db |
| @@ -4,15 +4,15 @@ · 3 unmodified lines | ||
| 4 | 4 | |
| 5 | 5 | import { Effect } from "effect"; |
| 6 | 6 | import { eq, and } from "drizzle-orm"; |
| 7 | −import type { PgDatabase } from "drizzle-orm/pg-core"; | |
| 8 | 7 | |
| 9 | 8 | import { Policy, PolicyId, ScopeId } from "@executor/sdk"; |
| 9 | +import type { DrizzleDb } from "./types"; | |
| 10 | 10 | import type { PolicyCheckInput } from "@executor/sdk"; |
| 11 | 11 | |
| 12 | 12 | import { policies } from "./schema"; |
| 13 | 13 | |
| 14 | 14 | export const makePgPolicyEngine = ( |
| 15 | − db: PgDatabase<any, any, any>, | |
| 15 | + db: DrizzleDb, | |
| 16 | 16 | teamId: string, |
| 17 | 17 | ) => { |
| 18 | 18 | let counter = 0; |
| @@ -58,14 +58,6 @@ export const invitations = pgTable("invitations", { · 57 unmodified lines | ||
| 58 | 58 | expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), |
| 59 | 59 | }); |
| 60 | 60 | |
| 61 | −export const sessions = pgTable("sessions", { | |
| 62 | − id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()), | |
| 63 | − userId: text("user_id").notNull(), | |
| 64 | − teamId: text("team_id").notNull(), | |
| 65 | − expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), | |
| 66 | − createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), | |
| 67 | −}); | |
| 68 | − | |
| 69 | 61 | // --------------------------------------------------------------------------- |
| 70 | 62 | // Domain data — all team-scoped |
| 71 | 63 | // --------------------------------------------------------------------------- |
| @@ -4,9 +4,9 @@ · 3 unmodified lines | ||
| 4 | 4 | |
| 5 | 5 | import { Effect, Option } from "effect"; |
| 6 | 6 | import { eq, and } from "drizzle-orm"; |
| 7 | −import type { PgDatabase } from "drizzle-orm/pg-core"; | |
| 8 | 7 | |
| 9 | 8 | import { SecretRef, SecretId, ScopeId } from "@executor/sdk"; |
| 9 | +import type { DrizzleDb } from "./types"; | |
| 10 | 10 | import { SecretNotFoundError, SecretResolutionError } from "@executor/sdk"; |
| 11 | 11 | import type { SecretProvider, SetSecretInput } from "@executor/sdk"; |
| 12 | 12 | |
| @@ -14,7 +14,7 @@ import { secrets } from "./schema"; · 1 unmodified line | ||
| 14 | 14 | import { encrypt, decrypt } from "./crypto"; |
| 15 | 15 | |
| 16 | 16 | export const makePgSecretStore = ( |
| 17 | − db: PgDatabase<any, any, any>, | |
| 17 | + db: DrizzleDb, | |
| 18 | 18 | teamId: string, |
| 19 | 19 | encryptionKey: string, |
| 20 | 20 | ) => { |
| @@ -99,14 +99,15 @@ export const makePgSecretStore = ( · 78 unmodified lines | ||
| 99 | 99 | |
| 100 | 100 | const row = rows[0]; |
| 101 | 101 | if (row) { |
| 102 | − try { | |
| 103 | − return decrypt(row.encryptedValue, row.iv, encryptionKey); | |
| 104 | − } catch { | |
| 105 | − return yield* new SecretResolutionError({ | |
| 106 | − secretId, | |
| 107 | − message: `Failed to decrypt secret "${secretId}"`, | |
| 108 | − }); | |
| 109 | − } | |
| 102 | + const decrypted = yield* Effect.try({ | |
| 103 | + try: () => decrypt(row.encryptedValue, row.iv, encryptionKey), | |
| 104 | + catch: () => | |
| 105 | + new SecretResolutionError({ | |
| 106 | + secretId, | |
| 107 | + message: `Failed to decrypt secret "${secretId}"`, | |
| 108 | + }), | |
| 109 | + }); | |
| 110 | + return decrypted; | |
| 110 | 111 | } |
| 111 | 112 | |
| 112 | 113 | // Fall back to registered providers |
| @@ -3,10 +3,10 @@ · 2 unmodified lines | ||
| 3 | 3 | // --------------------------------------------------------------------------- |
| 4 | 4 | |
| 5 | 5 | import { Effect } from "effect"; |
| 6 | −import { eq, and, or, ilike } from "drizzle-orm"; | |
| 7 | −import type { PgDatabase } from "drizzle-orm/pg-core"; | |
| 6 | +import { eq, and } from "drizzle-orm"; | |
| 8 | 7 | |
| 9 | 8 | import type { ToolId } from "@executor/sdk"; |
| 9 | +import type { DrizzleDb } from "./types"; | |
| 10 | 10 | import { ToolNotFoundError, ToolInvocationError, ToolRegistration } from "@executor/sdk"; |
| 11 | 11 | import { buildToolTypeScriptPreview } from "@executor/sdk"; |
| 12 | 12 | import type { |
| @@ -19,7 +19,7 @@ import type { · 6 unmodified lines | ||
| 19 | 19 | import { tools, toolDefinitions } from "./schema"; |
| 20 | 20 | |
| 21 | 21 | export const makePgToolRegistry = ( |
| 22 | − db: PgDatabase<any, any, any>, | |
| 22 | + db: DrizzleDb, | |
| 23 | 23 | teamId: string, |
| 24 | 24 | ) => { |
| 25 | 25 | const runtimeTools = new Map<string, ToolRegistration>(); |
| @@ -0,0 +1,7 @@ | ||
| 1 | +// --------------------------------------------------------------------------- | |
| 2 | +// Shared Drizzle DB type | |
| 3 | +// --------------------------------------------------------------------------- | |
| 4 | + | |
| 5 | +import type { PgDatabase } from "drizzle-orm/pg-core"; | |
| 6 | + | |
| 7 | +export type DrizzleDb = PgDatabase<any, any, any>; | |
| @@ -3,17 +3,16 @@ · 2 unmodified lines | ||
| 3 | 3 | // --------------------------------------------------------------------------- |
| 4 | 4 | |
| 5 | 5 | import { eq, and } from "drizzle-orm"; |
| 6 | −import type { PgDatabase } from "drizzle-orm/pg-core"; | |
| 7 | 6 | |
| 8 | −import { users, teams, teamMembers, invitations, sessions } from "./schema"; | |
| 7 | +import { users, teams, teamMembers, invitations } from "./schema"; | |
| 8 | +import type { DrizzleDb } from "./types"; | |
| 9 | 9 | |
| 10 | 10 | export type User = typeof users.$inferSelect; |
| 11 | 11 | export type Team = typeof teams.$inferSelect; |
| 12 | 12 | export type TeamMember = typeof teamMembers.$inferSelect; |
| 13 | 13 | export type Invitation = typeof invitations.$inferSelect; |
| 14 | −export type Session = typeof sessions.$inferSelect; | |
| 15 | 14 | |
| 16 | −export const makeUserStore = (db: PgDatabase<any, any, any>) => ({ | |
| 15 | +export const makeUserStore = (db: DrizzleDb) => ({ | |
| 17 | 16 | // --- Users --- |
| 18 | 17 | |
| 19 | 18 | upsertUser: async (user: { id: string; email: string; name?: string; avatarUrl?: string }) => { |
| @@ -133,29 +132,4 @@ export const makeUserStore = (db: PgDatabase<any, any, any>) => ({ · 113 unmodified lines | ||
| 133 | 132 | return updated ?? null; |
| 134 | 133 | }, |
| 135 | 134 | |
| 136 | − // --- Sessions --- | |
| 137 | − | |
| 138 | − createSession: async (userId: string, teamId: string, expiresAt: Date) => { | |
| 139 | − const [session] = await db | |
| 140 | − .insert(sessions) | |
| 141 | − .values({ userId, teamId, expiresAt }) | |
| 142 | − .returning(); | |
| 143 | − return session!; | |
| 144 | − }, | |
| 145 | − | |
| 146 | − getSession: async (sessionId: string) => { | |
| 147 | − const rows = await db.select().from(sessions).where(eq(sessions.id, sessionId)); | |
| 148 | − const session = rows[0]; | |
| 149 | − if (!session) return null; | |
| 150 | − if (session.expiresAt < new Date()) return null; | |
| 151 | − return session; | |
| 152 | − }, | |
| 153 | − | |
| 154 | − deleteSession: async (sessionId: string) => { | |
| 155 | − await db.delete(sessions).where(eq(sessions.id, sessionId)); | |
| 156 | − }, | |
| 157 | − | |
| 158 | − deleteUserSessions: async (userId: string) => { | |
| 159 | − await db.delete(sessions).where(eq(sessions.userId, userId)); | |
| 160 | − }, | |
| 161 | 135 | }); |