diff --git a/apps/api/README.md b/apps/api/README.md index 8d8e97a..2bb9c5c 100644 --- a/apps/api/README.md +++ b/apps/api/README.md @@ -292,8 +292,11 @@ Summary of what's implemented: ## MCP Server -The MCP server is exposed at `POST /mcp` over JSON-RPC HTTP. Use the -`Mcp-Session-Id` header for session continuation. +The MCP server is exposed at `POST /mcp` using the TypeScript SDK 2's stateless +Streamable HTTP handler. Clients using the `2026-07-28` protocol use the modern +request pattern, while 2025-era Streamable HTTP clients use the SDK's stateless +`initialize` compatibility path. Neither path creates or retains session IDs, +and the legacy HTTP+SSE transport is not supported. MCP clients authenticate the same way as REST clients: @@ -306,8 +309,14 @@ OAuth-capable MCP clients can discover metadata from: - `GET /.well-known/oauth-authorization-server` - `GET /.well-known/openid-configuration` -OAuth client registration, authorization, token, introspection, revocation, and -userinfo endpoints are served by Better Auth under `/api/auth/oauth2/*`. +OAuth authorization, token, introspection, revocation, and userinfo endpoints +are served by Better Auth under `/api/auth/oauth2/*`. Modern clients can use +CIMD, while existing clients can use Dynamic Client Registration (DCR); static +pre-registered clients are also supported. OAuth tool access is +default-deny using the scope map in `src/mcp/policy.ts`. Team API keys have full +tool access to their fixed team, while browser session cookies are rejected. +OAuth authorization requests must include an explicit, non-empty `scope` so a +missing value cannot expand to the client's complete capability set. MCP tools live in `src/mcp/tools/*` and cover contacts, templates, sequences, ESP settings (both the default-ESP singleton tools and the multi-ESP diff --git a/apps/api/docs/mcp-2026-07-28-migration.md b/apps/api/docs/mcp-2026-07-28-migration.md new file mode 100644 index 0000000..1dcee44 --- /dev/null +++ b/apps/api/docs/mcp-2026-07-28-migration.md @@ -0,0 +1,483 @@ +# MCP 2026-07-28 implementation record + +## Status + +This document describes the MCP implementation that is present in SendLit as +of 2026-08-09. It is an implementation record, not the original migration +proposal. + +SendLit now uses the stable TypeScript SDK 2 packages and exposes one +authenticated Streamable HTTP endpoint at `POST /mcp`. The endpoint serves: + +- modern MCP `2026-07-28` requests using the stateless protocol core; and +- existing 2025-era Streamable HTTP requests through SDK 2's built-in + stateless legacy serving mode. + +Both eras use the same request-local server factory, 68-tool catalog, +authorization policy, and domain services. There is no SDK v1 server, MCP +session store, `Mcp-Session-Id` response, or standalone HTTP+SSE endpoint. + +The implementation has been manually connected from Claude.ai, Cloudflare's +AI Playground, and VS Code through an HTTPS Tailscale Funnel. These clients can +discover the tool catalog and invoke tools. Automated tests also pin an SDK 2 +client to `2026-07-28` and assert that it negotiates the modern protocol era. + +## Sources + +- [MCP SDK documentation for 2026-07-28](https://modelcontextprotocol.io/docs/2026-07-28/sdk) +- [MCP 2026-07-28 release announcement](https://blog.modelcontextprotocol.io/posts/2026-07-28/) +- [TypeScript SDK v1 to v2 migration guide](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/docs/migration/upgrade-to-v2.md) +- [TypeScript SDK 2026-07-28 support guide](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/docs/migration/support-2026-07-28.md) +- [Better Auth CIMD documentation](https://better-auth.com/docs/beta/plugins/cimd) + +## Protocol implementation + +### Modern requests are genuinely stateless MCP 2026-07-28 + +The route constructs the official SDK handler as follows: + +```ts +createMcpHandler(() => buildMcpServer(), { + legacy: "stateless", + onerror, +}); +``` + +For a modern request, SDK 2: + +1. classifies and validates the `2026-07-28` request envelope; +2. validates `MCP-Protocol-Version`, `Mcp-Method`, and `Mcp-Name` against the + envelope; +3. calls the factory to create a fresh `McpServer`; +4. passes the request's verified `AuthInfo` into the handler context; and +5. closes the request-local server after the exchange. + +There is no `initialize` exchange and no hidden transport session on the modern +path. Any modern request can be served by any API instance. Durable SendLit +state remains in Postgres, Redis/BullMQ, and the existing auth system; that is +application state, not MCP transport-session state. + +The process-level schema cache in `mcp/tool-registry.ts` is also not client +state. It contains only immutable schema adapters keyed by tool name so the 68 +Zod-to-JSON-Schema conversions are not repeated for every request. It contains +no credentials, client capabilities, team selection, or request data. + +### Existing Streamable HTTP clients use SDK-provided compatibility + +The same endpoint accepts a 2025-era `initialize` POST because +`legacy: "stateless"` is enabled. That option is implemented by +`@modelcontextprotocol/server`; SendLit does not classify, translate, or +dispatch protocol eras itself. + +The explicit option currently matches SDK 2's default. It remains in the route +to document that support for existing Streamable HTTP clients is intentional. +Setting it to `legacy: "reject"` would make the endpoint strictly 2026-only. + +The legacy path is stateless too: SDK 2 creates a fresh server and a fresh +Streamable HTTP transport for each POST with no session ID generator. SendLit +does not retain the transport after the request. + +| Request form | Current behavior | +| ----------------------------------- | ------------------------------------------- | +| Modern `2026-07-28` request | Served by the SDK 2 per-request modern path | +| 2025-era `initialize`/request POST | Served by the SDK 2 stateless legacy path | +| `GET /mcp` | Not routed; returns 404 | +| `DELETE /mcp` | Not routed | +| `Mcp-Session-Id` response | Never emitted by the tested legacy flow | +| Standalone legacy HTTP+SSE endpoint | Not implemented | + +A 2025 Streamable HTTP POST may receive a `text/event-stream` response. That is +the response form used inside Streamable HTTP and is not the retired standalone +SSE transport, which required a separately opened GET stream. + +### Server capabilities + +`buildMcpServer()` creates one request-local `McpServer` identified as SendLit +version `2.0.0`. It registers tools in a fixed domain order and advertises +private five-minute cache hints for: + +- `server/discover`; and +- `tools/list`. + +SendLit currently exposes tools only. It does not register MCP prompts, +resources, tasks, MRTR approval flows, sampling, roots, or logging facilities. +Tool calls are ordinary request/response operations and do not depend on +server-to-client requests. + +## HTTP and Express integration + +The route is mounted before Express's global JSON body parser. The official +`@modelcontextprotocol/node` adapter owns body parsing, content-type validation, +and protocol error responses. + +The actual request pipeline is: + +```text +POST /mcp + -> MCP CORS handling + -> per-IP MCP rate limit + -> OAuth bearer or team API-key authentication + -> team resolution + -> typed MCP AuthInfo construction + -> request logging + -> @modelcontextprotocol/node adapter + -> @modelcontextprotocol/server createMcpHandler + -> fresh buildMcpServer() + -> centralized tool policy + -> existing SendLit domain query/service +``` + +The CORS preflight allows `Content-Type`, `Accept`, +`MCP-Protocol-Version`, `Mcp-Method`, `Mcp-Name`, `Authorization`, and +`x-sendlit-apikey`. It deliberately does not advertise `Mcp-Session-Id`. + +The endpoint has a 60-request-per-minute default limit per rate-limit key. +Requests whose `Mcp-Name` identifies one of the configured high-impact tools +use a 20-request limit. This header-aware lower limit applies naturally to +modern requests; a 2025 request without `Mcp-Name` receives the default limit. + +## Authentication and tenant isolation + +### Accepted credentials + +Every MCP protocol request, including `server/discover`, is authenticated +before SDK dispatch. The endpoint accepts: + +- an OAuth bearer access token; or +- `x-sendlit-apikey` for a fixed-team API key. + +Better Auth browser-session cookies are rejected on `/mcp`. Browser sessions +remain valid for SendLit's first-party UI and OAuth login/consent pages, but do +not silently authenticate a remote MCP caller. + +### Request-local AuthInfo + +`createSendLitMcpAuthInfo()` keeps these concepts separate: + +- the OAuth client identity in `AuthInfo.clientId`; +- the verified OAuth scopes in `AuthInfo.scopes`; +- the selected tenant in `AuthInfo.extra.teamId`; +- the authentication kind in `AuthInfo.extra.authKind`; and +- the optional human user in `AuthInfo.extra.user`. + +For a team API key, `AuthInfo.token` and `clientId` use the stable public API-key +ID, never the API-key secret. The secret is not copied into the MCP context. + +OAuth tokens carry the team selected during the authorization flow. A team API +key resolves directly to exactly one team. MCP therefore does not accept a +caller-supplied team selector as its source of tenant authority. + +### Default-deny tool scopes + +`mcp/policy.ts` is the central registry for both supported OAuth scopes and the +required scope of every tool. Registration calls `getRequiredScope(name)`, +which throws if a tool has no policy entry. Catalog tests assert that the 68 +registered tools and policy entries are identical sets. + +OAuth calls must contain the tool's required scope. Team API keys currently +have full access to their fixed team because scoped team keys are not yet a +SendLit API-key product feature. + +Dynamic registration and discovery use identity-only default scopes. All MCP +scopes remain requestable capabilities, but `/oauth2/authorize` rejects a +missing or empty `scope` rather than allowing Better Auth to substitute the +client's complete capability set. + +The supported scope families are contacts, templates, media, sequences, +emails, settings, ESP configuration, teams, API keys, feedback, delivery +events, and suppressions, with read/write or read/send separation as +appropriate. + +## OAuth discovery and client onboarding + +OAuth support is not provided solely by the MCP SDK. SendLit combines: + +- `@codelitdev/oauth-server-kit/mcp` for MCP protected-resource and + authorization-server discovery integration; +- Better Auth's OAuth provider for authorization code, PKCE, consent, tokens, + resources, and public Dynamic Client Registration (DCR); and +- Better Auth's CIMD plugin configured with the `mcp-2026-07-28` metadata + profile. + +The authorization-server metadata advertises: + +- issuer information and authorization-response issuer support; +- Client ID Metadata Documents (CIMD); +- a public DCR endpoint; and +- the same supported scopes enforced by the MCP policy registry. + +CIMD is preferred for modern clients. DCR remains enabled because clients such +as MCP Inspector still depend on it, even though DCR is deprecated by the +`2026-07-28` specification. + +### Custom CIMD fetch and client compatibility + +`auth/cimd-fetch.ts` is genuine compatibility code outside the MCP wire +protocol. It exists for two reasons: + +1. Better Auth `1.7.0-rc.4`'s secure fetch helper is incompatible with Node + 24's `lookup(..., { all: true })` callback contract. +2. VS Code and Claude publish optional OAuth grant types that SendLit's OAuth + provider does not implement, although both clients use Authorization Code + with PKCE for this connection. + +The custom fetcher: + +- requires an HTTPS metadata URL; +- resolves all DNS answers and rejects private, loopback, link-local, + documentation, multicast, and other non-public addresses; +- pins the outbound request to a validated DNS answer while retaining TLS SNI + and the original Host header; +- performs no redirect following because it uses a direct Node HTTPS request; +- passes through an available abort signal; +- removes Device Authorization only from VS Code's canonical metadata URL; +- removes JWT Bearer only from Claude's canonical metadata URL; and +- leaves every other CIMD document unchanged for normal provider validation. + +The implementation does not currently add its own response-size ceiling or +independent request deadline around this custom fetch. Those are production +hardening opportunities and must not be documented as already implemented. + +VS Code serves its metadata with `Cache-Control: no-store`, so the CIMD plugin's +minimum refetch interval is set to zero. Its other concurrency and revalidation +controls remain enabled. + +## Schema and result integration + +SendLit's REST contract remains on Zod 3, while SDK 2 expects Standard Schema. +`mcp/schema.ts` is the single conversion boundary: + +```text +Zod 3 schema + -> zod-to-json-schema (JSON Schema draft-07 target) + -> SDK 2 fromJsonSchema() + -> StandardSchemaWithJSON +``` + +MCP tool schemas reuse shared `@sendlit/api-contract` schemas where practical, +including email content, contact filters, and custom fields. The MCP catalog +also contains MCP-specific output schemas in `mcp/tools/schemas.ts`; therefore +the current implementation should not be described as having no handwritten +MCP schemas. + +Compiled schema adapters are cached once per process by the exhaustive tool +name. The actual server and request context remain per-request. + +`jsonResult()` produces matching text and structured results. It recursively +converts JavaScript `Date` instances to ISO strings before assigning either +`content` or `structuredContent`. MCP timestamp output schemas advertise the +actual JSON wire type (`string`, optionally nullable) rather than accepting a +JavaScript-only `Date`. This prevents SDK/client structured-output validation +errors after otherwise successful mutations. + +Tool handlers also pass returned rows through public-shape helpers such as +`omitInternal()` or domain-specific serializers so internal surrogate IDs and +stored secrets are not exposed. + +## Compatibility and integration inventory + +The implementation is not literally “only import `@modelcontextprotocol`,” but +there is no home-grown MCP protocol compatibility layer. + +| Component | Implementation | Purpose | +| ----------------------------- | ------------------------------------------ | --------------------------------------------------------------------------- | +| Modern `2026-07-28` serving | `@modelcontextprotocol/server` | Official per-request modern protocol implementation | +| 2025-era support | SDK option `legacy: "stateless"` | Official stateless fallback; no custom protocol translation | +| Node/Express bridge | `@modelcontextprotocol/node` | Official adapter from web-standard handler to Node request/response | +| Tool registration wrapper | SendLit `mcp/tool-registry.ts` | Scope enforcement, logging, and schema reuse | +| Zod 3 bridge | SendLit `mcp/schema.ts` | Converts existing schemas to SDK 2 Standard Schema | +| OAuth discovery | `@codelitdev/oauth-server-kit/mcp` | Publishes MCP/OAuth discovery routes | +| OAuth server | Better Auth OAuth provider and CIMD plugin | Authorization, PKCE, consent, tokens, CIMD, and DCR | +| Client metadata normalization | SendLit `auth/cimd-fetch.ts` | Node 24 fetch correction and narrowly scoped VS Code/Claude grant filtering | +| JSON result normalization | SendLit `mcp/tools/responses.ts` | Keeps text and structured content JSON-equivalent | + +The following compatibility mechanisms are not present: + +- SDK v1 alongside SDK v2; +- a separate hand-written 2025 MCP server; +- a protocol-envelope translator; +- a legacy MCP session database or in-memory session map; +- sticky-session routing; +- `Mcp-Session-Id` generation; or +- a standalone legacy SSE route. + +## Tool catalog and observability + +The request-local server registers 68 tools in a deterministic order across: + +1. contacts and segments; +2. templates and media; +3. sequences and broadcasts; +4. transactional email; +5. general settings and ESP configuration; +6. teams and API keys; and +7. delivery feedback, delivery events, and suppressions. + +Every registration includes an output schema and policy. Tool annotations +describe read-only, destructive, idempotent, and open-world behavior where +applicable. + +Request completion logs include method, tool name, status, duration, and auth +kind. Tool execution logs include tool name, required scope, scope decision, +outcome, duration, and auth kind. Arguments, credentials, message bodies, and +results are deliberately not logged by these MCP logging paths. + +There are not yet dedicated MCP metrics or alert definitions. Existing log and +application observability infrastructure can consume these records, but the +original plan's proposed dashboards and alerts should not be treated as +implemented. + +## Actual code map + +| File/area | Current responsibility | +| ------------------------------------------- | -------------------------------------------------------------------------------- | +| `apps/api/src/index.ts` | Mounts MCP before global Express body parsing | +| `apps/api/src/mcp/routes.ts` | Discovery routes, CORS, rate limit, auth pipeline, SDK 2 handler, and POST route | +| `apps/api/src/mcp/server.ts` | Pure request-local server factory and deterministic domain registration | +| `apps/api/src/mcp/auth-context.ts` | Converts verified Express auth fields into typed MCP `AuthInfo` | +| `apps/api/src/mcp/policy.ts` | Scope constants and exhaustive tool-to-scope policy | +| `apps/api/src/mcp/schema.ts` | Zod 3 to JSON Schema to SDK Standard Schema adapter | +| `apps/api/src/mcp/tool-registry.ts` | Schema cache, policy enforcement, registration wrapper, and safe logs | +| `apps/api/src/mcp/tools/schemas.ts` | MCP-specific output schemas | +| `apps/api/src/mcp/tools/responses.ts` | Shared error, text, structured-content, and date serialization helpers | +| `apps/api/src/mcp/tools/*.ts` | Thin domain adapters over existing SendLit queries and services | +| `apps/api/src/auth/middleware.ts` | Restricts MCP credentials and exposes verified request auth fields | +| `apps/api/src/auth/resolve-auth.ts` | Validates OAuth, API keys, and browser sessions before mode-specific filtering | +| `apps/api/src/auth/better-auth.ts` | OAuth provider, resource registration, team-selection hooks, CIMD, and DCR | +| `apps/api/src/auth/cimd-fetch.ts` | Secure pinned CIMD fetching and narrow client metadata normalization | +| `apps/api/src/mcp/*.test.ts` | Modern/legacy protocol, route, schema, auth-context, and catalog coverage | +| `apps/api/src/mcp/tools/*.test.ts` | Tool policy, tenant, serialization, and domain-adapter coverage | +| `apps/api/src/auth/*mcp*.test.ts` | OAuth discovery/metadata and MCP auth coverage | +| `apps/docs/content/docs/developers/mcp.mdx` | Public connection, auth, scope, and transport documentation | + +## Dependencies actually in use + +The installed API dependency set resolves to: + +- `@modelcontextprotocol/server@2.0.0`; +- `@modelcontextprotocol/node@2.0.0`; +- `@modelcontextprotocol/client@2.0.0` as a development/test dependency; +- `zod@3.25.76`; +- `zod-to-json-schema@3.25.2`; +- `better-auth@1.7.0-rc.4`; +- `@better-auth/oauth-provider@1.7.0-rc.4`; +- `@better-auth/cimd@1.7.0-rc.4`; and +- `@codelitdev/oauth-server-kit@0.1.0-alpha.0`. + +`apps/api/package.json` permits compatible SDK 2 patch/minor updates with +`^2.0.0`; the lockfile currently resolves the MCP packages to `2.0.0`. The +Better Auth packages are pinned to the same exact release candidate. + +The API has no direct dependency on the v1 `@modelcontextprotocol/sdk` package. +A transitive dependency may still install SDK v1 for another library; it is not +used by SendLit's MCP server implementation. + +## Verification currently present + +### Protocol and HTTP + +- an SDK 2 client pinned to `2026-07-28` connects and reports the modern era; +- `server/discover`, deterministic `tools/list`, cache hints, and catalog + refresh are exercised; +- protocol envelope/header mismatches are rejected; +- a raw `2025-11-25` initialize POST succeeds without an + `Mcp-Session-Id` response; +- `GET /mcp` returns 404, proving the retired standalone SSE route is absent; +- non-JSON content receives the SDK's 415 response; and +- browser preflight includes the modern protocol headers but excludes + `Mcp-Session-Id`. + +### Authentication and authorization + +- OAuth client identity and tenant identity remain separate; +- API-key secrets do not enter `AuthInfo`; +- browser sessions are rejected by MCP mode; +- the catalog exactly matches the default-deny policy registry; +- OAuth scopes allow and deny representative tools; +- fixed-team API keys receive the documented full-team behavior; +- authorization metadata advertises issuer protection, CIMD, DCR, and the MCP + scope set; and +- CIMD tests cover Node 24 address pinning, private/reserved address rejection, + exact VS Code/Claude normalization, and no relaxation for other clients. + +### Schemas and tools + +- all 68 tools appear once with descriptions, schemas, and annotations; +- representative tools are checked for auth, tenant isolation, and domain-call + behavior; +- date-valued database results are serialized consistently in text and + `structuredContent`; and +- a `create_contact` result containing database `Date` values validates against + its advertised MCP output schema. + +The latest focused verification run completed successfully with: + +```text +pnpm --filter @sendlit/api exec vitest run src/mcp +# 5 files, 49 tests passed + +pnpm --filter @sendlit/api typecheck +# passed +``` + +This focused run does not replace the full API test suite, build, REST/OpenAPI +validation, or production load/security testing. + +## Manual smoke-test procedure + +With Postgres, Redis, and Mailpit running: + +1. start the API with `pnpm dev:api`; +2. expose port 5000 through an HTTPS endpoint when the client requires HTTPS; +3. connect a modern SDK 2 client and confirm discovery, tool listing, and a safe + tool invocation; +4. connect VS Code or another 2025-era Streamable HTTP client and confirm the + same catalog and behavior; +5. test OAuth/CIMD and `x-sendlit-apikey` separately; +6. exercise safe contact/template/sequence reads and writes in a disposable + team; +7. route any send/test operation to Mailpit and a disposable recipient; +8. verify an insufficient-scope call and a cross-team access attempt; and +9. request `GET /mcp` and confirm the retired standalone SSE route is absent. + +Clients may cache tool schemas. Reconnect after changing advertised schemas or +the tool catalog. + +## Known limitations and production follow-up + +- Better Auth is still on `1.7.0-rc.4` and the OAuth server kit is on + `0.1.0-alpha.0`; both need an explicit production-readiness review. +- Public DCR intentionally expands the OAuth attack surface for Inspector and + other existing clients. PKCE, consent, resource binding, and scope checks + still apply, but DCR should be removed when supported clients no longer need + it. +- The custom CIMD fetcher needs an explicit response-size limit and independent + deadline before production. +- The two client-specific grant filters should be re-evaluated after Better + Auth or the clients change their metadata validation behavior. +- Team API keys are full-team credentials; independently scoped keys remain a + separate product feature. +- Fresh server construction registers 68 tools per request. Schema compilation + is cached, but construction and catalog performance have not been documented + with a load benchmark. +- MCP-specific logs exist, but dedicated metrics, dashboards, and alert rules + do not. +- MRTR approval/input flows are not implemented. High-impact operations rely on + scopes, annotations, descriptions, client behavior, and rate limiting. + +## Completion statement + +The migration itself is implemented: + +- SDK 2 is the direct MCP server implementation; +- modern requests use the stateless `2026-07-28` protocol; +- current 2025 Streamable HTTP clients use SDK 2's stateless legacy mode; +- both eras share one endpoint, server factory, tool catalog, auth context, and + policy registry; +- no retired standalone SSE or MCP session implementation remains; +- OAuth supports CIMD and compatibility DCR; +- all 68 tools are registered and policy-bound; and +- structured tool results are normalized to their advertised JSON schemas. + +The remaining items above are production-hardening work, not blockers to the +current development implementation. diff --git a/apps/api/docs/replace-oauth-server-with-better-auth.md b/apps/api/docs/replace-oauth-server-with-better-auth.md index 0fb9063..130d854 100644 --- a/apps/api/docs/replace-oauth-server-with-better-auth.md +++ b/apps/api/docs/replace-oauth-server-with-better-auth.md @@ -1,5 +1,10 @@ **PRD: Replace Custom OAuth2 With Better Auth** +> Historical design note: its transport decisions are superseded by +> [`mcp-2026-07-28-migration.md`](./mcp-2026-07-28-migration.md). The current +> MCP implementation prefers CIMD and retains public Dynamic Client +> Registration for existing clients. + **Objective** Replace SendLit’s custom OAuth2/auth implementation with Better Auth to support secure first-party web login, MCP OAuth, REST API authentication, and social login with Google plus Email OTP. diff --git a/apps/api/drizzle/0001_square_energizer.sql b/apps/api/drizzle/0001_square_energizer.sql new file mode 100644 index 0000000..134c57d --- /dev/null +++ b/apps/api/drizzle/0001_square_energizer.sql @@ -0,0 +1,70 @@ +CREATE TABLE IF NOT EXISTS "oauth_client_assertion" ( + "id" text PRIMARY KEY NOT NULL, + "expires_at" timestamp with time zone NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "oauth_client_resource" ( + "id" text PRIMARY KEY NOT NULL, + "client_id" text NOT NULL, + "resource_id" text NOT NULL, + "metadata" jsonb, + "created_at" timestamp with time zone +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "oauth_resource" ( + "id" text PRIMARY KEY NOT NULL, + "identifier" text NOT NULL, + "name" text NOT NULL, + "access_token_ttl" integer, + "refresh_token_ttl" integer, + "signing_algorithm" text, + "signing_key_id" text, + "allowed_scopes" text[], + "custom_claims" jsonb, + "dpop_bound_access_tokens_required" boolean DEFAULT false NOT NULL, + "disabled" boolean DEFAULT false NOT NULL, + "created_at" timestamp with time zone, + "updated_at" timestamp with time zone, + "policy_version" integer DEFAULT 1 NOT NULL, + "metadata" jsonb, + CONSTRAINT "oauth_resource_identifier_unique" UNIQUE("identifier") +); +--> statement-breakpoint +ALTER TABLE "oauth_access_token" ADD COLUMN "authorization_code_id" text;--> statement-breakpoint +ALTER TABLE "oauth_access_token" ADD COLUMN "resources" text[];--> statement-breakpoint +ALTER TABLE "oauth_access_token" ADD COLUMN "requested_user_info_claims" text[];--> statement-breakpoint +ALTER TABLE "oauth_access_token" ADD COLUMN "confirmation" jsonb;--> statement-breakpoint +ALTER TABLE "oauth_client" ADD COLUMN "client_discovery_id" text;--> statement-breakpoint +ALTER TABLE "oauth_client" ADD COLUMN "client_credentials_scopes" text[] DEFAULT '{}' NOT NULL;--> statement-breakpoint +ALTER TABLE "oauth_client" ADD COLUMN "backchannel_logout_uri" text;--> statement-breakpoint +ALTER TABLE "oauth_client" ADD COLUMN "backchannel_logout_session_required" boolean;--> statement-breakpoint +ALTER TABLE "oauth_client" ADD COLUMN "application_type" text;--> statement-breakpoint +ALTER TABLE "oauth_client" ADD COLUMN "jwks" text;--> statement-breakpoint +ALTER TABLE "oauth_client" ADD COLUMN "jwks_uri" text;--> statement-breakpoint +ALTER TABLE "oauth_client" ADD COLUMN "dpop_bound_access_tokens" boolean DEFAULT false;--> statement-breakpoint +ALTER TABLE "oauth_consent" ADD COLUMN "resources" text[];--> statement-breakpoint +ALTER TABLE "oauth_consent" ADD COLUMN "requested_user_info_claims" text[];--> statement-breakpoint +ALTER TABLE "oauth_refresh_token" ADD COLUMN "authorization_code_id" text;--> statement-breakpoint +ALTER TABLE "oauth_refresh_token" ADD COLUMN "resources" text[];--> statement-breakpoint +ALTER TABLE "oauth_refresh_token" ADD COLUMN "requested_user_info_claims" text[];--> statement-breakpoint +ALTER TABLE "oauth_refresh_token" ADD COLUMN "rotated_at" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "oauth_refresh_token" ADD COLUMN "rotation_replay_response" text;--> statement-breakpoint +ALTER TABLE "oauth_refresh_token" ADD COLUMN "rotation_replay_expires_at" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "oauth_refresh_token" ADD COLUMN "confirmation" jsonb;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "oauth_client_resource" ADD CONSTRAINT "oauth_client_resource_client_id_oauth_client_client_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."oauth_client"("client_id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "oauth_client_resource" ADD CONSTRAINT "oauth_client_resource_resource_id_oauth_resource_identifier_fk" FOREIGN KEY ("resource_id") REFERENCES "public"."oauth_resource"("identifier") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "auth_oauth_client_resource_client_id_idx" ON "oauth_client_resource" USING btree ("client_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "auth_oauth_client_resource_resource_id_idx" ON "oauth_client_resource" USING btree ("resource_id");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "auth_oauth_client_resource_client_id_resource_id_idx" ON "oauth_client_resource" USING btree ("client_id","resource_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "auth_oauth_access_token_authorization_code_id_idx" ON "oauth_access_token" USING btree ("authorization_code_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "auth_oauth_refresh_token_authorization_code_id_idx" ON "oauth_refresh_token" USING btree ("authorization_code_id"); \ No newline at end of file diff --git a/apps/api/drizzle/meta/0001_snapshot.json b/apps/api/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..d583c73 --- /dev/null +++ b/apps/api/drizzle/meta/0001_snapshot.json @@ -0,0 +1,6700 @@ +{ + "id": "fcf9682f-57e0-4aa2-9157-b81a2419254b", + "prevId": "a9326424-4448-4ed5-82df-534eb71adf5a", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "auth_account_user_id_idx": { + "name": "auth_account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.contact_custom_field_values": { + "name": "contact_custom_field_values", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value_type": { + "name": "value_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value_text": { + "name": "value_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "value_number": { + "name": "value_number", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "value_boolean": { + "name": "value_boolean", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "value_date": { + "name": "value_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "contact_custom_field_values_contact_key_idx": { + "name": "contact_custom_field_values_contact_key_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "contact_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "contact_custom_field_values_text_lookup_idx": { + "name": "contact_custom_field_values_text_lookup_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "value_text", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "contact_custom_field_values_number_lookup_idx": { + "name": "contact_custom_field_values_number_lookup_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "value_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "contact_custom_field_values_boolean_lookup_idx": { + "name": "contact_custom_field_values_boolean_lookup_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "value_boolean", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "contact_custom_field_values_date_lookup_idx": { + "name": "contact_custom_field_values_date_lookup_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "value_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "contact_custom_field_values_team_id_teams_id_fk": { + "name": "contact_custom_field_values_team_id_teams_id_fk", + "tableFrom": "contact_custom_field_values", + "tableTo": "teams", + "columnsFrom": ["team_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "contact_custom_field_values_contact_id_contacts_id_fk": { + "name": "contact_custom_field_values_contact_id_contacts_id_fk", + "tableFrom": "contact_custom_field_values", + "tableTo": "contacts", + "columnsFrom": ["contact_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.contacts": { + "name": "contacts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subscribed": { + "name": "subscribed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "custom_fields": { + "name": "custom_fields", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "unsubscribe_token": { + "name": "unsubscribe_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "contacts_team_id_email_idx": { + "name": "contacts_team_id_email_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "contacts_team_id_teams_id_fk": { + "name": "contacts_team_id_teams_id_fk", + "tableFrom": "contacts", + "tableTo": "teams", + "columnsFrom": ["team_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "contacts_contact_id_unique": { + "name": "contacts_contact_id_unique", + "nullsNotDistinct": false, + "columns": ["contact_id"] + }, + "contacts_unsubscribe_token_unique": { + "name": "contacts_unsubscribe_token_unique", + "nullsNotDistinct": false, + "columns": ["unsubscribe_token"] + } + }, + "policies": {}, + "checkConstraints": { + "contacts_contact_id_check": { + "name": "contacts_contact_id_check", + "value": "\"contacts\".\"contact_id\" ~ '^cnt_'" + } + }, + "isRLSEnabled": false + }, + "public.email_deliveries": { + "name": "email_deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sequence_id": { + "name": "sequence_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email_id": { + "name": "email_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "email_deliveries_team_id_teams_id_fk": { + "name": "email_deliveries_team_id_teams_id_fk", + "tableFrom": "email_deliveries", + "tableTo": "teams", + "columnsFrom": ["team_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_deliveries_sequence_id_sequences_id_fk": { + "name": "email_deliveries_sequence_id_sequences_id_fk", + "tableFrom": "email_deliveries", + "tableTo": "sequences", + "columnsFrom": ["sequence_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_deliveries_contact_id_contacts_id_fk": { + "name": "email_deliveries_contact_id_contacts_id_fk", + "tableFrom": "email_deliveries", + "tableTo": "contacts", + "columnsFrom": ["contact_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_deliveries_email_id_sequence_emails_id_fk": { + "name": "email_deliveries_email_id_sequence_emails_id_fk", + "tableFrom": "email_deliveries", + "tableTo": "sequence_emails", + "columnsFrom": ["email_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_delivery_events": { + "name": "email_delivery_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "receipt_id": { + "name": "receipt_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "outbound_message_id": { + "name": "outbound_message_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_event_key": { + "name": "provider_event_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_message_id": { + "name": "provider_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "recipient_email": { + "name": "recipient_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "normalized_recipient": { + "name": "normalized_recipient", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bounce_class": { + "name": "bounce_class", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "smtp_code": { + "name": "smtp_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "enhanced_status_code": { + "name": "enhanced_status_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "remote_mta": { + "name": "remote_mta", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "received_at": { + "name": "received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "email_delivery_events_connection_id_provider_event_key_idx": { + "name": "email_delivery_events_connection_id_provider_event_key_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_delivery_events_team_id_occurred_at_idx": { + "name": "email_delivery_events_team_id_occurred_at_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_delivery_events_outbound_message_id_idx": { + "name": "email_delivery_events_outbound_message_id_idx", + "columns": [ + { + "expression": "outbound_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_delivery_events_receipt_id_esp_webhook_receipts_id_fk": { + "name": "email_delivery_events_receipt_id_esp_webhook_receipts_id_fk", + "tableFrom": "email_delivery_events", + "tableTo": "esp_webhook_receipts", + "columnsFrom": ["receipt_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_delivery_events_connection_id_esp_feedback_connections_id_fk": { + "name": "email_delivery_events_connection_id_esp_feedback_connections_id_fk", + "tableFrom": "email_delivery_events", + "tableTo": "esp_feedback_connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_delivery_events_team_id_teams_id_fk": { + "name": "email_delivery_events_team_id_teams_id_fk", + "tableFrom": "email_delivery_events", + "tableTo": "teams", + "columnsFrom": ["team_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_delivery_events_outbound_message_id_outbound_messages_id_fk": { + "name": "email_delivery_events_outbound_message_id_outbound_messages_id_fk", + "tableFrom": "email_delivery_events", + "tableTo": "outbound_messages", + "columnsFrom": ["outbound_message_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "email_delivery_events_event_id_unique": { + "name": "email_delivery_events_event_id_unique", + "nullsNotDistinct": false, + "columns": ["event_id"] + } + }, + "policies": {}, + "checkConstraints": { + "email_delivery_events_event_id_check": { + "name": "email_delivery_events_event_id_check", + "value": "\"email_delivery_events\".\"event_id\" ~ '^evt_'" + } + }, + "isRLSEnabled": false + }, + "public.email_events": { + "name": "email_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sequence_id": { + "name": "sequence_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email_id": { + "name": "email_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "link_index": { + "name": "link_index", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "bounce_type": { + "name": "bounce_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bounce_reason": { + "name": "bounce_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "email_events_team_id_teams_id_fk": { + "name": "email_events_team_id_teams_id_fk", + "tableFrom": "email_events", + "tableTo": "teams", + "columnsFrom": ["team_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_events_sequence_id_sequences_id_fk": { + "name": "email_events_sequence_id_sequences_id_fk", + "tableFrom": "email_events", + "tableTo": "sequences", + "columnsFrom": ["sequence_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_events_contact_id_contacts_id_fk": { + "name": "email_events_contact_id_contacts_id_fk", + "tableFrom": "email_events", + "tableTo": "contacts", + "columnsFrom": ["contact_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_events_email_id_sequence_emails_id_fk": { + "name": "email_events_email_id_sequence_emails_id_fk", + "tableFrom": "email_events", + "tableTo": "sequence_emails", + "columnsFrom": ["email_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_suppression_actions": { + "name": "email_suppression_actions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "suppression_id": { + "name": "suppression_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_event_id": { + "name": "source_event_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_type": { + "name": "actor_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "explanation": { + "name": "explanation", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_suppression_actions_suppression_id_created_at_idx": { + "name": "email_suppression_actions_suppression_id_created_at_idx", + "columns": [ + { + "expression": "suppression_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_suppression_actions_team_id_teams_id_fk": { + "name": "email_suppression_actions_team_id_teams_id_fk", + "tableFrom": "email_suppression_actions", + "tableTo": "teams", + "columnsFrom": ["team_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_suppression_actions_suppression_id_email_suppressions_id_fk": { + "name": "email_suppression_actions_suppression_id_email_suppressions_id_fk", + "tableFrom": "email_suppression_actions", + "tableTo": "email_suppressions", + "columnsFrom": ["suppression_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_suppression_actions_source_event_id_email_delivery_events_id_fk": { + "name": "email_suppression_actions_source_event_id_email_delivery_events_id_fk", + "tableFrom": "email_suppression_actions", + "tableTo": "email_delivery_events", + "columnsFrom": ["source_event_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "email_suppression_actions_actor_user_id_user_id_fk": { + "name": "email_suppression_actions_actor_user_id_user_id_fk", + "tableFrom": "email_suppression_actions", + "tableTo": "user", + "columnsFrom": ["actor_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_suppressions": { + "name": "email_suppressions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "suppression_id": { + "name": "suppression_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "recipient_email": { + "name": "recipient_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "normalized_recipient": { + "name": "normalized_recipient", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "recipient_hash": { + "name": "recipient_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash_key_version": { + "name": "hash_key_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_event_id": { + "name": "source_event_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "first_suppressed_at": { + "name": "first_suppressed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_suppressed_at": { + "name": "last_suppressed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "released_at": { + "name": "released_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "released_by": { + "name": "released_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "release_reason": { + "name": "release_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "email_suppressions_team_id_recipient_hash_idx": { + "name": "email_suppressions_team_id_recipient_hash_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "recipient_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_suppressions_team_id_active_idx": { + "name": "email_suppressions_team_id_active_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_suppressions_team_id_teams_id_fk": { + "name": "email_suppressions_team_id_teams_id_fk", + "tableFrom": "email_suppressions", + "tableTo": "teams", + "columnsFrom": ["team_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_suppressions_source_event_id_email_delivery_events_id_fk": { + "name": "email_suppressions_source_event_id_email_delivery_events_id_fk", + "tableFrom": "email_suppressions", + "tableTo": "email_delivery_events", + "columnsFrom": ["source_event_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "email_suppressions_released_by_user_id_fk": { + "name": "email_suppressions_released_by_user_id_fk", + "tableFrom": "email_suppressions", + "tableTo": "user", + "columnsFrom": ["released_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "email_suppressions_suppression_id_unique": { + "name": "email_suppressions_suppression_id_unique", + "nullsNotDistinct": false, + "columns": ["suppression_id"] + } + }, + "policies": {}, + "checkConstraints": { + "email_suppressions_suppression_id_check": { + "name": "email_suppressions_suppression_id_check", + "value": "\"email_suppressions\".\"suppression_id\" ~ '^sup_'" + } + }, + "isRLSEnabled": false + }, + "public.email_templates": { + "name": "email_templates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "template_id": { + "name": "template_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "purpose": { + "name": "purpose", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'marketing'" + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "email_templates_team_id_title_idx": { + "name": "email_templates_team_id_title_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_templates_team_id_teams_id_fk": { + "name": "email_templates_team_id_teams_id_fk", + "tableFrom": "email_templates", + "tableTo": "teams", + "columnsFrom": ["team_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "email_templates_template_id_unique": { + "name": "email_templates_template_id_unique", + "nullsNotDistinct": false, + "columns": ["template_id"] + } + }, + "policies": {}, + "checkConstraints": { + "email_templates_template_id_check": { + "name": "email_templates_template_id_check", + "value": "\"email_templates\".\"template_id\" ~ '^tpl_'" + }, + "email_templates_purpose_check": { + "name": "email_templates_purpose_check", + "value": "\"email_templates\".\"purpose\" in ('marketing', 'transactional')" + } + }, + "isRLSEnabled": false + }, + "public.esp_config_team_grants": { + "name": "esp_config_team_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "grant_id": { + "name": "grant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "esp_config_id": { + "name": "esp_config_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "drain_until": { + "name": "drain_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reply_to": { + "name": "reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "daily_limit": { + "name": "daily_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "monthly_limit": { + "name": "monthly_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_by_type": { + "name": "created_by_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "esp_config_team_grants_non_revoked_team_idx": { + "name": "esp_config_team_grants_non_revoked_team_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"esp_config_team_grants\".\"status\" <> 'revoked'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "esp_config_team_grants_team_organization_fk": { + "name": "esp_config_team_grants_team_organization_fk", + "tableFrom": "esp_config_team_grants", + "tableTo": "teams", + "columnsFrom": ["team_id", "organization_id"], + "columnsTo": ["id", "organization_id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "esp_config_team_grants_esp_organization_fk": { + "name": "esp_config_team_grants_esp_organization_fk", + "tableFrom": "esp_config_team_grants", + "tableTo": "esp_configs", + "columnsFrom": ["esp_config_id", "organization_id"], + "columnsTo": ["id", "organization_id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "esp_config_team_grants_grant_id_unique": { + "name": "esp_config_team_grants_grant_id_unique", + "nullsNotDistinct": false, + "columns": ["grant_id"] + }, + "esp_config_team_grants_id_organization_id_unique": { + "name": "esp_config_team_grants_id_organization_id_unique", + "nullsNotDistinct": false, + "columns": ["id", "organization_id"] + }, + "esp_config_team_grants_id_team_esp_unique": { + "name": "esp_config_team_grants_id_team_esp_unique", + "nullsNotDistinct": false, + "columns": ["id", "team_id", "esp_config_id"] + } + }, + "policies": {}, + "checkConstraints": { + "esp_config_team_grants_public_id_check": { + "name": "esp_config_team_grants_public_id_check", + "value": "\"esp_config_team_grants\".\"grant_id\" ~ '^egr_'" + }, + "esp_config_team_grants_status_check": { + "name": "esp_config_team_grants_status_check", + "value": "\"esp_config_team_grants\".\"status\" IN ('active', 'draining', 'suspended', 'revoked')" + }, + "esp_config_team_grants_limit_check": { + "name": "esp_config_team_grants_limit_check", + "value": "(\"esp_config_team_grants\".\"daily_limit\" IS NULL OR \"esp_config_team_grants\".\"daily_limit\" >= 0)\n AND (\"esp_config_team_grants\".\"monthly_limit\" IS NULL OR \"esp_config_team_grants\".\"monthly_limit\" >= 0)" + }, + "esp_config_team_grants_created_by_type_check": { + "name": "esp_config_team_grants_created_by_type_check", + "value": "\"esp_config_team_grants\".\"created_by_type\" IN ('user', 'organization_key', 'system')" + } + }, + "isRLSEnabled": false + }, + "public.esp_configs": { + "name": "esp_configs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "esp_id": { + "name": "esp_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_scope": { + "name": "owner_scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'smtp'" + }, + "host": { + "name": "host", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 587 + }, + "secure": { + "name": "secure", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_secret": { + "name": "encrypted_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "secret_version": { + "name": "secret_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "last_tested_at": { + "name": "last_tested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_test_status": { + "name": "last_test_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_test_error": { + "name": "last_test_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "activated_at": { + "name": "activated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "drain_until": { + "name": "drain_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "retired_at": { + "name": "retired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "esp_configs_organization_id_idx": { + "name": "esp_configs_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "esp_configs_team_id_idx": { + "name": "esp_configs_team_id_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "esp_configs_organization_id_organizations_id_fk": { + "name": "esp_configs_organization_id_organizations_id_fk", + "tableFrom": "esp_configs", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "esp_configs_team_id_teams_id_fk": { + "name": "esp_configs_team_id_teams_id_fk", + "tableFrom": "esp_configs", + "tableTo": "teams", + "columnsFrom": ["team_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "esp_configs_esp_id_unique": { + "name": "esp_configs_esp_id_unique", + "nullsNotDistinct": false, + "columns": ["esp_id"] + }, + "esp_configs_id_organization_id_unique": { + "name": "esp_configs_id_organization_id_unique", + "nullsNotDistinct": false, + "columns": ["id", "organization_id"] + }, + "esp_configs_id_team_id_unique": { + "name": "esp_configs_id_team_id_unique", + "nullsNotDistinct": false, + "columns": ["id", "team_id"] + } + }, + "policies": {}, + "checkConstraints": { + "esp_configs_esp_id_check": { + "name": "esp_configs_esp_id_check", + "value": "\"esp_configs\".\"esp_id\" ~ '^esp_'" + }, + "esp_configs_owner_check": { + "name": "esp_configs_owner_check", + "value": "(\"esp_configs\".\"owner_scope\" = 'organization' AND \"esp_configs\".\"organization_id\" IS NOT NULL AND \"esp_configs\".\"team_id\" IS NULL)\n OR (\"esp_configs\".\"owner_scope\" = 'team' AND \"esp_configs\".\"organization_id\" IS NULL AND \"esp_configs\".\"team_id\" IS NOT NULL)" + }, + "esp_configs_status_check": { + "name": "esp_configs_status_check", + "value": "\"esp_configs\".\"status\" IN ('draft', 'active', 'suspended', 'draining', 'retired')" + } + }, + "isRLSEnabled": false + }, + "public.esp_feedback_connections": { + "name": "esp_feedback_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_scope": { + "name": "owner_scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "esp_config_id": { + "name": "esp_config_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_credentials": { + "name": "encrypted_credentials", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "previous_encrypted_credentials": { + "name": "previous_encrypted_credentials", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "previous_credential_expires_at": { + "name": "previous_credential_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expected_topic_arn": { + "name": "expected_topic_arn", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "last_received_at": { + "name": "last_received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_verified_at": { + "name": "last_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error_code": { + "name": "last_error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled_at": { + "name": "disabled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "esp_feedback_connections_team_id_idx": { + "name": "esp_feedback_connections_team_id_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "esp_feedback_connections_esp_config_active_idx": { + "name": "esp_feedback_connections_esp_config_active_idx", + "columns": [ + { + "expression": "esp_config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"esp_feedback_connections\".\"esp_config_id\" is not null and \"esp_feedback_connections\".\"status\" not in ('retiring', 'disabled')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "esp_feedback_connections_organization_id_organizations_id_fk": { + "name": "esp_feedback_connections_organization_id_organizations_id_fk", + "tableFrom": "esp_feedback_connections", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "esp_feedback_connections_team_id_teams_id_fk": { + "name": "esp_feedback_connections_team_id_teams_id_fk", + "tableFrom": "esp_feedback_connections", + "tableTo": "teams", + "columnsFrom": ["team_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "esp_feedback_connections_esp_config_id_esp_configs_id_fk": { + "name": "esp_feedback_connections_esp_config_id_esp_configs_id_fk", + "tableFrom": "esp_feedback_connections", + "tableTo": "esp_configs", + "columnsFrom": ["esp_config_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "esp_feedback_connections_connection_id_unique": { + "name": "esp_feedback_connections_connection_id_unique", + "nullsNotDistinct": false, + "columns": ["connection_id"] + } + }, + "policies": {}, + "checkConstraints": { + "esp_feedback_connections_connection_id_check": { + "name": "esp_feedback_connections_connection_id_check", + "value": "\"esp_feedback_connections\".\"connection_id\" ~ '^whc_'" + }, + "esp_feedback_connections_owner_check": { + "name": "esp_feedback_connections_owner_check", + "value": "(\n \"esp_feedback_connections\".\"owner_scope\" = 'organization'\n AND \"esp_feedback_connections\".\"organization_id\" IS NOT NULL\n AND \"esp_feedback_connections\".\"team_id\" IS NULL\n ) OR (\n \"esp_feedback_connections\".\"owner_scope\" = 'team'\n AND \"esp_feedback_connections\".\"organization_id\" IS NULL\n AND \"esp_feedback_connections\".\"team_id\" IS NOT NULL\n )" + } + }, + "isRLSEnabled": false + }, + "public.esp_webhook_receipts": { + "name": "esp_webhook_receipts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "receipt_id": { + "name": "receipt_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_request_id": { + "name": "provider_request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_sha256": { + "name": "body_sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_payload": { + "name": "encrypted_payload", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "safe_headers": { + "name": "safe_headers", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_attempts": { + "name": "processing_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error_code": { + "name": "last_error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "received_at": { + "name": "received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "esp_webhook_receipts_status_next_attempt_idx": { + "name": "esp_webhook_receipts_status_next_attempt_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "esp_webhook_receipts_connection_id_provider_request_id_idx": { + "name": "esp_webhook_receipts_connection_id_provider_request_id_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "esp_webhook_receipts_connection_id_esp_feedback_connections_id_fk": { + "name": "esp_webhook_receipts_connection_id_esp_feedback_connections_id_fk", + "tableFrom": "esp_webhook_receipts", + "tableTo": "esp_feedback_connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "esp_webhook_receipts_team_id_teams_id_fk": { + "name": "esp_webhook_receipts_team_id_teams_id_fk", + "tableFrom": "esp_webhook_receipts", + "tableTo": "teams", + "columnsFrom": ["team_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "esp_webhook_receipts_receipt_id_unique": { + "name": "esp_webhook_receipts_receipt_id_unique", + "nullsNotDistinct": false, + "columns": ["receipt_id"] + } + }, + "policies": {}, + "checkConstraints": { + "esp_webhook_receipts_receipt_id_check": { + "name": "esp_webhook_receipts_receipt_id_check", + "value": "\"esp_webhook_receipts\".\"receipt_id\" ~ '^whr_'" + } + }, + "isRLSEnabled": false + }, + "public.jwks": { + "name": "jwks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mail_dispatch_outbox": { + "name": "mail_dispatch_outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "dispatch_id": { + "name": "dispatch_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outbound_message_id": { + "name": "outbound_message_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "queue_name": { + "name": "queue_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "job_name": { + "name": "job_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "available_at": { + "name": "available_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "publish_attempts": { + "name": "publish_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mail_dispatch_outbox_due_idx": { + "name": "mail_dispatch_outbox_due_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mail_dispatch_outbox_outbound_message_id_outbound_messages_id_fk": { + "name": "mail_dispatch_outbox_outbound_message_id_outbound_messages_id_fk", + "tableFrom": "mail_dispatch_outbox", + "tableTo": "outbound_messages", + "columnsFrom": ["outbound_message_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mail_dispatch_outbox_dispatch_id_unique": { + "name": "mail_dispatch_outbox_dispatch_id_unique", + "nullsNotDistinct": false, + "columns": ["dispatch_id"] + }, + "mail_dispatch_outbox_outbound_message_id_unique": { + "name": "mail_dispatch_outbox_outbound_message_id_unique", + "nullsNotDistinct": false, + "columns": ["outbound_message_id"] + } + }, + "policies": {}, + "checkConstraints": { + "mail_dispatch_outbox_dispatch_id_check": { + "name": "mail_dispatch_outbox_dispatch_id_check", + "value": "\"mail_dispatch_outbox\".\"dispatch_id\" ~ '^mdj_'" + }, + "mail_dispatch_outbox_state_check": { + "name": "mail_dispatch_outbox_state_check", + "value": "\"mail_dispatch_outbox\".\"state\" IN ('pending', 'publishing', 'published', 'cancelled')" + } + }, + "isRLSEnabled": false + }, + "public.media": { + "name": "media", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "media_id": { + "name": "media_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "media_lit_id": { + "name": "media_lit_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thumbnail_url": { + "name": "thumbnail_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "alt": { + "name": "alt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "caption": { + "name": "caption", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "media_team_id_media_lit_id_idx": { + "name": "media_team_id_media_lit_id_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "media_lit_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "media_team_id_created_at_idx": { + "name": "media_team_id_created_at_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "media_team_id_teams_id_fk": { + "name": "media_team_id_teams_id_fk", + "tableFrom": "media", + "tableTo": "teams", + "columnsFrom": ["team_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "media_media_id_unique": { + "name": "media_media_id_unique", + "nullsNotDistinct": false, + "columns": ["media_id"] + } + }, + "policies": {}, + "checkConstraints": { + "media_media_id_check": { + "name": "media_media_id_check", + "value": "\"media\".\"media_id\" ~ '^med_'" + } + }, + "isRLSEnabled": false + }, + "public.media_references": { + "name": "media_references", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "media_id": { + "name": "media_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_internal_id": { + "name": "resource_internal_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "resource_public_id": { + "name": "resource_public_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_resource_internal_id": { + "name": "parent_resource_internal_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "parent_resource_public_id": { + "name": "parent_resource_public_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "media_references_resource_idx": { + "name": "media_references_resource_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "media_references_media_id_idx": { + "name": "media_references_media_id_idx", + "columns": [ + { + "expression": "media_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "media_references_resource_media_idx": { + "name": "media_references_resource_media_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "media_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "media_references_team_id_teams_id_fk": { + "name": "media_references_team_id_teams_id_fk", + "tableFrom": "media_references", + "tableTo": "teams", + "columnsFrom": ["team_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "media_references_media_id_media_id_fk": { + "name": "media_references_media_id_media_id_fk", + "tableFrom": "media_references", + "tableTo": "media", + "columnsFrom": ["media_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_access_token": { + "name": "oauth_access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authorization_code_id": { + "name": "authorization_code_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "requested_user_info_claims": { + "name": "requested_user_info_claims", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "refresh_id": { + "name": "refresh_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "confirmation": { + "name": "confirmation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "auth_oauth_access_token_client_id_idx": { + "name": "auth_oauth_access_token_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_oauth_access_token_session_id_idx": { + "name": "auth_oauth_access_token_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_oauth_access_token_user_id_idx": { + "name": "auth_oauth_access_token_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_oauth_access_token_authorization_code_id_idx": { + "name": "auth_oauth_access_token_authorization_code_id_idx", + "columns": [ + { + "expression": "authorization_code_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_oauth_access_token_refresh_id_idx": { + "name": "auth_oauth_access_token_refresh_id_idx", + "columns": [ + { + "expression": "refresh_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_access_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_access_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_session_id_session_id_fk": { + "name": "oauth_access_token_session_id_session_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_access_token_user_id_user_id_fk": { + "name": "oauth_access_token_user_id_user_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_refresh_id_oauth_refresh_token_id_fk": { + "name": "oauth_access_token_refresh_id_oauth_refresh_token_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_refresh_token", + "columnsFrom": ["refresh_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_token_token_unique": { + "name": "oauth_access_token_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_discovery_id": { + "name": "client_discovery_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "skip_consent": { + "name": "skip_consent", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enable_end_session": { + "name": "enable_end_session", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "client_credentials_scopes": { + "name": "client_credentials_scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contacts": { + "name": "contacts", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "tos": { + "name": "tos", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_id": { + "name": "software_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_version": { + "name": "software_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_statement": { + "name": "software_statement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "post_logout_redirect_uris": { + "name": "post_logout_redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "backchannel_logout_uri": { + "name": "backchannel_logout_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "backchannel_logout_session_required": { + "name": "backchannel_logout_session_required", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "application_type": { + "name": "application_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "jwks": { + "name": "jwks", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "jwks_uri": { + "name": "jwks_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_types": { + "name": "grant_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "response_types": { + "name": "response_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "require_pkce": { + "name": "require_pkce", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "dpop_bound_access_tokens": { + "name": "dpop_bound_access_tokens", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "auth_oauth_client_user_id_idx": { + "name": "auth_oauth_client_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_client_user_id_user_id_fk": { + "name": "oauth_client_user_id_user_id_fk", + "tableFrom": "oauth_client", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_client_client_id_unique": { + "name": "oauth_client_client_id_unique", + "nullsNotDistinct": false, + "columns": ["client_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client_assertion": { + "name": "oauth_client_assertion", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client_resource": { + "name": "oauth_client_resource", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "auth_oauth_client_resource_client_id_idx": { + "name": "auth_oauth_client_resource_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_oauth_client_resource_resource_id_idx": { + "name": "auth_oauth_client_resource_resource_id_idx", + "columns": [ + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_oauth_client_resource_client_id_resource_id_idx": { + "name": "auth_oauth_client_resource_client_id_resource_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_client_resource_client_id_oauth_client_client_id_fk": { + "name": "oauth_client_resource_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_client_resource", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_client_resource_resource_id_oauth_resource_identifier_fk": { + "name": "oauth_client_resource_resource_id_oauth_resource_identifier_fk", + "tableFrom": "oauth_client_resource", + "tableTo": "oauth_resource", + "columnsFrom": ["resource_id"], + "columnsTo": ["identifier"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_consent": { + "name": "oauth_consent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "requested_user_info_claims": { + "name": "requested_user_info_claims", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "auth_oauth_consent_client_id_idx": { + "name": "auth_oauth_consent_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_oauth_consent_user_id_idx": { + "name": "auth_oauth_consent_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_consent_client_id_oauth_client_client_id_fk": { + "name": "oauth_consent_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consent_user_id_user_id_fk": { + "name": "oauth_consent_user_id_user_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_post_login_team_selections": { + "name": "oauth_post_login_team_selections", + "schema": "", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_post_login_team_selections_session_id_session_id_fk": { + "name": "oauth_post_login_team_selections_session_id_session_id_fk", + "tableFrom": "oauth_post_login_team_selections", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_post_login_team_selections_team_id_teams_id_fk": { + "name": "oauth_post_login_team_selections_team_id_teams_id_fk", + "tableFrom": "oauth_post_login_team_selections", + "tableTo": "teams", + "columnsFrom": ["team_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_refresh_token": { + "name": "oauth_refresh_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authorization_code_id": { + "name": "authorization_code_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "requested_user_info_claims": { + "name": "requested_user_info_claims", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "revoked": { + "name": "revoked", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "rotated_at": { + "name": "rotated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "rotation_replay_response": { + "name": "rotation_replay_response", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rotation_replay_expires_at": { + "name": "rotation_replay_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "auth_time": { + "name": "auth_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "confirmation": { + "name": "confirmation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "auth_oauth_refresh_token_client_id_idx": { + "name": "auth_oauth_refresh_token_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_oauth_refresh_token_authorization_code_id_idx": { + "name": "auth_oauth_refresh_token_authorization_code_id_idx", + "columns": [ + { + "expression": "authorization_code_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_oauth_refresh_token_session_id_idx": { + "name": "auth_oauth_refresh_token_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_oauth_refresh_token_user_id_idx": { + "name": "auth_oauth_refresh_token_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_refresh_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_refresh_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_token_session_id_session_id_fk": { + "name": "oauth_refresh_token_session_id_session_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_refresh_token_user_id_user_id_fk": { + "name": "oauth_refresh_token_user_id_user_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_refresh_token_token_unique": { + "name": "oauth_refresh_token_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_resource": { + "name": "oauth_resource", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token_ttl": { + "name": "access_token_ttl", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "refresh_token_ttl": { + "name": "refresh_token_ttl", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "signing_algorithm": { + "name": "signing_algorithm", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "signing_key_id": { + "name": "signing_key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_scopes": { + "name": "allowed_scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "custom_claims": { + "name": "custom_claims", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "dpop_bound_access_tokens_required": { + "name": "dpop_bound_access_tokens_required", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "policy_version": { + "name": "policy_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_resource_identifier_unique": { + "name": "oauth_resource_identifier_unique", + "nullsNotDistinct": false, + "columns": ["identifier"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ongoing_sequences": { + "name": "ongoing_sequences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sequence_id": { + "name": "sequence_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "next_email_scheduled_time": { + "name": "next_email_scheduled_time", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "retry_count": { + "name": "retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sent_email_ids": { + "name": "sent_email_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "ongoing_sequences_sequence_id_contact_id_idx": { + "name": "ongoing_sequences_sequence_id_contact_id_idx", + "columns": [ + { + "expression": "sequence_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "contact_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ongoing_sequences_next_email_scheduled_time_idx": { + "name": "ongoing_sequences_next_email_scheduled_time_idx", + "columns": [ + { + "expression": "next_email_scheduled_time", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ongoing_sequences_team_id_teams_id_fk": { + "name": "ongoing_sequences_team_id_teams_id_fk", + "tableFrom": "ongoing_sequences", + "tableTo": "teams", + "columnsFrom": ["team_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ongoing_sequences_sequence_id_sequences_id_fk": { + "name": "ongoing_sequences_sequence_id_sequences_id_fk", + "tableFrom": "ongoing_sequences", + "tableTo": "sequences", + "columnsFrom": ["sequence_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ongoing_sequences_contact_id_contacts_id_fk": { + "name": "ongoing_sequences_contact_id_contacts_id_fk", + "tableFrom": "ongoing_sequences", + "tableTo": "contacts", + "columnsFrom": ["contact_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_api_keys": { + "name": "organization_api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "organization_api_key_id": { + "name": "organization_api_key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organization_api_keys_organization_id_idx": { + "name": "organization_api_keys_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_api_keys_organization_id_organizations_id_fk": { + "name": "organization_api_keys_organization_id_organizations_id_fk", + "tableFrom": "organization_api_keys", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_api_keys_created_by_user_id_user_id_fk": { + "name": "organization_api_keys_created_by_user_id_user_id_fk", + "tableFrom": "organization_api_keys", + "tableTo": "user", + "columnsFrom": ["created_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_api_keys_organization_api_key_id_unique": { + "name": "organization_api_keys_organization_api_key_id_unique", + "nullsNotDistinct": false, + "columns": ["organization_api_key_id"] + }, + "organization_api_keys_key_hash_unique": { + "name": "organization_api_keys_key_hash_unique", + "nullsNotDistinct": false, + "columns": ["key_hash"] + } + }, + "policies": {}, + "checkConstraints": { + "organization_api_keys_public_id_check": { + "name": "organization_api_keys_public_id_check", + "value": "\"organization_api_keys\".\"organization_api_key_id\" ~ '^oak_'" + } + }, + "isRLSEnabled": false + }, + "public.organization_audit_events": { + "name": "organization_audit_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "actor_type": { + "name": "actor_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "esp_config_id": { + "name": "esp_config_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "esp_grant_id": { + "name": "esp_grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organization_audit_events_organization_id_created_at_idx": { + "name": "organization_audit_events_organization_id_created_at_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "organization_audit_events_team_id_created_at_idx": { + "name": "organization_audit_events_team_id_created_at_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_audit_events_organization_id_organizations_id_fk": { + "name": "organization_audit_events_organization_id_organizations_id_fk", + "tableFrom": "organization_audit_events", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_delivery_policies": { + "name": "organization_delivery_policies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "default_esp_config_id": { + "name": "default_esp_config_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "auto_grant_default_esp": { + "name": "auto_grant_default_esp", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "default_daily_limit": { + "name": "default_daily_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "default_monthly_limit": { + "name": "default_monthly_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "aggregate_daily_limit": { + "name": "aggregate_daily_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "aggregate_monthly_limit": { + "name": "aggregate_monthly_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "team_esp_enabled_by_default": { + "name": "team_esp_enabled_by_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "team_can_change_default": { + "name": "team_can_change_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "organization_delivery_policies_organization_id_organizations_id_fk": { + "name": "organization_delivery_policies_organization_id_organizations_id_fk", + "tableFrom": "organization_delivery_policies", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_delivery_policies_default_esp_fk": { + "name": "organization_delivery_policies_default_esp_fk", + "tableFrom": "organization_delivery_policies", + "tableTo": "esp_configs", + "columnsFrom": ["default_esp_config_id", "organization_id"], + "columnsTo": ["id", "organization_id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_delivery_policies_organization_id_unique": { + "name": "organization_delivery_policies_organization_id_unique", + "nullsNotDistinct": false, + "columns": ["organization_id"] + } + }, + "policies": {}, + "checkConstraints": { + "organization_delivery_policies_limit_check": { + "name": "organization_delivery_policies_limit_check", + "value": "(\"organization_delivery_policies\".\"default_daily_limit\" IS NULL OR \"organization_delivery_policies\".\"default_daily_limit\" >= 0)\n AND (\"organization_delivery_policies\".\"default_monthly_limit\" IS NULL OR \"organization_delivery_policies\".\"default_monthly_limit\" >= 0)\n AND (\"organization_delivery_policies\".\"aggregate_daily_limit\" IS NULL OR \"organization_delivery_policies\".\"aggregate_daily_limit\" >= 0)\n AND (\"organization_delivery_policies\".\"aggregate_monthly_limit\" IS NULL OR \"organization_delivery_policies\".\"aggregate_monthly_limit\" >= 0)" + } + }, + "isRLSEnabled": false + }, + "public.organization_esp_quota_reservations": { + "name": "organization_esp_quota_reservations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "reservation_id": { + "name": "reservation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outbound_message_id": { + "name": "outbound_message_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "day_period_start": { + "name": "day_period_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "month_period_start": { + "name": "month_period_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'reserved'" + }, + "release_reason": { + "name": "release_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "committed_at": { + "name": "committed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "released_at": { + "name": "released_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "organization_esp_quota_reservations_outbound_message_id_outbound_messages_id_fk": { + "name": "organization_esp_quota_reservations_outbound_message_id_outbound_messages_id_fk", + "tableFrom": "organization_esp_quota_reservations", + "tableTo": "outbound_messages", + "columnsFrom": ["outbound_message_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "organization_esp_quota_reservations_grant_id_esp_config_team_grants_id_fk": { + "name": "organization_esp_quota_reservations_grant_id_esp_config_team_grants_id_fk", + "tableFrom": "organization_esp_quota_reservations", + "tableTo": "esp_config_team_grants", + "columnsFrom": ["grant_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "organization_esp_quota_reservations_organization_id_organizations_id_fk": { + "name": "organization_esp_quota_reservations_organization_id_organizations_id_fk", + "tableFrom": "organization_esp_quota_reservations", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "organization_esp_quota_reservations_grant_organization_fk": { + "name": "organization_esp_quota_reservations_grant_organization_fk", + "tableFrom": "organization_esp_quota_reservations", + "tableTo": "esp_config_team_grants", + "columnsFrom": ["grant_id", "organization_id"], + "columnsTo": ["id", "organization_id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_esp_quota_reservations_reservation_id_unique": { + "name": "organization_esp_quota_reservations_reservation_id_unique", + "nullsNotDistinct": false, + "columns": ["reservation_id"] + }, + "organization_esp_quota_reservations_outbound_message_id_unique": { + "name": "organization_esp_quota_reservations_outbound_message_id_unique", + "nullsNotDistinct": false, + "columns": ["outbound_message_id"] + } + }, + "policies": {}, + "checkConstraints": { + "organization_esp_quota_reservations_reservation_id_check": { + "name": "organization_esp_quota_reservations_reservation_id_check", + "value": "\"organization_esp_quota_reservations\".\"reservation_id\" ~ '^qrs_'" + }, + "organization_esp_quota_reservations_state_check": { + "name": "organization_esp_quota_reservations_state_check", + "value": "\"organization_esp_quota_reservations\".\"state\" IN ('reserved', 'committed', 'released')" + } + }, + "isRLSEnabled": false + }, + "public.organization_esp_usage_buckets": { + "name": "organization_esp_usage_buckets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "bucket_scope": { + "name": "bucket_scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "period_type": { + "name": "period_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period_start": { + "name": "period_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "reserved_count": { + "name": "reserved_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "accepted_count": { + "name": "accepted_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organization_esp_usage_buckets_grant_period_idx": { + "name": "organization_esp_usage_buckets_grant_period_idx", + "columns": [ + { + "expression": "grant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"organization_esp_usage_buckets\".\"grant_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "organization_esp_usage_buckets_organization_period_idx": { + "name": "organization_esp_usage_buckets_organization_period_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"organization_esp_usage_buckets\".\"bucket_scope\" = 'organization'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_esp_usage_buckets_organization_id_organizations_id_fk": { + "name": "organization_esp_usage_buckets_organization_id_organizations_id_fk", + "tableFrom": "organization_esp_usage_buckets", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "organization_esp_usage_buckets_grant_id_esp_config_team_grants_id_fk": { + "name": "organization_esp_usage_buckets_grant_id_esp_config_team_grants_id_fk", + "tableFrom": "organization_esp_usage_buckets", + "tableTo": "esp_config_team_grants", + "columnsFrom": ["grant_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "organization_esp_usage_buckets_grant_organization_fk": { + "name": "organization_esp_usage_buckets_grant_organization_fk", + "tableFrom": "organization_esp_usage_buckets", + "tableTo": "esp_config_team_grants", + "columnsFrom": ["grant_id", "organization_id"], + "columnsTo": ["id", "organization_id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "organization_esp_usage_buckets_scope_check": { + "name": "organization_esp_usage_buckets_scope_check", + "value": "(\n \"organization_esp_usage_buckets\".\"bucket_scope\" = 'grant' AND \"organization_esp_usage_buckets\".\"grant_id\" IS NOT NULL\n ) OR (\n \"organization_esp_usage_buckets\".\"bucket_scope\" = 'organization' AND \"organization_esp_usage_buckets\".\"grant_id\" IS NULL\n )" + }, + "organization_esp_usage_buckets_period_check": { + "name": "organization_esp_usage_buckets_period_check", + "value": "\"organization_esp_usage_buckets\".\"period_type\" IN ('day', 'month')" + }, + "organization_esp_usage_buckets_count_check": { + "name": "organization_esp_usage_buckets_count_check", + "value": "\"organization_esp_usage_buckets\".\"reserved_count\" >= 0 AND \"organization_esp_usage_buckets\".\"accepted_count\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.organization_members": { + "name": "organization_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organization_members_organization_id_user_id_idx": { + "name": "organization_members_organization_id_user_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_members_organization_id_organizations_id_fk": { + "name": "organization_members_organization_id_organizations_id_fk", + "tableFrom": "organization_members", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_members_user_id_user_id_fk": { + "name": "organization_members_user_id_user_id_fk", + "tableFrom": "organization_members", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "organization_members_role_check": { + "name": "organization_members_role_check", + "value": "\"organization_members\".\"role\" IN ('owner', 'admin', 'member')" + } + }, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organizations_organization_id_unique": { + "name": "organizations_organization_id_unique", + "nullsNotDistinct": false, + "columns": ["organization_id"] + } + }, + "policies": {}, + "checkConstraints": { + "organizations_organization_id_check": { + "name": "organizations_organization_id_check", + "value": "\"organizations\".\"organization_id\" ~ '^org_'" + }, + "organizations_status_check": { + "name": "organizations_status_check", + "value": "\"organizations\".\"status\" IN ('active', 'suspended', 'closed')" + } + }, + "isRLSEnabled": false + }, + "public.outbound_messages": { + "name": "outbound_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "delivery_source_type": { + "name": "delivery_source_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "esp_config_id": { + "name": "esp_config_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "esp_grant_id": { + "name": "esp_grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "feedback_connection_id": { + "name": "feedback_connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "submission_key": { + "name": "submission_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "campaign_delivery_id": { + "name": "campaign_delivery_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "transactional_email_id": { + "name": "transactional_email_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "recipient_email": { + "name": "recipient_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_recipient": { + "name": "normalized_recipient", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rfc_message_id": { + "name": "rfc_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_message_id": { + "name": "provider_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "delivery_status": { + "name": "delivery_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "feedback_status": { + "name": "feedback_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "bounced_at": { + "name": "bounced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "complained_at": { + "name": "complained_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_event_at": { + "name": "last_event_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "outbound_messages_team_id_created_at_idx": { + "name": "outbound_messages_team_id_created_at_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbound_messages_connection_provider_msg_idx": { + "name": "outbound_messages_connection_provider_msg_idx", + "columns": [ + { + "expression": "feedback_connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbound_messages_team_id_recipient_created_at_idx": { + "name": "outbound_messages_team_id_recipient_created_at_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "normalized_recipient", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "outbound_messages_team_id_teams_id_fk": { + "name": "outbound_messages_team_id_teams_id_fk", + "tableFrom": "outbound_messages", + "tableTo": "teams", + "columnsFrom": ["team_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "outbound_messages_esp_config_id_esp_configs_id_fk": { + "name": "outbound_messages_esp_config_id_esp_configs_id_fk", + "tableFrom": "outbound_messages", + "tableTo": "esp_configs", + "columnsFrom": ["esp_config_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "outbound_messages_esp_grant_id_esp_config_team_grants_id_fk": { + "name": "outbound_messages_esp_grant_id_esp_config_team_grants_id_fk", + "tableFrom": "outbound_messages", + "tableTo": "esp_config_team_grants", + "columnsFrom": ["esp_grant_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "outbound_messages_feedback_connection_id_esp_feedback_connections_id_fk": { + "name": "outbound_messages_feedback_connection_id_esp_feedback_connections_id_fk", + "tableFrom": "outbound_messages", + "tableTo": "esp_feedback_connections", + "columnsFrom": ["feedback_connection_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "outbound_messages_campaign_delivery_id_email_deliveries_id_fk": { + "name": "outbound_messages_campaign_delivery_id_email_deliveries_id_fk", + "tableFrom": "outbound_messages", + "tableTo": "email_deliveries", + "columnsFrom": ["campaign_delivery_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "outbound_messages_transactional_email_id_transactional_emails_id_fk": { + "name": "outbound_messages_transactional_email_id_transactional_emails_id_fk", + "tableFrom": "outbound_messages", + "tableTo": "transactional_emails", + "columnsFrom": ["transactional_email_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "outbound_messages_message_id_unique": { + "name": "outbound_messages_message_id_unique", + "nullsNotDistinct": false, + "columns": ["message_id"] + }, + "outbound_messages_submission_key_unique": { + "name": "outbound_messages_submission_key_unique", + "nullsNotDistinct": false, + "columns": ["submission_key"] + } + }, + "policies": {}, + "checkConstraints": { + "outbound_messages_message_id_check": { + "name": "outbound_messages_message_id_check", + "value": "\"outbound_messages\".\"message_id\" ~ '^msg_'" + }, + "outbound_messages_delivery_pin_check": { + "name": "outbound_messages_delivery_pin_check", + "value": "(\n \"outbound_messages\".\"delivery_source_type\" = 'team'\n AND \"outbound_messages\".\"esp_config_id\" IS NOT NULL\n AND \"outbound_messages\".\"esp_grant_id\" IS NULL\n ) OR (\n \"outbound_messages\".\"delivery_source_type\" = 'organization'\n AND \"outbound_messages\".\"esp_config_id\" IS NOT NULL\n AND \"outbound_messages\".\"esp_grant_id\" IS NOT NULL\n )" + } + }, + "isRLSEnabled": false + }, + "public.rules": { + "name": "rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "rule_id": { + "name": "rule_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sequence_id": { + "name": "sequence_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "event_date_in_millis": { + "name": "event_date_in_millis", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "event_data": { + "name": "event_data", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "rules_team_id_teams_id_fk": { + "name": "rules_team_id_teams_id_fk", + "tableFrom": "rules", + "tableTo": "teams", + "columnsFrom": ["team_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "rules_sequence_id_sequences_id_fk": { + "name": "rules_sequence_id_sequences_id_fk", + "tableFrom": "rules", + "tableTo": "sequences", + "columnsFrom": ["sequence_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "rules_rule_id_unique": { + "name": "rules_rule_id_unique", + "nullsNotDistinct": false, + "columns": ["rule_id"] + } + }, + "policies": {}, + "checkConstraints": { + "rules_rule_id_check": { + "name": "rules_rule_id_check", + "value": "\"rules\".\"rule_id\" ~ '^rule_'" + } + }, + "isRLSEnabled": false + }, + "public.segments": { + "name": "segments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "segment_id": { + "name": "segment_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filter": { + "name": "filter", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "segments_team_id_name_idx": { + "name": "segments_team_id_name_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "segments_team_id_teams_id_fk": { + "name": "segments_team_id_teams_id_fk", + "tableFrom": "segments", + "tableTo": "teams", + "columnsFrom": ["team_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "segments_segment_id_unique": { + "name": "segments_segment_id_unique", + "nullsNotDistinct": false, + "columns": ["segment_id"] + } + }, + "policies": {}, + "checkConstraints": { + "segments_segment_id_check": { + "name": "segments_segment_id_check", + "value": "\"segments\".\"segment_id\" ~ '^seg_'" + } + }, + "isRLSEnabled": false + }, + "public.sequence_emails": { + "name": "sequence_emails", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "sequence_id": { + "name": "sequence_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email_id": { + "name": "email_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "delay_in_millis": { + "name": "delay_in_millis", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 86400000 + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "template_id": { + "name": "template_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action_type": { + "name": "action_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action_data": { + "name": "action_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "sequence_emails_sequence_id_email_id_idx": { + "name": "sequence_emails_sequence_id_email_id_idx", + "columns": [ + { + "expression": "sequence_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sequence_emails_sequence_id_sequences_id_fk": { + "name": "sequence_emails_sequence_id_sequences_id_fk", + "tableFrom": "sequence_emails", + "tableTo": "sequences", + "columnsFrom": ["sequence_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "sequence_emails_email_id_check": { + "name": "sequence_emails_email_id_check", + "value": "\"sequence_emails\".\"email_id\" ~ '^email_'" + } + }, + "isRLSEnabled": false + }, + "public.sequences": { + "name": "sequences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sequence_id": { + "name": "sequence_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "delivery_source_intent": { + "name": "delivery_source_intent", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "delivery_source_type": { + "name": "delivery_source_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outbox_id": { + "name": "outbox_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "esp_grant_id": { + "name": "esp_grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trigger_data": { + "name": "trigger_data", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "filter": { + "name": "filter", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "exclude_filter": { + "name": "exclude_filter", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "emails_order": { + "name": "emails_order", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "entrants": { + "name": "entrants", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "report": { + "name": "report", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sequences_team_id_teams_id_fk": { + "name": "sequences_team_id_teams_id_fk", + "tableFrom": "sequences", + "tableTo": "teams", + "columnsFrom": ["team_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sequences_outbox_id_esp_configs_id_fk": { + "name": "sequences_outbox_id_esp_configs_id_fk", + "tableFrom": "sequences", + "tableTo": "esp_configs", + "columnsFrom": ["outbox_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "sequences_esp_grant_id_esp_config_team_grants_id_fk": { + "name": "sequences_esp_grant_id_esp_config_team_grants_id_fk", + "tableFrom": "sequences", + "tableTo": "esp_config_team_grants", + "columnsFrom": ["esp_grant_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sequences_sequence_id_unique": { + "name": "sequences_sequence_id_unique", + "nullsNotDistinct": false, + "columns": ["sequence_id"] + } + }, + "policies": {}, + "checkConstraints": { + "sequences_sequence_id_check": { + "name": "sequences_sequence_id_check", + "value": "\"sequences\".\"sequence_id\" ~ '^seq_'" + }, + "sequences_delivery_pin_check": { + "name": "sequences_delivery_pin_check", + "value": "(\n \"sequences\".\"delivery_source_type\" IS NULL\n AND \"sequences\".\"outbox_id\" IS NULL\n AND \"sequences\".\"esp_grant_id\" IS NULL\n ) OR (\n \"sequences\".\"delivery_source_type\" = 'team'\n AND \"sequences\".\"outbox_id\" IS NOT NULL\n AND \"sequences\".\"esp_grant_id\" IS NULL\n ) OR (\n \"sequences\".\"delivery_source_type\" = 'organization'\n AND \"sequences\".\"outbox_id\" IS NOT NULL\n AND \"sequences\".\"esp_grant_id\" IS NOT NULL\n )" + } + }, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "auth_session_user_id_idx": { + "name": "auth_session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "mailing_address": { + "name": "mailing_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_team_id_teams_id_fk": { + "name": "settings_team_id_teams_id_fk", + "tableFrom": "settings", + "tableTo": "teams", + "columnsFrom": ["team_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_team_id_unique": { + "name": "settings_team_id_unique", + "nullsNotDistinct": false, + "columns": ["team_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_api_keys": { + "name": "team_api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "team_api_key_id": { + "name": "team_api_key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by_type": { + "name": "created_by_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "created_by_id": { + "name": "created_by_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "team_api_keys_team_id_idx": { + "name": "team_api_keys_team_id_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_api_keys_team_id_teams_id_fk": { + "name": "team_api_keys_team_id_teams_id_fk", + "tableFrom": "team_api_keys", + "tableTo": "teams", + "columnsFrom": ["team_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "team_api_keys_team_api_key_id_unique": { + "name": "team_api_keys_team_api_key_id_unique", + "nullsNotDistinct": false, + "columns": ["team_api_key_id"] + }, + "team_api_keys_key_hash_unique": { + "name": "team_api_keys_key_hash_unique", + "nullsNotDistinct": false, + "columns": ["key_hash"] + } + }, + "policies": {}, + "checkConstraints": { + "team_api_keys_public_id_check": { + "name": "team_api_keys_public_id_check", + "value": "\"team_api_keys\".\"team_api_key_id\" ~ '^tak_'" + }, + "team_api_keys_created_by_type_check": { + "name": "team_api_keys_created_by_type_check", + "value": "\"team_api_keys\".\"created_by_type\" IN ('user', 'organization_key', 'system')" + } + }, + "isRLSEnabled": false + }, + "public.team_delivery_settings": { + "name": "team_delivery_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "team_esp_enabled": { + "name": "team_esp_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "team_can_change_default": { + "name": "team_can_change_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "default_source": { + "name": "default_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_team_esp_config_id": { + "name": "default_team_esp_config_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_delivery_settings_team_id_teams_id_fk": { + "name": "team_delivery_settings_team_id_teams_id_fk", + "tableFrom": "team_delivery_settings", + "tableTo": "teams", + "columnsFrom": ["team_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_delivery_settings_default_team_esp_fk": { + "name": "team_delivery_settings_default_team_esp_fk", + "tableFrom": "team_delivery_settings", + "tableTo": "esp_configs", + "columnsFrom": ["default_team_esp_config_id", "team_id"], + "columnsTo": ["id", "team_id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "team_delivery_settings_team_id_unique": { + "name": "team_delivery_settings_team_id_unique", + "nullsNotDistinct": false, + "columns": ["team_id"] + } + }, + "policies": {}, + "checkConstraints": { + "team_delivery_settings_default_source_check": { + "name": "team_delivery_settings_default_source_check", + "value": "\"team_delivery_settings\".\"default_source\" IS NULL OR \"team_delivery_settings\".\"default_source\" IN ('organization', 'team')" + } + }, + "isRLSEnabled": false + }, + "public.team_members": { + "name": "team_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "team_members_team_id_user_id_idx": { + "name": "team_members_team_id_user_id_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_members_team_id_teams_id_fk": { + "name": "team_members_team_id_teams_id_fk", + "tableFrom": "team_members", + "tableTo": "teams", + "columnsFrom": ["team_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_members_user_id_user_id_fk": { + "name": "team_members_user_id_user_id_fk", + "tableFrom": "team_members", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "team_members_role_check": { + "name": "team_members_role_check", + "value": "\"team_members\".\"role\" IN ('admin', 'member')" + } + }, + "isRLSEnabled": false + }, + "public.teams": { + "name": "teams", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provisioning_request_hash": { + "name": "provisioning_request_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "teams_organization_id_external_id_idx": { + "name": "teams_organization_id_external_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"teams\".\"external_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "teams_organization_id_organizations_id_fk": { + "name": "teams_organization_id_organizations_id_fk", + "tableFrom": "teams", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "teams_team_id_unique": { + "name": "teams_team_id_unique", + "nullsNotDistinct": false, + "columns": ["team_id"] + }, + "teams_id_organization_id_unique": { + "name": "teams_id_organization_id_unique", + "nullsNotDistinct": false, + "columns": ["id", "organization_id"] + } + }, + "policies": {}, + "checkConstraints": { + "teams_team_id_check": { + "name": "teams_team_id_check", + "value": "\"teams\".\"team_id\" ~ '^team_'" + }, + "teams_status_check": { + "name": "teams_status_check", + "value": "\"teams\".\"status\" IN ('active', 'sending_suspended', 'archived')" + } + }, + "isRLSEnabled": false + }, + "public.transactional_emails": { + "name": "transactional_emails", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "txe_id": { + "name": "txe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "delivery_source_type": { + "name": "delivery_source_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outbox_id": { + "name": "outbox_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "esp_grant_id": { + "name": "esp_grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "to_email": { + "name": "to_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reply_to": { + "name": "reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template_id": { + "name": "template_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "html": { + "name": "html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "headers": { + "name": "headers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "contact_id": { + "name": "contact_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "track_opens": { + "name": "track_opens", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "track_clicks": { + "name": "track_clicks", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "open_count": { + "name": "open_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "click_count": { + "name": "click_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "transactional_emails_team_id_idempotency_key_idx": { + "name": "transactional_emails_team_id_idempotency_key_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"transactional_emails\".\"idempotency_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "transactional_emails_team_id_created_at_idx": { + "name": "transactional_emails_team_id_created_at_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "transactional_emails_team_id_status_idx": { + "name": "transactional_emails_team_id_status_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "transactional_emails_team_id_teams_id_fk": { + "name": "transactional_emails_team_id_teams_id_fk", + "tableFrom": "transactional_emails", + "tableTo": "teams", + "columnsFrom": ["team_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "transactional_emails_outbox_id_esp_configs_id_fk": { + "name": "transactional_emails_outbox_id_esp_configs_id_fk", + "tableFrom": "transactional_emails", + "tableTo": "esp_configs", + "columnsFrom": ["outbox_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "transactional_emails_esp_grant_id_esp_config_team_grants_id_fk": { + "name": "transactional_emails_esp_grant_id_esp_config_team_grants_id_fk", + "tableFrom": "transactional_emails", + "tableTo": "esp_config_team_grants", + "columnsFrom": ["esp_grant_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "transactional_emails_contact_id_contacts_id_fk": { + "name": "transactional_emails_contact_id_contacts_id_fk", + "tableFrom": "transactional_emails", + "tableTo": "contacts", + "columnsFrom": ["contact_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "transactional_emails_txe_id_unique": { + "name": "transactional_emails_txe_id_unique", + "nullsNotDistinct": false, + "columns": ["txe_id"] + } + }, + "policies": {}, + "checkConstraints": { + "transactional_emails_txe_id_check": { + "name": "transactional_emails_txe_id_check", + "value": "\"transactional_emails\".\"txe_id\" ~ '^txe_'" + }, + "transactional_emails_delivery_pin_check": { + "name": "transactional_emails_delivery_pin_check", + "value": "(\n \"transactional_emails\".\"delivery_source_type\" = 'team'\n AND \"transactional_emails\".\"outbox_id\" IS NOT NULL\n AND \"transactional_emails\".\"esp_grant_id\" IS NULL\n ) OR (\n \"transactional_emails\".\"delivery_source_type\" = 'organization'\n AND \"transactional_emails\".\"outbox_id\" IS NOT NULL\n AND \"transactional_emails\".\"esp_grant_id\" IS NOT NULL\n )" + } + }, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_organization_id": { + "name": "default_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "user_default_organization_id_organizations_id_fk": { + "name": "user_default_organization_id_organizations_id_fk", + "tableFrom": "user", + "tableTo": "organizations", + "columnsFrom": ["default_organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "auth_verification_identifier_idx": { + "name": "auth_verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/api/drizzle/meta/_journal.json b/apps/api/drizzle/meta/_journal.json index fea283c..0b771b1 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1785470058751, "tag": "0000_equal_blue_blade", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1786279046698, + "tag": "0001_square_energizer", + "breakpoints": true } ] } diff --git a/apps/api/package.json b/apps/api/package.json index e0f9f63..ac78f99 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -19,15 +19,17 @@ "typecheck": "tsc -p tsconfig.test.json" }, "dependencies": { - "@better-auth/oauth-provider": "^1.6.23", + "@better-auth/cimd": "1.7.0-rc.4", + "@better-auth/oauth-provider": "1.7.0-rc.4", "@codelitdev/oauth-server-kit": "0.1.0-alpha.0", - "@modelcontextprotocol/sdk": "^1.29.0", + "@modelcontextprotocol/node": "^2.0.0", + "@modelcontextprotocol/server": "^2.0.0", "@sendlit/api-contract": "workspace:*", "@sendlit/email-blocks": "workspace:*", "@sendlit/email-editor": "workspace:*", "@ts-rest/express": "^3.52.1", "@ts-rest/open-api": "^3.52.1", - "better-auth": "^1.6.23", + "better-auth": "1.7.0-rc.4", "bullmq": "^5.34.0", "cors": "^2.8.5", "dotenv": "^17.2.3", @@ -48,10 +50,12 @@ "svix": "^1.97.0", "swagger-ui-express": "^5.0.1", "uuidv7": "^1.0.2", - "zod": "^3.25.76" + "zod": "^3.25.76", + "zod-to-json-schema": "^3.25.2" }, "devDependencies": { "@electric-sql/pglite": "^0.2.17", + "@modelcontextprotocol/client": "^2.0.0", "@types/cors": "^2.8.12", "@types/express": "^4.17.20", "@types/jsdom": "^21.1.7", diff --git a/apps/api/src/auth/better-auth-mcp.test.ts b/apps/api/src/auth/better-auth-mcp.test.ts new file mode 100644 index 0000000..fd88647 --- /dev/null +++ b/apps/api/src/auth/better-auth-mcp.test.ts @@ -0,0 +1,95 @@ +import { beforeAll, describe, expect, it, vi } from "vitest"; +import { MCP_SCOPES_SUPPORTED } from "../mcp/policy"; + +const dbMock = vi.hoisted(() => ({ + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ limit: vi.fn(async () => []) })), + })), + })), + insert: vi.fn(() => ({ + values: vi.fn(() => ({ returning: vi.fn(async () => [{}]) })), + })), +})); + +vi.mock("../db/client", () => ({ db: dbMock })); +vi.mock("../organization/queries", () => ({ + ensureDefaultOrganization: vi.fn(), +})); + +describe("Better Auth MCP authorization metadata", () => { + let auth: (typeof import("./better-auth.js"))["auth"]; + let defaultScopes: (typeof import("./better-auth.js"))["OAUTH_CLIENT_DEFAULT_SCOPES"]; + let requestableScopes: (typeof import("./better-auth.js"))["OAUTH_CLIENT_REQUESTABLE_SCOPES"]; + + beforeAll(async () => { + vi.stubEnv( + "BETTER_AUTH_SECRET", + "test-only-secret-with-at-least-thirty-two-characters", + ); + vi.stubEnv("API_PUBLIC_URL", "https://sendlit.test"); + const betterAuthModule = await import("./better-auth.js"); + auth = betterAuthModule.auth; + defaultScopes = betterAuthModule.OAUTH_CLIENT_DEFAULT_SCOPES; + requestableScopes = betterAuthModule.OAUTH_CLIENT_REQUESTABLE_SCOPES; + }); + + it("keeps registration defaults identity-only while allowing MCP scopes", () => { + expect(defaultScopes).toEqual(["openid", "profile", "email"]); + expect(requestableScopes).toEqual( + expect.arrayContaining(["offline_access", ...MCP_SCOPES_SUPPORTED]), + ); + expect(defaultScopes).not.toEqual( + expect.arrayContaining([...MCP_SCOPES_SUPPORTED]), + ); + }); + + it.each(["", " "])( + "rejects authorization without an explicit scope (%j)", + async (scope) => { + const query = new URLSearchParams({ + response_type: "code", + client_id: "test-client", + redirect_uri: "https://client.example/callback", + code_challenge: "test-code-challenge", + code_challenge_method: "S256", + }); + if (scope.length > 0) query.set("scope", scope); + + const response = await auth.handler( + new Request( + `https://sendlit.test/api/auth/oauth2/authorize?${query}`, + ), + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ + error: "invalid_scope", + error_description: + "OAuth authorization requests must include an explicit scope.", + }); + }, + ); + + it("advertises CIMD, DCR, issuer protection, and all enforced MCP scopes", async () => { + const response = await auth.handler( + new Request( + "https://sendlit.test/api/auth/.well-known/oauth-authorization-server", + ), + ); + const metadata = await response.json(); + + expect(response.status).toBe(200); + expect(metadata).toMatchObject({ + issuer: "https://sendlit.test/api/auth", + client_id_metadata_document_supported: true, + authorization_response_iss_parameter_supported: true, + }); + expect(metadata.scopes_supported).toEqual( + expect.arrayContaining([...MCP_SCOPES_SUPPORTED]), + ); + expect(metadata.registration_endpoint).toBe( + "https://sendlit.test/api/auth/oauth2/register", + ); + }); +}); diff --git a/apps/api/src/auth/better-auth.ts b/apps/api/src/auth/better-auth.ts index 0b200dd..eea8927 100644 --- a/apps/api/src/auth/better-auth.ts +++ b/apps/api/src/auth/better-auth.ts @@ -1,9 +1,11 @@ import { betterAuth } from "better-auth"; +import { APIError, createAuthMiddleware } from "better-auth/api"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { emailOTP } from "better-auth/plugins/email-otp"; import { jwt } from "better-auth/plugins/jwt"; import { oauthProvider } from "@better-auth/oauth-provider"; import { oauthProviderResourceClient } from "@better-auth/oauth-provider/resource-client"; +import { cimd } from "@better-auth/cimd"; import { createOAuthProviderOptions } from "@codelitdev/oauth-server-kit/better-auth"; import type { HostedLoginMethod } from "@codelitdev/oauth-server-kit/express"; import { createTransport } from "nodemailer"; @@ -15,6 +17,8 @@ import { createSendLitOAuthTeamSelectionHooks, oauthTeamSelectionAdapter, } from "./oauth-team-selection"; +import { MCP_SCOPES_SUPPORTED } from "../mcp/policy"; +import { fetchClientMetadataResource } from "./cimd-fetch"; export const webClientUrl = process.env.WEB_CLIENT || "http://localhost:3000"; const apiUrl = process.env.API_PUBLIC_URL || process.env.BETTER_AUTH_URL; @@ -49,6 +53,19 @@ export const mcpResourceUrl = `${authBaseUrl}/mcp`; * single source of truth shared between `oauthProvider({ validAudiences })` * below and `resolve-auth.ts`'s bearer verification. */ export const validOAuthAudiences = [authBaseUrl, mcpResourceUrl]; +export const OAUTH_CLIENT_DEFAULT_SCOPES = [ + "openid", + "profile", + "email", +] as const; +export const OAUTH_CLIENT_REQUESTABLE_SCOPES = [ + "offline_access", + ...MCP_SCOPES_SUPPORTED, +] as const; +const supportedOAuthScopes = [ + ...OAUTH_CLIENT_DEFAULT_SCOPES, + ...OAUTH_CLIENT_REQUESTABLE_SCOPES, +] as const; /** Where an MCP client should discover `mcpResourceUrl`'s protected-resource * metadata (RFC 9728: `/.well-known/oauth-protected-resource`). @@ -162,6 +179,25 @@ export const auth = betterAuth({ }, }, }, + hooks: { + before: createAuthMiddleware(async (context) => { + if (context.path !== "/oauth2/authorize") return; + + const scope = context.query?.scope; + if (typeof scope === "string" && scope.trim().length > 0) return; + + // Better Auth persists the union of registration defaults and + // allowed scopes as the client's capability set, then treats that + // complete set as requested when `scope` is omitted. Require an + // explicit choice so a scope-less request can never become a + // full-access MCP grant. + throw new APIError("BAD_REQUEST", { + error: "invalid_scope", + error_description: + "OAuth authorization requests must include an explicit scope.", + }); + }), + }, plugins: [ emailOTP({ async sendVerificationOTP({ email, otp }) { @@ -180,45 +216,53 @@ export const auth = betterAuth({ ...createOAuthProviderOptions({ loginPage: `${authBaseUrl}/oauth/login`, consentPage: `${authBaseUrl}/oauth/consent`, - // MCP clients may register their own public OAuth client. - // Registration is constrained to SendLit's supported scopes. + // Prefer CIMD for modern clients, while retaining public DCR + // for existing MCP clients such as Inspector. Registration + // creates only a public OAuth client; user authentication, + // consent, resource binding, PKCE, and scope enforcement still + // apply before any access token is issued. allowDynamicClientRegistration: true, allowUnauthenticatedDynamicClientRegistration: true, - scopes: [ - "openid", - "profile", - "email", - "offline_access", - "contacts:read", - "contacts:write", - "templates:read", - "templates:write", - "media:read", - "media:write", - "broadcasts:write", - "sequences:read", - "sequences:write", - ], + scopes: supportedOAuthScopes, validAudiences: validOAuthAudiences, - clientRegistrationDefaultScopes: ["openid", "profile", "email"], - clientRegistrationAllowedScopes: [ - "offline_access", - "contacts:read", - "contacts:write", - "templates:read", - "templates:write", - "media:read", - "media:write", - "broadcasts:write", - "sequences:read", - "sequences:write", - ], + // Registration establishes capabilities, not grants. Keep the + // default set identity-only and allow clients to explicitly + // request the narrower MCP scope set they present at consent. + clientRegistrationDefaultScopes: OAUTH_CLIENT_DEFAULT_SCOPES, + clientRegistrationAllowedScopes: + OAUTH_CLIENT_REQUESTABLE_SCOPES, }), + // The authorization request's RFC 8707 `resource` value must + // resolve to a persisted provider resource. Seed the sole current + // remote-MCP resource and link it to CIMD clients at registration. + resources: [ + { + identifier: mcpResourceUrl, + name: "SendLit MCP", + allowedScopes: [...MCP_SCOPES_SUPPORTED], + }, + ], + clientRegistrationDefaultResources: [mcpResourceUrl], ...createSendLitOAuthTeamSelectionHooks({ page: `${authBaseUrl}/oauth/select-team`, adapter: oauthTeamSelectionAdapter, }), }), + cimd({ + fetchClientMetadataResource, + metadataProfile: "mcp-2026-07-28", + metadataRevalidationInterval: "60m", + // VS Code serves its canonical CIMD document with `no-store`. + // OAuth must then fetch it once for authorization and again + // immediately for the authorization-code token exchange. Better + // Auth's one-second default rejects that required second fetch as + // temporarily_unavailable; retain its concurrency/rate budgets + // but allow the same flow to revalidate without a delay. + metadataFetchPolicy: { + minimumFetchInterval: 0, + }, + originBoundFields: ["post_logout_redirect_uris", "client_uri"], + }), ], }); diff --git a/apps/api/src/auth/cimd-fetch.test.ts b/apps/api/src/auth/cimd-fetch.test.ts new file mode 100644 index 0000000..2601f57 --- /dev/null +++ b/apps/api/src/auth/cimd-fetch.test.ts @@ -0,0 +1,135 @@ +import { EventEmitter } from "node:events"; +import { Readable } from "node:stream"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + lookup: vi.fn(), + request: vi.fn(), +})); + +vi.mock("node:dns/promises", () => ({ lookup: mocks.lookup })); +vi.mock("node:https", () => ({ request: mocks.request })); + +import { fetchClientMetadataResource } from "./cimd-fetch"; + +describe("CIMD Node fetcher", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + function mockMetadataDocument( + metadata: Record, + inspectOptions?: (options: any) => void, + ) { + mocks.request.mockImplementation((...args: any[]) => { + const options = args[1]; + inspectOptions?.(options); + const response = Object.assign( + Readable.from([Buffer.from(JSON.stringify(metadata))]), + { + statusCode: 200, + statusMessage: "OK", + headers: { "content-type": "application/json" }, + }, + ); + queueMicrotask(() => args[2](response)); + return Object.assign(new EventEmitter(), { end: vi.fn() }); + }); + } + + it("returns a pinned address array when Node requests lookup all=true", async () => { + const pinnedAddress = { address: "13.107.213.48", family: 4 }; + mocks.lookup.mockResolvedValue([pinnedAddress]); + let lookupResult: unknown; + mockMetadataDocument( + { + client_name: "Visual Studio Code", + grant_types: [ + "authorization_code", + "refresh_token", + "urn:ietf:params:oauth:grant-type:device_code", + ], + }, + (options) => + options.lookup( + "vscode.dev", + { all: true }, + (_error: unknown, addresses: unknown) => { + lookupResult = addresses; + }, + ), + ); + + const response = await fetchClientMetadataResource( + "https://vscode.dev/oauth/client-metadata.json", + ); + + expect(lookupResult).toEqual([pinnedAddress]); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + client_name: "Visual Studio Code", + grant_types: ["authorization_code", "refresh_token"], + }); + }); + + it("rejects private and reserved DNS answers before opening a connection", async () => { + mocks.lookup.mockResolvedValue([{ address: "127.0.0.1", family: 4 }]); + + await expect( + fetchClientMetadataResource("https://metadata.example/client.json"), + ).rejects.toThrow("public-routable"); + expect(mocks.request).not.toHaveBeenCalled(); + + mocks.lookup.mockResolvedValue([{ address: "192.0.2.1", family: 4 }]); + await expect( + fetchClientMetadataResource("https://metadata.example/client.json"), + ).rejects.toThrow("public-routable"); + expect(mocks.request).not.toHaveBeenCalled(); + }); + + it("retains Claude's supported grants and removes only JWT bearer", async () => { + mocks.lookup.mockResolvedValue([ + { address: "104.18.32.47", family: 4 }, + ]); + mockMetadataDocument({ + client_id: "https://claude.ai/oauth/mcp-oauth-client-metadata", + grant_types: [ + "authorization_code", + "refresh_token", + "urn:ietf:params:oauth:grant-type:jwt-bearer", + ], + }); + + const response = await fetchClientMetadataResource( + "https://claude.ai/oauth/mcp-oauth-client-metadata", + ); + + await expect(response.json()).resolves.toEqual({ + client_id: "https://claude.ai/oauth/mcp-oauth-client-metadata", + grant_types: ["authorization_code", "refresh_token"], + }); + }); + + it("does not relax grant validation for other CIMD clients", async () => { + mocks.lookup.mockResolvedValue([ + { address: "93.184.216.34", family: 4 }, + ]); + mockMetadataDocument({ + grant_types: [ + "authorization_code", + "urn:ietf:params:oauth:grant-type:device_code", + ], + }); + + const response = await fetchClientMetadataResource( + "https://client.example/metadata.json", + ); + + await expect(response.json()).resolves.toMatchObject({ + grant_types: [ + "authorization_code", + "urn:ietf:params:oauth:grant-type:device_code", + ], + }); + }); +}); diff --git a/apps/api/src/auth/cimd-fetch.ts b/apps/api/src/auth/cimd-fetch.ts new file mode 100644 index 0000000..e8bdba9 --- /dev/null +++ b/apps/api/src/auth/cimd-fetch.ts @@ -0,0 +1,199 @@ +import type { ClientMetadataResourceFetch } from "@better-auth/oauth-provider"; +import { lookup } from "node:dns/promises"; +import { request } from "node:https"; +import { isIP } from "node:net"; +import { Readable } from "node:stream"; + +const bodyForbiddenResponseStatuses = new Set([204, 205, 304]); +const vscodeClientMetadataUrl = "https://vscode.dev/oauth/client-metadata.json"; +const claudeClientMetadataUrl = + "https://claude.ai/oauth/mcp-oauth-client-metadata"; +const deviceCodeGrantType = "urn:ietf:params:oauth:grant-type:device_code"; +const jwtBearerGrantType = "urn:ietf:params:oauth:grant-type:jwt-bearer"; +const ignoredGrantTypesByClientMetadataUrl = new Map>([ + [vscodeClientMetadataUrl, new Set([deviceCodeGrantType])], + [claudeClientMetadataUrl, new Set([jwtBearerGrantType])], +]); + +/** Reject every IPv4 range and IPv6 address class that must never be fetched + * as a client-metadata origin. This intentionally permits only globally + * routable unicast addresses; it is used after DNS resolution, not for user + * supplied hostnames. */ +function isPublicRoutableAddress(address: string): boolean { + const family = isIP(address); + if (family === 4) { + const [a, b, c] = address.split(".").map(Number); + if ( + a === 0 || + a === 10 || + a === 127 || + a >= 224 || + (a === 100 && b >= 64 && b <= 127) || + (a === 169 && b === 254) || + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 0 && (c === 0 || c === 2 || c === 88)) || + (a === 192 && b === 168) || + (a === 198 && (b === 18 || b === 19)) || + (a === 198 && b === 51 && c === 100) || + (a === 203 && b === 0 && c === 113) + ) { + return false; + } + return true; + } + + if (family === 6) { + // Global-unicast IPv6 is exactly 2000::/3. This excludes unspecified, + // loopback, IPv4-mapped, unique-local, link-local, multicast, and + // documentation/reserved ranges without needing a second IP parser. + const firstHextet = Number.parseInt(address.split(":")[0] || "0", 16); + return firstHextet >= 0x2000 && firstHextet <= 0x3fff; + } + + return false; +} + +function toResponseHeaders( + headers: Record, +) { + const result = new Headers(); + for (const [name, value] of Object.entries(headers)) { + if (Array.isArray(value)) { + for (const item of value) result.append(name, item); + } else if (value !== undefined) { + result.append(name, value); + } + } + return result; +} + +/** + * Fetch a CIMD document without allowing DNS rebinding or redirects. + * + * This is intentionally equivalent to Better Auth's secure Node fetcher, but + * correctly supports Node 24's `lookup(..., { all: true })` callback contract. + * The 1.7.0-rc.4 helper returns one address for that mode, which makes every + * public CIMD request fail in Node 24 with ERR_INVALID_IP_ADDRESS. + */ +const fetchPinnedClientMetadataResource: ClientMetadataResourceFetch = async ( + input, + init, +) => { + const webRequest = new Request(input, init); + const url = new URL(webRequest.url); + if (url.protocol !== "https:") { + throw new TypeError("CIMD Node transport requires an HTTPS URL"); + } + if (webRequest.method !== "GET" && webRequest.method !== "HEAD") { + throw new TypeError("CIMD Node transport supports only GET and HEAD"); + } + + const addresses = await lookup(url.hostname, { all: true, verbatim: true }); + if (addresses.length === 0) { + throw new TypeError("metadata hostname returned no DNS addresses"); + } + for (const address of addresses) { + if (!isPublicRoutableAddress(address.address)) { + throw new TypeError( + "metadata hostname must resolve only to public-routable addresses", + ); + } + } + const pinnedAddress = addresses[0]; + const headers = Object.fromEntries(webRequest.headers.entries()); + headers.host = url.host; + const signal = + init?.signal ?? + (input instanceof Request ? input.signal : webRequest.signal); + + return new Promise((resolve, reject) => { + const nodeRequest = request( + url, + { + agent: false, + headers, + method: webRequest.method, + servername: + isIP(url.hostname.replace(/^\[|\]$/g, "")) === 0 + ? url.hostname + : undefined, + signal, + lookup: (_hostname, options, callback) => { + // Node 24 enables `all` when establishing a connection. + // In that mode Node requires `LookupAddress[]`, not the + // traditional `(address, family)` callback values. + if (options.all) { + callback(null, [pinnedAddress]); + return; + } + callback(null, pinnedAddress.address, pinnedAddress.family); + }, + }, + (response) => { + const status = response.statusCode ?? 500; + const body = + webRequest.method === "HEAD" || + bodyForbiddenResponseStatuses.has(status) + ? null + : Readable.toWeb(response); + resolve( + new Response(body as BodyInit | null, { + headers: toResponseHeaders(response.headers), + status, + statusText: response.statusMessage, + }), + ); + }, + ); + nodeRequest.once("error", reject); + nodeRequest.end(); + }); +}; + +/** + * Some first-party MCP clients publish every grant they can use across OAuth + * providers. VS Code includes Device Authorization and Claude includes the JWT + * bearer grant alongside authorization-code and refresh-token grants. SendLit + * does not implement those optional grants; both clients use Authorization + * Code + PKCE for this MCP connection. + * + * Keep the exceptions exact and deterministic: only each client's canonical + * metadata URL has its known unsupported declaration removed before + * registration. Every other CIMD document receives strict provider validation. + */ +export const fetchClientMetadataResource: ClientMetadataResourceFetch = async ( + input, + init, +) => { + const response = await fetchPinnedClientMetadataResource(input, init); + const requestUrl = input instanceof Request ? input.url : String(input); + const ignoredGrantTypes = ignoredGrantTypesByClientMetadataUrl.get( + new URL(requestUrl).href, + ); + if (!ignoredGrantTypes) return response; + if (response.status !== 200) return response; + + const metadata = (await response.json()) as { + grant_types?: unknown; + [key: string]: unknown; + }; + if (!Array.isArray(metadata.grant_types)) return response; + + const headers = new Headers(response.headers); + headers.delete("content-length"); + return new Response( + JSON.stringify({ + ...metadata, + grant_types: metadata.grant_types.filter( + (grantType) => + typeof grantType !== "string" || + !ignoredGrantTypes.has(grantType), + ), + }), + { + headers, + status: response.status, + statusText: response.statusText, + }, + ); +}; diff --git a/apps/api/src/auth/middleware.test.ts b/apps/api/src/auth/middleware.test.ts new file mode 100644 index 0000000..73800aa --- /dev/null +++ b/apps/api/src/auth/middleware.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("./better-auth", () => ({ + mcpProtectedResourceMetadataUrl: + "https://sendlit.test/.well-known/oauth-protected-resource/mcp", +})); +vi.mock("./resolve-auth", () => ({ + resolveAuth: vi.fn(), + sendAuthError: vi.fn(() => false), +})); + +import { createAuthMiddleware } from "./middleware"; + +function responseDouble() { + const response: any = { + statusCode: 200, + headers: {} as Record, + body: undefined as unknown, + setHeader(name: string, value: string) { + this.headers[name] = value; + }, + status(code: number) { + this.statusCode = code; + return this; + }, + json(body: unknown) { + this.body = body; + return this; + }, + }; + return response; +} + +describe("MCP auth middleware", () => { + it("rejects browser sessions", async () => { + const resolver = vi.fn(async () => ({ + status: "authenticated" as const, + kind: "session" as const, + identity: { method: "session" as const }, + user: { id: "user-1" }, + userId: "user-1", + scopes: ["web"] as ["web"], + })); + const middleware = createAuthMiddleware( + "mcp", + resolver as any, + "https://sendlit.test/.well-known/oauth-protected-resource/mcp", + ); + const req: any = { headers: {}, body: undefined }; + const res = responseDouble(); + const next = vi.fn(); + + await middleware(req, res, next); + + expect(res.statusCode).toBe(401); + expect(res.headers["WWW-Authenticate"]).toContain( + "oauth-protected-resource/mcp", + ); + expect(next).not.toHaveBeenCalled(); + expect(req.auth).toBeUndefined(); + }); + + it("retains stable non-secret identity for team API keys", async () => { + const resolver = vi.fn(async () => ({ + status: "authenticated" as const, + kind: "team_key" as const, + user: null, + apiKeyId: "tak_public", + teamId: "team-1", + })); + const middleware = createAuthMiddleware("mcp", resolver as any); + const req: any = { + headers: { "x-sendlit-apikey": "sl_live_secret" }, + body: undefined, + }; + const res = responseDouble(); + const next = vi.fn(); + + await middleware(req, res, next); + + expect(next).toHaveBeenCalledOnce(); + expect(req).toMatchObject({ + authKind: "team_key", + apiKeyId: "tak_public", + teamId: "team-1", + }); + const { headers: _headers, body: _body, ...authState } = req; + expect(JSON.stringify(authState)).not.toContain("sl_live_secret"); + }); +}); diff --git a/apps/api/src/auth/middleware.ts b/apps/api/src/auth/middleware.ts index 402e4d1..d87e205 100644 --- a/apps/api/src/auth/middleware.ts +++ b/apps/api/src/auth/middleware.ts @@ -22,7 +22,7 @@ function applyAuthToRequest( req.user = auth.user; if (auth.kind === "team_key") { - req.apikey = auth.apiKey; + req.apiKeyId = auth.apiKeyId; // A key authenticates as exactly one, fixed team — no further // resolution needed (see `require-team.ts`). req.teamId = auth.teamId; @@ -42,6 +42,7 @@ function applyAuthToRequest( } if (mode === "mcp" && auth.kind === "oauth") { + req.oauthToken = auth.token; req.clientId = auth.clientId; req.scopes = auth.scopes; } @@ -68,6 +69,24 @@ export function createAuthMiddleware( if (sendAuthError(res, auth, resourceMetadataUrl)) return; if (auth.status !== "authenticated") return; + // Remote MCP authentication is intentionally limited to OAuth + // bearer tokens and fixed-team API keys. Dashboard cookies are a + // first-party web concern and must not silently authenticate MCP. + if (mode === "mcp" && auth.kind === "session") { + if (resourceMetadataUrl) { + res.setHeader( + "WWW-Authenticate", + `Bearer resource_metadata="${resourceMetadataUrl}"`, + ); + } + res.status(401).json({ + error: "unauthorized", + error_description: + "MCP requires an OAuth bearer token or x-sendlit-apikey header.", + }); + return; + } + applyAuthToRequest(req, auth, mode); if (auth.kind === "oauth" || auth.kind === "session") { req.auth = auth.identity; diff --git a/apps/api/src/auth/resolve-auth.test.ts b/apps/api/src/auth/resolve-auth.test.ts index 2c122f0..527c71b 100644 --- a/apps/api/src/auth/resolve-auth.test.ts +++ b/apps/api/src/auth/resolve-auth.test.ts @@ -167,7 +167,7 @@ describe("resolveAuth", () => { ).resolves.toMatchObject({ status: "authenticated", kind: "team_key", - apiKey: "api-key", + apiKeyId: "tak_1", teamId: "team-1", }); @@ -176,7 +176,7 @@ describe("resolveAuth", () => { ).resolves.toMatchObject({ status: "authenticated", kind: "team_key", - apiKey: "body-key", + apiKeyId: "tak_1", teamId: "team-1", }); }); diff --git a/apps/api/src/auth/resolve-auth.ts b/apps/api/src/auth/resolve-auth.ts index 11fc58c..1e5ac11 100644 --- a/apps/api/src/auth/resolve-auth.ts +++ b/apps/api/src/auth/resolve-auth.ts @@ -1,6 +1,5 @@ import { fromNodeHeaders } from "better-auth/node"; import { - verifyOAuthAccessToken, type AuthenticatedIdentity, type AuthenticationResult, } from "@codelitdev/oauth-server-kit"; @@ -36,6 +35,7 @@ export type AuthResult = identity: Extract; user: User; userId: string; + token: string; clientId: string; scopes: string[]; teamId?: string; @@ -52,7 +52,7 @@ export type AuthResult = status: "authenticated"; kind: "team_key"; user: null; - apiKey: string; + apiKeyId: string; teamId: string; } | { @@ -95,22 +95,15 @@ function headerValue(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; } -/** Product claims are decoded only after oauth-server-kit has verified the - * token's signature, issuer, audience, and expiry. They are then revalidated - * against current SendLit membership below. */ -function teamClaimFromVerifiedJwt(token: string): string | undefined { - try { - const parts = token.split("."); - if (parts.length !== 3) return undefined; - const payload = JSON.parse( - Buffer.from(parts[1], "base64url").toString("utf8"), - ) as Record; - return typeof payload.team_id === "string" - ? payload.team_id - : undefined; - } catch { - return undefined; - } +function optionalString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function stringClaims(value: unknown): string[] { + if (typeof value === "string") return [value]; + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === "string") + : []; } const defaultDependencies: AuthDependencies = { @@ -124,25 +117,49 @@ const defaultDependencies: AuthDependencies = { ); }, async authenticateBearer(token) { - const result = await verifyOAuthAccessToken( - { - oauthResourceClient, - audiences: validOAuthAudiences, - issuer: authIssuer, - resourceMetadataMappings: { mcp: mcpResourceUrl }, - }, - token, - ); - if ( - result.status !== "authenticated" || - result.identity.method !== "oauth" - ) { + try { + // This is Better Auth's current resource-server API. The prior + // oauth-server-kit verifier expected an obsolete + // `getActions().verifyAccessToken` method, so every otherwise + // valid SDK 2 bearer token was rejected after OAuth completed. + const claims = await oauthResourceClient + .getActions() + .verifyBearerToken(token, { + verifyOptions: { + issuer: authIssuer, + audience: validOAuthAudiences, + }, + resourceMetadataMappings: { mcp: mcpResourceUrl }, + }); + const subject = optionalString(claims.sub); + const clientId = + optionalString(claims.azp) ?? optionalString(claims.client_id); + const audiences = stringClaims(claims.aud).filter((audience) => + validOAuthAudiences.includes(audience), + ); + if (!subject || !clientId || audiences.length === 0) return null; + const email = optionalString(claims.email); + const name = optionalString(claims.name); + const scopes = stringClaims(claims.scope).flatMap((scope) => + scope.split(/\s+/).filter(Boolean), + ); + + return { + identity: { + method: "oauth", + issuer: authIssuer, + subject, + ...(email ? { email } : {}), + ...(name ? { name } : {}), + clientId, + scopes, + audiences, + }, + teamId: optionalString(claims.team_id), + }; + } catch { return null; } - return { - identity: result.identity, - teamId: teamClaimFromVerifiedJwt(token), - }; }, ensureDefaultOrganization, getTeamMembership, @@ -197,6 +214,7 @@ export async function resolveAuth( identity: authenticated.identity, user, userId: user.id, + token, clientId: authenticated.identity.clientId, scopes: authenticated.identity.scopes, teamId, @@ -213,7 +231,7 @@ export async function resolveAuth( status: "authenticated", kind: "team_key", user: null, - apiKey: submittedApiKey, + apiKeyId: apiKey.teamApiKeyId, teamId: apiKey.teamId, }; } diff --git a/apps/api/src/auth/rest-bearer-token.test.ts b/apps/api/src/auth/rest-bearer-token.test.ts index 04943f5..a861507 100644 --- a/apps/api/src/auth/rest-bearer-token.test.ts +++ b/apps/api/src/auth/rest-bearer-token.test.ts @@ -12,7 +12,7 @@ vi.mock("../db/client", async () => { const tdb = db as unknown as TestDb; const mocks = vi.hoisted(() => ({ - verifyOAuthAccessToken: vi.fn(), + verifyBearerToken: vi.fn(), })); vi.mock("./better-auth", () => ({ @@ -26,11 +26,9 @@ vi.mock("./better-auth", () => ({ "https://sendlit.test/.well-known/oauth-protected-resource/mcp", mcpResourceUrl: "https://sendlit.test/mcp", validOAuthAudiences: ["https://sendlit.test", "https://sendlit.test/mcp"], - oauthResourceClient: {}, -})); - -vi.mock("@codelitdev/oauth-server-kit", () => ({ - verifyOAuthAccessToken: mocks.verifyOAuthAccessToken, + oauthResourceClient: { + getActions: () => ({ verifyBearerToken: mocks.verifyBearerToken }), + }, })); vi.mock("better-auth/node", () => ({ @@ -110,7 +108,7 @@ async function request( describe("REST bearer token authentication", () => { beforeEach(async () => { await truncateAll(tdb); - mocks.verifyOAuthAccessToken.mockReset(); + mocks.verifyBearerToken.mockReset(); }); afterAll(async () => { @@ -122,16 +120,12 @@ describe("REST bearer token authentication", () => { account: { email: "owner@example.com" }, contact: { email: "reader@example.com" }, }); - mocks.verifyOAuthAccessToken.mockResolvedValueOnce({ - status: "authenticated", - identity: { - method: "oauth", - issuer: "https://sendlit.test/api/auth", - subject: account.id, - clientId: "sendlit-local-token", - scopes: ["contacts:read"], - audiences: ["https://sendlit.test"], - }, + mocks.verifyBearerToken.mockResolvedValueOnce({ + iss: "https://sendlit.test/api/auth", + sub: account.id, + azp: "sendlit-local-token", + scope: "contacts:read", + aud: "https://sendlit.test", }); const response = await request(contactsRoutes, "/contacts", { @@ -148,9 +142,13 @@ describe("REST bearer token authentication", () => { }, ], }); - expect(mocks.verifyOAuthAccessToken).toHaveBeenCalledWith( - expect.any(Object), + expect(mocks.verifyBearerToken).toHaveBeenCalledWith( "valid-oauth-token", + expect.objectContaining({ + verifyOptions: expect.objectContaining({ + issuer: "https://sendlit.test/api/auth", + }), + }), ); }); }); diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index ec350b4..caa3f21 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -309,11 +309,18 @@ export const oauthClient = pgTable( id: text("id").primaryKey(), clientId: text("client_id").notNull().unique(), clientSecret: text("client_secret"), + // Required for CIMD ownership and refresh. Discovery-owned clients + // must not be mutable through managed-client paths. + clientDiscoveryId: text("client_discovery_id"), disabled: boolean("disabled").default(false), skipConsent: boolean("skip_consent"), enableEndSession: boolean("enable_end_session"), subjectType: text("subject_type"), scopes: text("scopes").array(), + clientCredentialsScopes: text("client_credentials_scopes") + .array() + .notNull() + .default([]), userId: text("user_id").references(() => user.id, { onDelete: "cascade", }), @@ -330,12 +337,22 @@ export const oauthClient = pgTable( softwareStatement: text("software_statement"), redirectUris: text("redirect_uris").array().notNull(), postLogoutRedirectUris: text("post_logout_redirect_uris").array(), + backchannelLogoutUri: text("backchannel_logout_uri"), + backchannelLogoutSessionRequired: boolean( + "backchannel_logout_session_required", + ), tokenEndpointAuthMethod: text("token_endpoint_auth_method"), + applicationType: text("application_type"), + jwks: text("jwks"), + jwksUri: text("jwks_uri"), grantTypes: text("grant_types").array(), responseTypes: text("response_types").array(), public: boolean("public"), type: text("type"), requirePKCE: boolean("require_pkce"), + dpopBoundAccessTokens: boolean("dpop_bound_access_tokens").default( + false, + ), referenceId: text("reference_id"), metadata: jsonb("metadata"), }, @@ -344,6 +361,60 @@ export const oauthClient = pgTable( }), ); +/** Better Auth OAuth Provider's persistent protected-resource registry. + * CIMD authorization uses this to bind the MCP resource indicator to its + * allowed scopes and token policy. */ +export const oauthResource = pgTable("oauth_resource", { + id: text("id").primaryKey(), + identifier: text("identifier").notNull().unique(), + name: text("name").notNull(), + accessTokenTtl: integer("access_token_ttl"), + refreshTokenTtl: integer("refresh_token_ttl"), + signingAlgorithm: text("signing_algorithm"), + signingKeyId: text("signing_key_id"), + allowedScopes: text("allowed_scopes").array(), + customClaims: jsonb("custom_claims"), + dpopBoundAccessTokensRequired: boolean("dpop_bound_access_tokens_required") + .notNull() + .default(false), + disabled: boolean("disabled").notNull().default(false), + createdAt: timestamp("created_at", { withTimezone: true }), + updatedAt: timestamp("updated_at", { withTimezone: true }), + policyVersion: integer("policy_version").notNull().default(1), + metadata: jsonb("metadata"), +}); + +/** Optional per-client resource linkage used by Better Auth's OAuth provider. + * The provider keeps this table even when resource enforcement is currently + * permissive, so future policy tightening needs no schema rewrite. */ +export const oauthClientResource = pgTable( + "oauth_client_resource", + { + id: text("id").primaryKey(), + clientId: text("client_id") + .notNull() + .references(() => oauthClient.clientId, { onDelete: "cascade" }), + resourceId: text("resource_id") + .notNull() + .references(() => oauthResource.identifier, { + onDelete: "cascade", + }), + metadata: jsonb("metadata"), + createdAt: timestamp("created_at", { withTimezone: true }), + }, + (table) => ({ + clientIdIdx: index("auth_oauth_client_resource_client_id_idx").on( + table.clientId, + ), + resourceIdIdx: index("auth_oauth_client_resource_resource_id_idx").on( + table.resourceId, + ), + clientResourceUnique: uniqueIndex( + "auth_oauth_client_resource_client_id_resource_id_idx", + ).on(table.clientId, table.resourceId), + }), +); + export const oauthRefreshToken = pgTable( "oauth_refresh_token", { @@ -361,16 +432,28 @@ export const oauthRefreshToken = pgTable( .notNull() .references(() => user.id, { onDelete: "cascade" }), referenceId: text("reference_id"), + authorizationCodeId: text("authorization_code_id"), + resources: text("resources").array(), + requestedUserInfoClaims: text("requested_user_info_claims").array(), expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), createdAt: timestamp("created_at", { withTimezone: true }).notNull(), revoked: timestamp("revoked", { withTimezone: true }), + rotatedAt: timestamp("rotated_at", { withTimezone: true }), + rotationReplayResponse: text("rotation_replay_response"), + rotationReplayExpiresAt: timestamp("rotation_replay_expires_at", { + withTimezone: true, + }), authTime: timestamp("auth_time", { withTimezone: true }), + confirmation: jsonb("confirmation"), scopes: text("scopes").array().notNull(), }, (table) => ({ clientIdIdx: index("auth_oauth_refresh_token_client_id_idx").on( table.clientId, ), + authorizationCodeIdIdx: index( + "auth_oauth_refresh_token_authorization_code_id_idx", + ).on(table.authorizationCodeId), sessionIdIdx: index("auth_oauth_refresh_token_session_id_idx").on( table.sessionId, ), @@ -397,12 +480,16 @@ export const oauthAccessToken = pgTable( onDelete: "cascade", }), referenceId: text("reference_id"), + authorizationCodeId: text("authorization_code_id"), + resources: text("resources").array(), + requestedUserInfoClaims: text("requested_user_info_claims").array(), refreshId: text("refresh_id").references(() => oauthRefreshToken.id, { onDelete: "set null", }), expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), createdAt: timestamp("created_at", { withTimezone: true }).notNull(), scopes: text("scopes").array().notNull(), + confirmation: jsonb("confirmation"), }, (table) => ({ clientIdIdx: index("auth_oauth_access_token_client_id_idx").on( @@ -414,6 +501,9 @@ export const oauthAccessToken = pgTable( userIdIdx: index("auth_oauth_access_token_user_id_idx").on( table.userId, ), + authorizationCodeIdIdx: index( + "auth_oauth_access_token_authorization_code_id_idx", + ).on(table.authorizationCodeId), refreshIdIdx: index("auth_oauth_access_token_refresh_id_idx").on( table.refreshId, ), @@ -433,6 +523,8 @@ export const oauthConsent = pgTable( onDelete: "cascade", }), referenceId: text("reference_id"), + resources: text("resources").array(), + requestedUserInfoClaims: text("requested_user_info_claims").array(), scopes: text("scopes").array().notNull(), createdAt: timestamp("created_at", { withTimezone: true }).notNull(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(), @@ -445,6 +537,14 @@ export const oauthConsent = pgTable( }), ); +/** Single-use identifiers for private_key_jwt client assertions. The local + * MCP client is public and does not use these, but keeping the provider's + * complete schema makes the configured plugin safe to extend. */ +export const oauthClientAssertion = pgTable("oauth_client_assertion", { + id: text("id").primaryKey(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), +}); + /** The team an OAuth end-user picked on the post-login "select a team" screen * (`/oauth/select-team`, shown only when their account belongs to more than * one team — mirrors Notion's workspace picker). One row per Better Auth diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 1b23a86..64c49ad 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -57,8 +57,8 @@ startDeliveryLifecycleJobs(); app.set("trust proxy", process.env.ENABLE_TRUST_PROXY === "true" ? 1 : false); app.use(cors()); -// MCP clients use public dynamic client registration (DCR) during their first -// OAuth connection. Keep that standards-based bootstrap available, while +// MCP clients may use public dynamic client registration (DCR) during their +// first OAuth connection. Keep that standards-based bootstrap available while // bounding anonymous client creation independently from authenticated API // traffic. Better Auth still validates the allowed scopes and PKCE below. app.use( @@ -76,6 +76,9 @@ app.use( ); app.all("/api/auth/*", toNodeHandler(auth)); app.use(oauthPagesRoutes); +// The MCP handler owns JSON parsing and protocol content-type errors, so it +// must be mounted before the API's global Express body parser. +app.use(mcpRoutes); // Mounted before global body parsing: this route needs the unmodified raw // request bytes for provider signature verification and has no // session/API-key concept at all — see delivery-feedback/webhook-route.ts. @@ -111,8 +114,6 @@ app.use( // directly by email clients/recipients. Since the routers below gate all of // their traffic with `router.use(requireAuth)`, anything mounted after them // would otherwise be incorrectly blocked by that blanket check. -// `mcpRoutes` also serves OAuth discovery metadata under `/.well-known`. -app.use(mcpRoutes); app.use(trackingRoutes); app.use(provisioningRoutes); app.use(organizationRoutes); diff --git a/apps/api/src/mcp/auth-context.test.ts b/apps/api/src/mcp/auth-context.test.ts new file mode 100644 index 0000000..3037f86 --- /dev/null +++ b/apps/api/src/mcp/auth-context.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { createSendLitMcpAuthInfo } from "./auth-context"; + +describe("SendLit MCP auth context", () => { + it("keeps tenant identity separate from OAuth client identity", () => { + const auth = createSendLitMcpAuthInfo({ + authKind: "oauth", + oauthToken: "oauth-token", + clientId: "oauth-client", + scopes: ["contacts:read"], + teamId: "team-1", + user: { id: "user-1" }, + }); + + expect(auth).toMatchObject({ + token: "oauth-token", + clientId: "oauth-client", + scopes: ["contacts:read"], + extra: { + authKind: "oauth", + teamId: "team-1", + }, + }); + expect(auth?.clientId).not.toBe(auth?.extra.teamId); + }); + + it("never places an API-key secret in AuthInfo", () => { + const auth = createSendLitMcpAuthInfo({ + authKind: "team_key", + apiKeyId: "tak_public", + apikey: "sl_live_secret", + teamId: "team-1", + }); + + expect(auth).toMatchObject({ + token: "tak_public", + clientId: "team-key:tak_public", + extra: { + authKind: "team_key", + teamId: "team-1", + }, + }); + expect(JSON.stringify(auth)).not.toContain("sl_live_secret"); + }); +}); diff --git a/apps/api/src/mcp/auth-context.ts b/apps/api/src/mcp/auth-context.ts new file mode 100644 index 0000000..1ff8e23 --- /dev/null +++ b/apps/api/src/mcp/auth-context.ts @@ -0,0 +1,83 @@ +import type { AuthInfo, ServerContext } from "@modelcontextprotocol/server"; +import type { User } from "../user/queries"; + +export type SendLitMcpAuthExtra = { + authKind: "oauth" | "team_key"; + teamId: string; + user: User | null; +}; + +export type SendLitMcpAuthInfo = AuthInfo & { + extra: SendLitMcpAuthExtra; +}; + +type McpAuthRequest = { + authKind?: unknown; + teamId?: unknown; + oauthToken?: unknown; + clientId?: unknown; + scopes?: unknown; + apiKeyId?: unknown; + user?: User | null; +}; + +export function createSendLitMcpAuthInfo( + input: unknown, +): SendLitMcpAuthInfo | null { + if (!input || typeof input !== "object") return null; + const req = input as McpAuthRequest; + const teamId = typeof req.teamId === "string" ? req.teamId : null; + if (!teamId) return null; + + if (req.authKind === "oauth") { + if ( + typeof req.oauthToken !== "string" || + typeof req.clientId !== "string" + ) { + return null; + } + return { + token: req.oauthToken, + clientId: req.clientId, + scopes: Array.isArray(req.scopes) ? req.scopes : [], + extra: { + authKind: "oauth", + teamId, + user: req.user ?? null, + }, + }; + } + + if (req.authKind === "team_key" && typeof req.apiKeyId === "string") { + return { + // The SDK requires a token field for AuthInfo pass-through. Use the + // stable public key id, never the API-key secret. + token: req.apiKeyId, + clientId: `team-key:${req.apiKeyId}`, + scopes: [], + extra: { + authKind: "team_key", + teamId, + user: null, + }, + }; + } + + return null; +} + +export function getSendLitMcpAuthInfo( + ctx: ServerContext, +): SendLitMcpAuthInfo | null { + const authInfo = ctx.http?.authInfo; + const extra = authInfo?.extra as SendLitMcpAuthExtra | undefined; + if ( + !authInfo || + !extra || + (extra.authKind !== "oauth" && extra.authKind !== "team_key") || + typeof extra.teamId !== "string" + ) { + return null; + } + return authInfo as SendLitMcpAuthInfo; +} diff --git a/apps/api/src/mcp/policy.ts b/apps/api/src/mcp/policy.ts new file mode 100644 index 0000000..3fda168 --- /dev/null +++ b/apps/api/src/mcp/policy.ts @@ -0,0 +1,153 @@ +import type { ServerContext } from "@modelcontextprotocol/server"; +import { errorResult } from "./tools/responses"; + +export const MCP_SCOPES = { + contactsRead: "contacts:read", + contactsWrite: "contacts:write", + templatesRead: "templates:read", + templatesWrite: "templates:write", + mediaRead: "media:read", + mediaWrite: "media:write", + sequencesRead: "sequences:read", + sequencesWrite: "sequences:write", + emailsRead: "emails:read", + emailsSend: "emails:send", + settingsRead: "settings:read", + settingsWrite: "settings:write", + espRead: "esp:read", + espWrite: "esp:write", + teamsRead: "teams:read", + teamsWrite: "teams:write", + apiKeysRead: "api_keys:read", + apiKeysWrite: "api_keys:write", + feedbackRead: "feedback:read", + feedbackWrite: "feedback:write", + deliveryEventsRead: "delivery_events:read", + suppressionsRead: "suppressions:read", + suppressionsWrite: "suppressions:write", +} as const; + +export type McpScope = (typeof MCP_SCOPES)[keyof typeof MCP_SCOPES]; + +export const MCP_SCOPES_SUPPORTED = Object.freeze( + Object.values(MCP_SCOPES), +) as readonly McpScope[]; + +const toolPolicies = { + list_contacts: MCP_SCOPES.contactsRead, + get_contact: MCP_SCOPES.contactsRead, + get_contact_deliveries: MCP_SCOPES.contactsRead, + create_contact: MCP_SCOPES.contactsWrite, + update_contact: MCP_SCOPES.contactsWrite, + delete_contact: MCP_SCOPES.contactsWrite, + add_contact_tag: MCP_SCOPES.contactsWrite, + remove_contact_tag: MCP_SCOPES.contactsWrite, + + list_segments: MCP_SCOPES.contactsRead, + get_segment: MCP_SCOPES.contactsRead, + create_segment: MCP_SCOPES.contactsWrite, + update_segment: MCP_SCOPES.contactsWrite, + delete_segment: MCP_SCOPES.contactsWrite, + + list_system_templates: MCP_SCOPES.templatesRead, + list_templates: MCP_SCOPES.templatesRead, + get_template: MCP_SCOPES.templatesRead, + create_template: MCP_SCOPES.templatesWrite, + update_template: MCP_SCOPES.templatesWrite, + duplicate_template: MCP_SCOPES.templatesWrite, + delete_template: MCP_SCOPES.templatesWrite, + + list_media: MCP_SCOPES.mediaRead, + get_media: MCP_SCOPES.mediaRead, + list_media_references: MCP_SCOPES.mediaRead, + update_media: MCP_SCOPES.mediaWrite, + delete_media: MCP_SCOPES.mediaWrite, + + list_sequences: MCP_SCOPES.sequencesRead, + get_sequence: MCP_SCOPES.sequencesRead, + get_sequence_stats: MCP_SCOPES.sequencesRead, + get_sequence_subscribers: MCP_SCOPES.sequencesRead, + create_sequence: MCP_SCOPES.sequencesWrite, + update_sequence: MCP_SCOPES.sequencesWrite, + add_sequence_email: MCP_SCOPES.sequencesWrite, + update_sequence_email: MCP_SCOPES.sequencesWrite, + delete_sequence_email: MCP_SCOPES.sequencesWrite, + start_sequence: MCP_SCOPES.sequencesWrite, + pause_sequence: MCP_SCOPES.sequencesWrite, + + get_email: MCP_SCOPES.emailsRead, + list_emails: MCP_SCOPES.emailsRead, + send_email: MCP_SCOPES.emailsSend, + + get_general_settings: MCP_SCOPES.settingsRead, + update_general_settings: MCP_SCOPES.settingsWrite, + + get_esp_config: MCP_SCOPES.espRead, + list_esps: MCP_SCOPES.espRead, + get_esp: MCP_SCOPES.espRead, + update_esp_config: MCP_SCOPES.espWrite, + delete_esp_config: MCP_SCOPES.espWrite, + send_test_email: MCP_SCOPES.espWrite, + create_esp: MCP_SCOPES.espWrite, + update_esp: MCP_SCOPES.espWrite, + delete_esp: MCP_SCOPES.espWrite, + test_esp: MCP_SCOPES.espWrite, + activate_esp: MCP_SCOPES.espWrite, + + list_teams: MCP_SCOPES.teamsRead, + create_team: MCP_SCOPES.teamsWrite, + rename_team: MCP_SCOPES.teamsWrite, + delete_team: MCP_SCOPES.teamsWrite, + list_api_keys: MCP_SCOPES.apiKeysRead, + create_api_key: MCP_SCOPES.apiKeysWrite, + delete_api_key: MCP_SCOPES.apiKeysWrite, + + get_esp_feedback_connection: MCP_SCOPES.feedbackRead, + upsert_esp_feedback_connection: MCP_SCOPES.feedbackWrite, + test_esp_feedback_connection: MCP_SCOPES.feedbackWrite, + delete_esp_feedback_connection: MCP_SCOPES.feedbackWrite, + list_delivery_events: MCP_SCOPES.deliveryEventsRead, + get_delivery_event: MCP_SCOPES.deliveryEventsRead, + list_suppressions: MCP_SCOPES.suppressionsRead, + get_suppression: MCP_SCOPES.suppressionsRead, + release_suppression: MCP_SCOPES.suppressionsWrite, +} as const satisfies Record; + +export type McpToolName = keyof typeof toolPolicies; + +export function isMcpToolName(name: string): name is McpToolName { + return Object.prototype.hasOwnProperty.call(toolPolicies, name); +} + +export function getRequiredScope(name: string): McpScope { + if (!isMcpToolName(name)) { + throw new Error(`MCP tool '${name}' has no authorization policy.`); + } + return toolPolicies[name]; +} + +export function authorizeMcpTool(name: string, ctx: ServerContext) { + const requiredScope = getRequiredScope(name); + const authInfo = ctx.http?.authInfo; + const authKind = authInfo?.extra?.authKind; + + if (!authInfo) { + return errorResult("Authentication required."); + } + + // A team API key is already a full-access credential for exactly one team. + // Scoped team keys are a separate API-key product feature. + if (authKind === "team_key") return null; + + if (authKind !== "oauth" || !authInfo.scopes.includes(requiredScope)) { + return errorResult( + `insufficient_scope: this tool requires '${requiredScope}'.`, + ); + } + + return null; +} + +export function listMcpToolPolicies(): Readonly> { + return toolPolicies; +} diff --git a/apps/api/src/mcp/routes.test.ts b/apps/api/src/mcp/routes.test.ts index f8170b9..91cd24f 100644 --- a/apps/api/src/mcp/routes.test.ts +++ b/apps/api/src/mcp/routes.test.ts @@ -1,9 +1,23 @@ import type { AddressInfo } from "node:net"; import express, { Router } from "express"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; +import { + Client, + StreamableHTTPClientTransport, +} from "@modelcontextprotocol/client"; +import { McpServer } from "@modelcontextprotocol/server"; const mocks = vi.hoisted(() => ({ createDiscoveryRoutes: vi.fn(), + buildServer: vi.fn(), })); vi.mock("../auth/better-auth", () => ({ @@ -17,19 +31,54 @@ vi.mock("@codelitdev/oauth-server-kit/mcp", () => ({ createMcpOAuthDiscoveryRoutes: mocks.createDiscoveryRoutes, })); vi.mock("../auth/middleware", () => ({ - mcpAuth: vi.fn((_req, _res, next) => next()), + mcpAuth: vi.fn((req: any, _res: any, next: any) => { + req.authKind = "team_key"; + req.apiKeyId = "tak_test"; + req.teamId = "team-1"; + next(); + }), })); vi.mock("../auth/require-team", () => ({ requireTeam: vi.fn((_req, _res, next) => next()), })); vi.mock("./server.js", () => ({ - createMCPSession: vi.fn(), + buildMcpServer: () => mocks.buildServer(), })); -describe("MCP OAuth discovery integration", () => { - beforeEach(() => { - vi.resetModules(); - mocks.createDiscoveryRoutes.mockReset(); +type ListeningApp = { + origin: string; + close(): Promise; +}; + +const listeningApps: ListeningApp[] = []; +const clients: Client[] = []; +let routes: Router; + +async function listen(app: express.Express): Promise { + const server = app.listen(0, "127.0.0.1"); + await new Promise((resolve) => server.once("listening", resolve)); + const { port } = server.address() as AddressInfo; + const instance = { + origin: `http://127.0.0.1:${port}`, + close: () => + new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ), + }; + listeningApps.push(instance); + return instance; +} + +afterEach(async () => { + await Promise.allSettled(clients.splice(0).map((client) => client.close())); + await Promise.allSettled(listeningApps.splice(0).map((app) => app.close())); +}); + +describe("MCP Streamable HTTP route", () => { + beforeAll(async () => { + mocks.buildServer.mockImplementation( + () => new McpServer({ name: "SendLit Test", version: "2.0.0" }), + ); mocks.createDiscoveryRoutes.mockImplementation(() => { const router = Router(); router.get( @@ -39,32 +88,23 @@ describe("MCP OAuth discovery integration", () => { ); return router; }); + routes = ((await import("./routes.js")) as any).default; }); - async function request(app: express.Express, path: string) { - const server = app.listen(0, "127.0.0.1"); - try { - await new Promise((resolve) => - server.once("listening", resolve), - ); - const { port } = server.address() as AddressInfo; - const response = await fetch(`http://127.0.0.1:${port}${path}`); - return { status: response.status, json: () => response.json() }; - } finally { - await new Promise((resolve, reject) => - server.close((error) => (error ? reject(error) : resolve())), - ); - } - } + beforeEach(() => { + mocks.buildServer.mockClear(); + }); - it("mounts the shared discovery router with SendLit MCP scopes", async () => { - const routes = ((await import("./routes.js")) as any).default; + async function createApp() { const app = express(); app.use(routes); + return listen(app); + } - const response = await request( - app, - "/.well-known/oauth-protected-resource/mcp", + it("publishes protected-resource metadata with every enforced scope", async () => { + const listening = await createApp(); + const response = await fetch( + `${listening.origin}/.well-known/oauth-protected-resource/mcp`, ); expect(response.status).toBe(200); @@ -77,10 +117,113 @@ describe("MCP OAuth discovery integration", () => { allowedOrigins: "*", scopesSupported: expect.arrayContaining([ "contacts:read", - "templates:write", - "sequences:write", + "emails:send", + "esp:write", + "api_keys:write", + "suppressions:write", ]), }), ); }); + + it("connects a client pinned to the modern protocol", async () => { + const listening = await createApp(); + const client = new Client( + { name: "route-test", version: "1.0.0" }, + { versionNegotiation: { mode: { pin: "2026-07-28" } } }, + ); + clients.push(client); + const transport = new StreamableHTTPClientTransport( + new URL(`${listening.origin}/mcp`), + { + requestInit: { + headers: { "x-sendlit-apikey": "sl_live_test" }, + }, + }, + ); + + await client.connect(transport); + expect(client.getProtocolEra()).toBe("modern"); + expect(mocks.buildServer).toHaveBeenCalled(); + }); + + it("serves the existing Streamable HTTP initialize handshake statelessly", async () => { + const listening = await createApp(); + const response = await fetch(`${listening.origin}/mcp`, { + method: "POST", + headers: { + accept: "application/json, text/event-stream", + "content-type": "application/json", + "x-sendlit-apikey": "sl_live_test", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-11-25", + capabilities: {}, + clientInfo: { name: "legacy", version: "1.0.0" }, + }, + }), + }); + + expect(response.ok).toBe(true); + expect(response.headers.has("mcp-session-id")).toBe(false); + expect(response.headers.get("content-type")).toContain( + "text/event-stream", + ); + await expect(response.text()).resolves.toContain( + '"protocolVersion":"2025-11-25"', + ); + }); + + it("does not expose the retired SSE transport", async () => { + const listening = await createApp(); + const response = await fetch(`${listening.origin}/mcp`, { + headers: { "x-sendlit-apikey": "sl_live_test" }, + }); + + expect(response.status).toBe(404); + }); + + it("rejects non-JSON request bodies before protocol dispatch", async () => { + const listening = await createApp(); + const response = await fetch(`${listening.origin}/mcp`, { + method: "POST", + headers: { + accept: "application/json", + "content-type": "text/plain", + "x-sendlit-apikey": "sl_live_test", + }, + body: "not an MCP envelope", + }); + + expect(response.status).toBe(415); + expect(mocks.buildServer).not.toHaveBeenCalled(); + }); + + it("allows Streamable HTTP protocol headers in browser preflight", async () => { + const listening = await createApp(); + const response = await fetch(`${listening.origin}/mcp`, { + method: "OPTIONS", + headers: { + origin: "https://client.example", + "access-control-request-method": "POST", + "access-control-request-headers": + "mcp-protocol-version,mcp-method,mcp-name,authorization", + }, + }); + + expect(response.status).toBe(204); + expect(response.headers.get("access-control-allow-origin")).toBe( + "https://client.example", + ); + const allowed = + response.headers.get("access-control-allow-headers") || ""; + expect(allowed).toContain("MCP-Protocol-Version"); + expect(allowed).toContain("Mcp-Method"); + expect(allowed).toContain("Mcp-Name"); + expect(allowed).not.toContain("Mcp-Session-Id"); + }); }); diff --git a/apps/api/src/mcp/routes.ts b/apps/api/src/mcp/routes.ts index 334093f..f05c048 100644 --- a/apps/api/src/mcp/routes.ts +++ b/apps/api/src/mcp/routes.ts @@ -1,11 +1,20 @@ -import { Router } from "express"; +import { + Router, + type NextFunction, + type Request, + type Response, +} from "express"; import rateLimit from "express-rate-limit"; -import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { createMcpHandler } from "@modelcontextprotocol/server"; +import { toNodeHandler } from "@modelcontextprotocol/node"; import { createMcpOAuthDiscoveryRoutes } from "@codelitdev/oauth-server-kit/mcp"; import { mcpAuth } from "../auth/middleware"; import { requireTeam } from "../auth/require-team"; import { auth, mcpResourceUrl, oauthResourceClient } from "../auth/better-auth"; -import { createMCPSession } from "./server"; +import logger from "../services/log"; +import { createSendLitMcpAuthInfo } from "./auth-context"; +import { MCP_SCOPES_SUPPORTED } from "./policy"; +import { buildMcpServer } from "./server"; const router = Router(); @@ -14,124 +23,132 @@ router.use( auth, resourceUrl: mcpResourceUrl, oauthResourceClient, - scopesSupported: [ - "contacts:read", - "contacts:write", - "templates:read", - "templates:write", - "media:read", - "media:write", - "broadcasts:write", - "sequences:read", - "sequences:write", - ], + scopesSupported: [...MCP_SCOPES_SUPPORTED], allowedOrigins: "*", }), ); +const highImpactTools = new Set([ + "send_email", + "send_test_email", + "test_esp", + "start_sequence", + "create_api_key", + "delete_api_key", + "delete_team", + "release_suppression", +]); + const mcpLimiter = rateLimit({ windowMs: 60_000, - max: 60, + max: (req) => + highImpactTools.has(String(req.headers["mcp-name"] || "")) ? 20 : 60, standardHeaders: true, legacyHeaders: false, message: { error: "too_many_requests", - error_description: "Too many requests.", + error_description: "Too many MCP requests.", + }, + handler(req, res, _next, options) { + logger.warn( + { + method: req.headers["mcp-method"], + tool: req.headers["mcp-name"], + authKind: (req as any).authKind, + }, + "MCP rate limit exceeded", + ); + res.status(options.statusCode).json(options.message); }, }); -const mcpSessions = new Map(); - -const mcpCors = (req: any, res: any, next: any) => { +function mcpCors(req: Request, res: Response, next: NextFunction) { const origin = req.headers.origin || "*"; res.header("Access-Control-Allow-Origin", origin); - res.header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS"); + res.header("Vary", "Origin"); + res.header("Access-Control-Allow-Methods", "POST, OPTIONS"); res.header( "Access-Control-Allow-Headers", - "Content-Type, Accept, Mcp-Session-Id, Mcp-Protocol-Version, x-sendlit-apikey, Authorization", + "Content-Type, Accept, MCP-Protocol-Version, Mcp-Method, Mcp-Name, x-sendlit-apikey, Authorization", ); - res.header("Access-Control-Expose-Headers", "Mcp-Session-Id"); if (req.method === "OPTIONS") { - return res.status(204).end(); + res.status(204).end(); + return; } next(); -}; - -/** Some MCP clients omit `text/event-stream` or `application/json` from - * `Accept`, which the SDK's transport requires. */ -function patchMcpAcceptHeaders(req: any) { - const accept = req.headers.accept || ""; - const needsJson = !accept.includes("application/json"); - const needsSSE = !accept.includes("text/event-stream"); - if (!needsJson && !needsSSE) return; - - const additions: string[] = []; - if (needsJson) additions.push("application/json"); - if (needsSSE) additions.push("text/event-stream"); - const newAccept = accept - ? `${accept}, ${additions.join(", ")}` - : additions.join(", "); - req.headers.accept = newAccept; +} - const rawHeaders: string[] = req.rawHeaders; - let found = false; - for (let i = 0; i < rawHeaders.length; i += 2) { - if (rawHeaders[i].toLowerCase() === "accept") { - rawHeaders[i + 1] = newAccept; - found = true; - break; - } +function attachMcpAuthInfo(req: Request, res: Response, next: NextFunction) { + const authInfo = createSendLitMcpAuthInfo(req); + if (!authInfo) { + res.status(401).json({ + error: "unauthorized", + error_description: "MCP authentication context is incomplete.", + }); + return; } - if (!found) rawHeaders.push("Accept", newAccept); + (req as any).auth = authInfo; + next(); } -function getMcpAuth(req: any) { - return { - token: req.apikey || "", - // "clientId" here is the resolved *team* id (see `auth/require-team.ts`) — - // every MCP tool operates on team-scoped resources. - clientId: String(req.teamId || ""), - user: req.user, - scopes: (req.scopes as string[] | undefined) || [], - }; +function observeMcpRequest(req: Request, res: Response, next: NextFunction) { + const startedAt = performance.now(); + res.once("finish", () => { + // Deliberately exclude arguments, credentials, message content, and + // results. These low-cardinality fields are safe to aggregate into + // latency/error dashboards at the logging layer. + logger.info( + { + method: req.headers["mcp-method"], + tool: req.headers["mcp-name"], + statusCode: res.statusCode, + durationMs: Math.round(performance.now() - startedAt), + authKind: (req as any).authKind, + }, + "MCP request completed", + ); + }); + next(); } +export const mcpHandler = createMcpHandler( + // One request-local server instance works for both supported eras. Modern + // clients use stateless 2026 request envelopes; existing MCP clients such + // as VS Code still begin with the 2025 `initialize` handshake. + () => buildMcpServer(), + { + // Keep 2025 traffic stateless too: no session IDs or retained + // transports are introduced, while clients that have not upgraded to + // the 2026 wire revision can complete their Streamable HTTP handshake. + legacy: "stateless", + onerror(error) { + logger.error({ errorType: error.name }, "MCP protocol error"); + }, + }, +); + +const nodeMcpHandler = toNodeHandler(mcpHandler, { + onerror(error) { + logger.error({ errorType: error.name }, "MCP Node adapter error"); + }, +}); + router.post( "/mcp", mcpCors, mcpLimiter, mcpAuth, requireTeam, - async (req: any, res: any) => { - patchMcpAcceptHeaders(req); - - const auth = getMcpAuth(req); - const sessionId = req.headers["mcp-session-id"] as string | undefined; - - if (sessionId) { - const transport = mcpSessions.get(sessionId); - if (!transport) { - return res.status(404).json({ - jsonrpc: "2.0", - error: { code: -32001, message: "Session not found" }, - id: null, - }); - } - await transport.handleRequest( - Object.assign(req, { auth }), - res, - req.body, - ); - } else { - const transport = createMCPSession( - (id) => mcpSessions.set(id, transport), - (id) => mcpSessions.delete(id), - ); - await transport.handleRequest( - Object.assign(req, { auth }), - res, - req.body, - ); + attachMcpAuthInfo, + observeMcpRequest, + async (req: Request, res: Response, next: NextFunction) => { + try { + // This router is mounted before Express body parsing. The official + // adapter owns JSON parsing/content-type validation so protocol + // parse errors and 415 responses remain MCP-compliant. + await nodeMcpHandler(req as any, res); + } catch (error) { + next(error); } }, ); diff --git a/apps/api/src/mcp/schema.ts b/apps/api/src/mcp/schema.ts new file mode 100644 index 0000000..d3853e3 --- /dev/null +++ b/apps/api/src/mcp/schema.ts @@ -0,0 +1,22 @@ +import { + fromJsonSchema, + type JsonSchemaType, + type StandardSchemaWithJSON, +} from "@modelcontextprotocol/server"; +import { z, type ZodRawShape, type ZodTypeAny } from "zod"; +import { zodToJsonSchema } from "zod-to-json-schema"; + +export function toMcpSchema(schema: ZodTypeAny): StandardSchemaWithJSON { + const jsonSchema = zodToJsonSchema(schema, { + target: "jsonSchema7", + $refStrategy: "none", + errorMessages: true, + }) as JsonSchemaType; + return fromJsonSchema(jsonSchema); +} + +export function toMcpObjectSchema( + shape: ZodRawShape | undefined, +): StandardSchemaWithJSON { + return toMcpSchema(z.object(shape ?? {})); +} diff --git a/apps/api/src/mcp/server.test.ts b/apps/api/src/mcp/server.test.ts new file mode 100644 index 0000000..e73c459 --- /dev/null +++ b/apps/api/src/mcp/server.test.ts @@ -0,0 +1,149 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + Client, + StreamableHTTPClientTransport, +} from "@modelcontextprotocol/client"; +import { + createMcpHandler, + type AuthInfo, + type McpHttpHandler, + type ServerContext, +} from "@modelcontextprotocol/server"; +import { buildMcpServer } from "./server"; +import { authorizeMcpTool, listMcpToolPolicies, MCP_SCOPES } from "./policy"; + +vi.mock("../db/client", () => ({ db: {}, pool: {} })); + +const endpoint = new URL("http://sendlit.test/mcp"); +const teamKeyAuth: AuthInfo = { + token: "tak_test", + clientId: "team-key:tak_test", + scopes: [], + extra: { + authKind: "team_key", + teamId: "team-1", + user: null, + }, +}; + +function createHandler() { + return createMcpHandler(() => buildMcpServer(), { + legacy: "stateless", + }); +} + +function transportFor(handler: McpHttpHandler, authInfo = teamKeyAuth) { + return new StreamableHTTPClientTransport(endpoint, { + fetch: (input, init) => + handler.fetch(new Request(input, init), { authInfo }), + }); +} + +const closeables: Array<{ close(): Promise }> = []; + +afterEach(async () => { + await Promise.allSettled(closeables.splice(0).map((item) => item.close())); +}); + +describe("MCP server", () => { + it("serves the complete deterministic tool catalog on 2026-07-28", async () => { + const handler = createHandler(); + const client = new Client( + { name: "sendlit-test", version: "1.0.0" }, + { versionNegotiation: { mode: { pin: "2026-07-28" } } }, + ); + closeables.push(client, handler); + + await client.connect(transportFor(handler)); + expect(client.getProtocolEra()).toBe("modern"); + + const result = await client.listTools(); + expect(result.tools).toHaveLength(68); + expect(result.tools.map((tool) => tool.name).sort()).toEqual( + Object.keys(listMcpToolPolicies()).sort(), + ); + expect(new Set(result.tools.map((tool) => tool.name)).size).toBe(68); + expect(result.ttlMs).toBe(300_000); + expect(result.cacheScope).toBe("private"); + for (const tool of result.tools) { + expect(tool.description).toBeTruthy(); + expect(tool.inputSchema).toMatchObject({ type: "object" }); + expect(tool.outputSchema).toBeTruthy(); + expect(tool.annotations).toBeTruthy(); + } + + const refreshed = await client.listTools(undefined, { + cacheMode: "refresh", + }); + expect(refreshed.tools.map((tool) => tool.name)).toEqual( + result.tools.map((tool) => tool.name), + ); + }); + + it("rejects protocol-envelope/header mismatches", async () => { + const handler = createHandler(); + const client = new Client( + { name: "sendlit-header-test", version: "1.0.0" }, + { versionNegotiation: { mode: { pin: "2026-07-28" } } }, + ); + closeables.push(client, handler); + let tamperHeaders = false; + const transport = new StreamableHTTPClientTransport(endpoint, { + fetch: (input, init) => { + const request = new Request(input, init); + if (!tamperHeaders) { + return handler.fetch(request, { authInfo: teamKeyAuth }); + } + const headers = new Headers(request.headers); + headers.set("Mcp-Method", "tools/call"); + return handler.fetch(new Request(request, { headers }), { + authInfo: teamKeyAuth, + }); + }, + }); + + await client.connect(transport); + tamperHeaders = true; + + await expect( + client.listTools(undefined, { cacheMode: "refresh" }), + ).rejects.toThrow(); + }); + + it("enforces default-deny OAuth scopes while allowing fixed-team keys", () => { + const baseContext = { + mcpReq: {}, + http: { authInfo: teamKeyAuth }, + } as unknown as ServerContext; + expect(authorizeMcpTool("send_email", baseContext)).toBeNull(); + + const oauthContext = { + mcpReq: {}, + http: { + authInfo: { + token: "oauth-token", + clientId: "client-1", + scopes: [MCP_SCOPES.contactsRead], + extra: { + authKind: "oauth", + teamId: "team-1", + user: null, + }, + }, + }, + } as unknown as ServerContext; + + expect(authorizeMcpTool("list_contacts", oauthContext)).toBeNull(); + expect(authorizeMcpTool("send_email", oauthContext)).toMatchObject({ + isError: true, + content: [ + expect.objectContaining({ + text: expect.stringContaining(MCP_SCOPES.emailsSend), + }), + ], + }); + expect(() => + authorizeMcpTool("unregistered_tool", oauthContext), + ).toThrow("has no authorization policy"); + }); +}); diff --git a/apps/api/src/mcp/server.ts b/apps/api/src/mcp/server.ts index fd9472f..a9a2f38 100644 --- a/apps/api/src/mcp/server.ts +++ b/apps/api/src/mcp/server.ts @@ -1,6 +1,4 @@ -import crypto from "crypto"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { McpServer } from "@modelcontextprotocol/server"; import { registerContactTools } from "./tools/contacts"; import { registerSegmentTools } from "./tools/segments"; import { registerTemplateTools } from "./tools/templates"; @@ -11,42 +9,53 @@ import { registerGeneralSettingsTools } from "./tools/general-settings"; import { registerTeamTools } from "./tools/teams"; import { registerMediaTools } from "./tools/media"; import { registerDeliveryFeedbackTools } from "./tools/delivery-feedback"; +import { createMcpToolRegistrar } from "./tool-registry"; -function registerAllTools(server: McpServer): void { - registerContactTools(server); - registerSegmentTools(server); - registerTemplateTools(server); - registerSequenceTools(server); - registerTransactionalTools(server); - registerEspTools(server); - registerGeneralSettingsTools(server); - registerTeamTools(server); - registerMediaTools(server); - registerDeliveryFeedbackTools(server); -} +export const SENDLIT_MCP_VERSION = "2.0.0"; /** - * Create a new MCP session (transport + server pair). Each connecting client - * must get its own session — the StreamableHTTPServerTransport is - * single-session by design. Ported from `medialit/apps/api/src/mcp/server.ts`. + * Build one request-local MCP server for both the existing 2025 and modern + * 2026 protocol eras. This factory is deliberately pure: tenant/principal + * state arrives through the SDK request context and all durable state lives in + * SendLit's existing database/queue services. */ -export function createMCPSession( - onsessioninitialized: (sessionId: string) => void, - onsessionclosed: (sessionId: string) => void, -): StreamableHTTPServerTransport { - const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => crypto.randomUUID(), - enableJsonResponse: true, - onsessioninitialized, - onsessionclosed, - }); - const server = new McpServer({ - name: "SendLit", - version: "1.0.0", - description: - "SendLit MCP server — compose, send and automate email for a SendLit account. Supports managing contacts and segmentation, reusable email templates, broadcasts/sequences (create, edit, start/pause, and inspect delivery stats), transactional emails (single API-triggered sends with delivery status polling), and the account's ESP (SMTP) sending configuration.", - }); - registerAllTools(server); - server.connect(transport); - return transport; +export function buildMcpServer(): McpServer { + const server = new McpServer( + { + name: "SendLit", + version: SENDLIT_MCP_VERSION, + description: + "SendLit MCP server — compose, send and automate email for a SendLit team. Manage contacts and segments, templates and media, broadcasts and sequences, transactional email, sending providers, settings, API keys, and delivery feedback.", + }, + { + instructions: + "Use read tools to inspect existing state before mutations. Sending, activation, deletion, key management, and suppression-release tools have external or destructive effects; confirm the requested target and inputs before calling them.", + cacheHints: { + "server/discover": { + ttlMs: 300_000, + cacheScope: "private", + }, + "tools/list": { + ttlMs: 300_000, + cacheScope: "private", + }, + }, + }, + ); + const tools = createMcpToolRegistrar(server); + + // Registration order is stable so catalog caching and upstream prompt + // caches receive deterministic tool lists. + registerContactTools(tools); + registerSegmentTools(tools); + registerTemplateTools(tools); + registerMediaTools(tools); + registerSequenceTools(tools); + registerTransactionalTools(tools); + registerGeneralSettingsTools(tools); + registerEspTools(tools); + registerTeamTools(tools); + registerDeliveryFeedbackTools(tools); + + return server; } diff --git a/apps/api/src/mcp/tool-registry.ts b/apps/api/src/mcp/tool-registry.ts new file mode 100644 index 0000000..f8e2369 --- /dev/null +++ b/apps/api/src/mcp/tool-registry.ts @@ -0,0 +1,140 @@ +import { + type McpServer, + type ServerContext, + type StandardSchemaWithJSON, + type ToolAnnotations, +} from "@modelcontextprotocol/server"; +import type { ZodRawShape, ZodTypeAny } from "zod"; +import logger from "../services/log"; +import { authorizeMcpTool, getRequiredScope, type McpToolName } from "./policy"; +import { toMcpObjectSchema, toMcpSchema } from "./schema"; + +export type McpToolConfig = { + title?: string; + description?: string; + inputSchema?: ZodRawShape; + outputSchema: ZodTypeAny; + annotations?: ToolAnnotations; +}; + +export type McpToolHandler = ( + argsOrContext: any, + context?: ServerContext, +) => Promise | any; + +export interface McpToolRegistrar { + registerTool( + name: McpToolName, + config: McpToolConfig, + handler: McpToolHandler, + ): void; +} + +type CompiledToolSchemas = { + inputSchema: StandardSchemaWithJSON; + outputSchema: StandardSchemaWithJSON; +}; + +// SDK 2 creates a fresh server for every stateless request, but SendLit's tool +// schemas are immutable. Cache their adapters by the exhaustively policy-bound +// tool name so 68 Zod-to-JSON-Schema conversions happen once per process, not +// once per request. +const compiledSchemas = new Map(); + +function getCompiledSchemas( + name: McpToolName, + config: McpToolConfig, +): CompiledToolSchemas { + const cached = compiledSchemas.get(name); + if (cached) return cached; + + const schemas = { + inputSchema: toMcpObjectSchema(config.inputSchema), + outputSchema: toMcpSchema(config.outputSchema), + }; + compiledSchemas.set(name, schemas); + return schemas; +} + +/** + * Adapt SendLit's canonical Zod 3 tool schemas and thin handlers to the MCP + * SDK 2 Standard Schema/context APIs. Every registration is default-deny: + * looking up the required scope throws if policy is missing. + */ +export function createMcpToolRegistrar(server: McpServer): McpToolRegistrar { + return { + registerTool(name, config, handler) { + const requiredScope = getRequiredScope(name); + const hasDeclaredInput = config.inputSchema !== undefined; + const schemas = getCompiledSchemas(name, config); + + server.registerTool( + name, + { + title: config.title, + description: config.description, + ...schemas, + annotations: config.annotations, + }, + async (args, ctx) => { + const startedAt = performance.now(); + const authKind = ctx.http?.authInfo?.extra?.authKind; + const denied = authorizeMcpTool(name, ctx); + if (denied) { + logger.warn( + { + tool: name, + requiredScope, + authKind, + scopeDecision: "denied", + }, + "MCP tool authorization denied", + ); + return denied; + } + + try { + const result = await (hasDeclaredInput + ? handler(args, ctx) + : handler(ctx)); + logger.info( + { + tool: name, + requiredScope, + authKind, + scopeDecision: "allowed", + outcome: result?.isError + ? "tool_error" + : "success", + durationMs: Math.round( + performance.now() - startedAt, + ), + }, + "MCP tool completed", + ); + return result; + } catch (error) { + logger.error( + { + tool: name, + requiredScope, + authKind, + scopeDecision: "allowed", + outcome: "exception", + durationMs: Math.round( + performance.now() - startedAt, + ), + errorType: + error instanceof Error + ? error.name + : typeof error, + }, + "MCP tool failed", + ); + throw error; + } + }, + ); + }, + }; +} diff --git a/apps/api/src/mcp/tools/auth.ts b/apps/api/src/mcp/tools/auth.ts index 947f99d..76347fd 100644 --- a/apps/api/src/mcp/tools/auth.ts +++ b/apps/api/src/mcp/tools/auth.ts @@ -1,18 +1,17 @@ +import type { ServerContext } from "@modelcontextprotocol/server"; import type { User } from "../../user/queries"; +import { getSendLitMcpAuthInfo } from "../auth-context"; -/** MCP tool handlers receive the resolved team id via `extra.authInfo.clientId` - * (see `mcp/routes.ts`'s `getMcpAuth`, populated by `auth/middleware.ts` + - * `auth/require-team.ts`). A key always resolves to exactly one team; an - * user-authenticated session resolves to its sole team if the account only - * has one, otherwise the connection is rejected (see `mcp/routes.ts`). */ -export function getTeamId(extra: any): string | null { - return extra?.authInfo?.clientId || null; +/** The team id is explicit tenant context and is never overloaded into the + * OAuth `clientId`. It is attached after credential and team verification. */ +export function getTeamId(ctx: ServerContext): string | null { + return getSendLitMcpAuthInfo(ctx)?.extra.teamId ?? null; } /** The logged-in human, when authenticated as a user — `null` for API-key * sessions (a key has no single owning account; a team can have several * members). Only used for cosmetic fallbacks (e.g. "send the test email to * me"), never for authorization. */ -export function getAuthUser(extra: any): User | null { - return extra?.authInfo?.user || null; +export function getAuthUser(ctx: ServerContext): User | null { + return getSendLitMcpAuthInfo(ctx)?.extra.user ?? null; } diff --git a/apps/api/src/mcp/tools/contacts.ts b/apps/api/src/mcp/tools/contacts.ts index 79e3f2c..81c6f56 100644 --- a/apps/api/src/mcp/tools/contacts.ts +++ b/apps/api/src/mcp/tools/contacts.ts @@ -1,4 +1,4 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { McpToolRegistrar } from "../tool-registry"; import { z } from "zod"; import { customFieldsSchema } from "@sendlit/api-contract"; import { @@ -23,9 +23,8 @@ import { } from "./schemas"; import { getTeamId } from "./auth"; import { omitInternal } from "../../utils/public"; -import { serializeDates } from "../../utils/serialize"; -export function registerContactTools(server: McpServer): void { +export function registerContactTools(server: McpToolRegistrar): void { server.registerTool( "list_contacts", { @@ -147,7 +146,7 @@ export function registerContactTools(server: McpServer): void { teamId, contact.id, ); - return jsonResult({ items: serializeDates(deliveries) }); + return jsonResult({ items: deliveries }); } catch { return INTERNAL_ERROR; } diff --git a/apps/api/src/mcp/tools/delivery-feedback.test.ts b/apps/api/src/mcp/tools/delivery-feedback.test.ts index 16650f5..63b94eb 100644 --- a/apps/api/src/mcp/tools/delivery-feedback.test.ts +++ b/apps/api/src/mcp/tools/delivery-feedback.test.ts @@ -63,7 +63,20 @@ function makeToolRegistry() { return tools; } -const auth = { authInfo: { clientId: "team-1", user: { id: "user-1" } } }; +const auth: any = { + http: { + authInfo: { + token: "test-token", + clientId: "test-client", + scopes: [], + extra: { + authKind: "oauth", + teamId: "team-1", + user: { id: "user-1" }, + }, + }, + }, +}; beforeEach(() => { for (const mock of Object.values(mocks)) mock.mockReset(); diff --git a/apps/api/src/mcp/tools/delivery-feedback.ts b/apps/api/src/mcp/tools/delivery-feedback.ts index 8966d4c..7b16dda 100644 --- a/apps/api/src/mcp/tools/delivery-feedback.ts +++ b/apps/api/src/mcp/tools/delivery-feedback.ts @@ -1,4 +1,4 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { McpToolRegistrar } from "../tool-registry"; import { z } from "zod"; import { getEspConfigByEspId, @@ -122,7 +122,7 @@ async function toPublicEvents(teamId: string, events: DeliveryEvent[]) { * behavior. Only providers with a reviewed adapter * (`feedbackCapableProviders`) can be configured. */ -export function registerDeliveryFeedbackTools(server: McpServer): void { +export function registerDeliveryFeedbackTools(server: McpToolRegistrar): void { server.registerTool( "get_esp_feedback_connection", { diff --git a/apps/api/src/mcp/tools/esp.ts b/apps/api/src/mcp/tools/esp.ts index d0f949e..18248ad 100644 --- a/apps/api/src/mcp/tools/esp.ts +++ b/apps/api/src/mcp/tools/esp.ts @@ -1,4 +1,4 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { McpToolRegistrar } from "../tool-registry"; import { z } from "zod"; import { createEspConfig, @@ -67,7 +67,7 @@ const connectionFields = { fromEmail: z.string().email().optional(), }; -export function registerEspTools(server: McpServer): void { +export function registerEspTools(server: McpToolRegistrar): void { server.registerTool( "get_esp_config", { diff --git a/apps/api/src/mcp/tools/general-settings.ts b/apps/api/src/mcp/tools/general-settings.ts index 577d158..f70340f 100644 --- a/apps/api/src/mcp/tools/general-settings.ts +++ b/apps/api/src/mcp/tools/general-settings.ts @@ -1,4 +1,4 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { McpToolRegistrar } from "../tool-registry"; import { z } from "zod"; import { getGeneralSettings, @@ -16,7 +16,7 @@ function toPublicShape(settings: GeneralSettings) { }; } -export function registerGeneralSettingsTools(server: McpServer): void { +export function registerGeneralSettingsTools(server: McpToolRegistrar): void { server.registerTool( "get_general_settings", { diff --git a/apps/api/src/mcp/tools/media.ts b/apps/api/src/mcp/tools/media.ts index 0a79f9e..78d5155 100644 --- a/apps/api/src/mcp/tools/media.ts +++ b/apps/api/src/mcp/tools/media.ts @@ -1,4 +1,4 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { McpToolRegistrar } from "../tool-registry"; import { z } from "zod"; import { countMedia, @@ -44,7 +44,7 @@ function toPublicReference(reference: { }; } -export function registerMediaTools(server: McpServer): void { +export function registerMediaTools(server: McpToolRegistrar): void { server.registerTool( "list_media", { diff --git a/apps/api/src/mcp/tools/responses.ts b/apps/api/src/mcp/tools/responses.ts index 2c78446..9dfca07 100644 --- a/apps/api/src/mcp/tools/responses.ts +++ b/apps/api/src/mcp/tools/responses.ts @@ -1,3 +1,5 @@ +import { serializeDates } from "../../utils/serialize"; + export const AUTH_ERROR = { content: [ { @@ -31,8 +33,9 @@ export function errorResult(message: string) { } export function jsonResult(data: unknown) { + const serialized = serializeDates(data ?? {}) as Record; return { - content: [{ type: "text" as const, text: JSON.stringify(data) }], - structuredContent: (data ?? {}) as Record, + content: [{ type: "text" as const, text: JSON.stringify(serialized) }], + structuredContent: serialized, }; } diff --git a/apps/api/src/mcp/tools/schemas.ts b/apps/api/src/mcp/tools/schemas.ts index e9c7fe6..555e40d 100644 --- a/apps/api/src/mcp/tools/schemas.ts +++ b/apps/api/src/mcp/tools/schemas.ts @@ -21,8 +21,8 @@ export const contactSchema = z.object({ customFields: customFieldsSchema, tags: z.array(z.string()), unsubscribeToken: z.string(), - createdAt: z.string().or(z.date()), - updatedAt: z.string().or(z.date()), + createdAt: z.string(), + updatedAt: z.string(), }); export const contactListSchema = z.object({ @@ -35,7 +35,7 @@ export const contactDeliverySchema = z.object({ sequenceTitle: z.string(), sequenceType: z.string(), emailId: z.string(), - createdAt: z.string().or(z.date()).nullable(), + createdAt: z.string().nullable(), }); export const contactDeliveryListSchema = z.object({ @@ -46,8 +46,8 @@ export const segmentSchema = z.object({ segmentId: z.string(), name: z.string(), filter: contactFilterSchema, - createdAt: z.string().or(z.date()), - updatedAt: z.string().or(z.date()), + createdAt: z.string(), + updatedAt: z.string(), }); export const segmentListSchema = z.object({ @@ -60,8 +60,8 @@ export const templateSchema = z.object({ purpose: z.enum(["marketing", "transactional"]), content: emailContentSchema, requiredVariables: z.array(z.string()), - createdAt: z.string().or(z.date()), - updatedAt: z.string().or(z.date()), + createdAt: z.string(), + updatedAt: z.string(), }); export const mediaSchema = z.object({ @@ -76,8 +76,8 @@ export const mediaSchema = z.object({ height: z.number().nullable().optional(), alt: z.string().nullable().optional(), caption: z.string().nullable().optional(), - createdAt: z.string().or(z.date()).nullable().optional(), - updatedAt: z.string().or(z.date()).nullable().optional(), + createdAt: z.string().nullable().optional(), + updatedAt: z.string().nullable().optional(), }); export const mediaListSchema = z.object({ @@ -89,8 +89,8 @@ export const mediaReferenceSchema = z.object({ resourceType: z.enum(["TEMPLATE", "SEQUENCE_EMAIL"]), resourcePublicId: z.string(), parentResourcePublicId: z.string().nullable().optional(), - createdAt: z.string().or(z.date()).nullable().optional(), - updatedAt: z.string().or(z.date()).nullable().optional(), + createdAt: z.string().nullable().optional(), + updatedAt: z.string().nullable().optional(), }); export const mediaReferenceListSchema = z.object({ @@ -179,7 +179,7 @@ export const apiKeySchema = z.object({ id: z.string(), keyPrefix: z.string(), name: z.string().nullable(), - createdAt: z.string().or(z.date()).nullable(), + createdAt: z.string().nullable(), }); export const createdApiKeySchema = apiKeySchema.extend({ @@ -208,7 +208,7 @@ export const espConfigSchema = z.object({ fromEmail: z.string().nullable().optional(), status: z.string(), secretVersion: z.number(), - lastTestedAt: z.string().or(z.date()).nullable().optional(), + lastTestedAt: z.string().nullable().optional(), lastTestStatus: z.string().nullable().optional(), lastTestError: z.string().nullable().optional(), }); @@ -220,7 +220,7 @@ export const espConfigListSchema = z.object({ /** General (non-ESP) per-team settings singleton — see settings/general. */ export const generalSettingsSchema = z.object({ mailingAddress: z.string().nullable(), - updatedAt: z.string().or(z.date()).nullable().optional(), + updatedAt: z.string().nullable().optional(), }); // ---- Bounce and complaint processing (docs/bounces-and-complaints.md) ----- @@ -243,8 +243,8 @@ export const feedbackConnectionSchema = z.object({ "retiring", "disabled", ]), - lastReceivedAt: z.string().or(z.date()).nullable().optional(), - lastVerifiedAt: z.string().or(z.date()).nullable().optional(), + lastReceivedAt: z.string().nullable().optional(), + lastVerifiedAt: z.string().nullable().optional(), lastErrorCode: z.string().nullable().optional(), }); @@ -258,8 +258,8 @@ export const deliveryEventSchema = z.object({ eventType: z.string(), bounceClass: z.string().nullable().optional(), reason: z.string().nullable().optional(), - occurredAt: z.string().or(z.date()), - receivedAt: z.string().or(z.date()), + occurredAt: z.string(), + receivedAt: z.string(), }); export const deliveryEventListSchema = z.object({ @@ -278,9 +278,9 @@ export const suppressionSchema = z.object({ "manual", ]), active: z.boolean(), - firstSuppressedAt: z.string().or(z.date()), - lastSuppressedAt: z.string().or(z.date()), - releasedAt: z.string().or(z.date()).nullable().optional(), + firstSuppressedAt: z.string(), + lastSuppressedAt: z.string(), + releasedAt: z.string().nullable().optional(), releaseReason: z.string().nullable().optional(), }); diff --git a/apps/api/src/mcp/tools/segments.ts b/apps/api/src/mcp/tools/segments.ts index bf1fe0b..f45b915 100644 --- a/apps/api/src/mcp/tools/segments.ts +++ b/apps/api/src/mcp/tools/segments.ts @@ -1,4 +1,4 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { McpToolRegistrar } from "../tool-registry"; import { z } from "zod"; import { createSegment, @@ -13,13 +13,12 @@ import { segmentListSchema, segmentSchema } from "./schemas"; import { getTeamId } from "./auth"; import { omitInternal } from "../../utils/public"; -export function registerSegmentTools(server: McpServer): void { +export function registerSegmentTools(server: McpToolRegistrar): void { server.registerTool( "list_segments", { description: "Returns all saved segments (named, reusable contact filters) for the team.", - inputSchema: {}, outputSchema: segmentListSchema, annotations: { readOnlyHint: true, @@ -27,7 +26,7 @@ export function registerSegmentTools(server: McpServer): void { openWorldHint: false, }, }, - async (_args: any, extra: any) => { + async (extra: any) => { const teamId = getTeamId(extra); if (!teamId) return AUTH_ERROR; try { diff --git a/apps/api/src/mcp/tools/sequences.ts b/apps/api/src/mcp/tools/sequences.ts index 4d245ba..ca339d2 100644 --- a/apps/api/src/mcp/tools/sequences.ts +++ b/apps/api/src/mcp/tools/sequences.ts @@ -1,4 +1,4 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { McpToolRegistrar } from "../tool-registry"; import { z } from "zod"; import { emailContentInputSchema } from "@sendlit/api-contract"; import { mailTypes, emailActionTypes } from "../../config/constants"; @@ -52,7 +52,7 @@ function toPublicSequence(sequence: HydratedSequence) { }; } -export function registerSequenceTools(server: McpServer): void { +export function registerSequenceTools(server: McpToolRegistrar): void { server.registerTool( "list_sequences", { diff --git a/apps/api/src/mcp/tools/teams.ts b/apps/api/src/mcp/tools/teams.ts index 787cd68..8938204 100644 --- a/apps/api/src/mcp/tools/teams.ts +++ b/apps/api/src/mcp/tools/teams.ts @@ -1,4 +1,4 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { McpToolRegistrar } from "../tool-registry"; import { z } from "zod"; import { createTeam, @@ -23,7 +23,7 @@ import { import { getAuthUser, getTeamId } from "./auth"; import { getOrganizationMembership } from "../../organization/queries"; -export function registerTeamTools(server: McpServer): void { +export function registerTeamTools(server: McpToolRegistrar): void { server.registerTool( "list_teams", { diff --git a/apps/api/src/mcp/tools/templates.ts b/apps/api/src/mcp/tools/templates.ts index 860f56b..91582d7 100644 --- a/apps/api/src/mcp/tools/templates.ts +++ b/apps/api/src/mcp/tools/templates.ts @@ -1,4 +1,4 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { McpToolRegistrar } from "../tool-registry"; import { z } from "zod"; import { createTemplate, @@ -23,7 +23,7 @@ import { TemplateValidationError } from "../../templates/validation"; import { getTeamId } from "./auth"; import { omitInternal } from "../../utils/public"; -export function registerTemplateTools(server: McpServer): void { +export function registerTemplateTools(server: McpToolRegistrar): void { server.registerTool( "list_system_templates", { diff --git a/apps/api/src/mcp/tools/tools.test.ts b/apps/api/src/mcp/tools/tools.test.ts index a7b846f..d8a7dfe 100644 --- a/apps/api/src/mcp/tools/tools.test.ts +++ b/apps/api/src/mcp/tools/tools.test.ts @@ -203,10 +203,37 @@ function makeToolRegistry(register: (server: any) => void) { return tools; } -const auth = { - authInfo: { - clientId: "team-1", - user: { id: "user-1", email: "owner@example.com", name: "Owner" }, +const auth: any = { + http: { + authInfo: { + token: "test-token", + clientId: "test-client", + scopes: [], + extra: { + authKind: "oauth", + teamId: "team-1", + user: { + id: "user-1", + email: "owner@example.com", + name: "Owner", + }, + }, + }, + }, +}; + +const teamKeyAuth: any = { + http: { + authInfo: { + token: "tak_test", + clientId: "team-key:tak_test", + scopes: [], + extra: { + authKind: "team_key", + teamId: "team-1", + user: null, + }, + }, }, }; @@ -219,17 +246,35 @@ beforeEach(() => { describe("MCP tool auth helpers and response helpers", () => { it("extracts team/account auth context and emits structured JSON results", () => { expect(getTeamId(auth)).toBe("team-1"); - expect(getTeamId({ authInfo: {} })).toBeNull(); + expect(getTeamId({ http: { authInfo: {} } } as any)).toBeNull(); expect(getAuthUser(auth)).toEqual( expect.objectContaining({ id: "user-1" }), ); - expect(getAuthUser({ authInfo: {} })).toBeNull(); + expect(getAuthUser({ http: { authInfo: {} } } as any)).toBeNull(); expect(jsonResult({ ok: true })).toEqual({ content: [{ type: "text", text: JSON.stringify({ ok: true }) }], structuredContent: { ok: true }, }); }); + + it("serializes dates consistently in text and structured results", () => { + const createdAt = new Date("2026-08-09T10:00:00.000Z"); + + expect(jsonResult({ data: { createdAt } })).toEqual({ + content: [ + { + type: "text", + text: JSON.stringify({ + data: { createdAt: "2026-08-09T10:00:00.000Z" }, + }), + }, + ], + structuredContent: { + data: { createdAt: "2026-08-09T10:00:00.000Z" }, + }, + }); + }); }); describe("MCP contact tools", () => { @@ -259,8 +304,17 @@ describe("MCP contact tools", () => { offset: 2, }); - mocks.createContact.mockResolvedValue({ contactId: secondContactId }); - await tools + mocks.createContact.mockResolvedValue({ + contactId: secondContactId, + email: "reader@example.com", + subscribed: true, + customFields: { plan: "pro" }, + tags: [], + unsubscribeToken: "unsubscribe-token", + createdAt: new Date("2026-08-09T10:00:00.000Z"), + updatedAt: new Date("2026-08-09T10:00:00.000Z"), + }); + const createResult = await tools .get("create_contact")! .handler( { email: "reader@example.com", customFields: { plan: "pro" } }, @@ -271,6 +325,16 @@ describe("MCP contact tools", () => { email: "reader@example.com", customFields: { plan: "pro" }, }); + expect(createResult.structuredContent).toMatchObject({ + createdAt: "2026-08-09T10:00:00.000Z", + updatedAt: "2026-08-09T10:00:00.000Z", + }); + expect( + tools + .get("create_contact")! + .config.outputSchema.safeParse(createResult.structuredContent) + .success, + ).toBe(true); }); it("does not leak contacts from another team", async () => { @@ -431,9 +495,7 @@ describe("MCP ESP tools", () => { }); await expect( - tools - .get("send_test_email")! - .handler({}, { authInfo: { clientId: "team-1" } }), + tools.get("send_test_email")!.handler({}, teamKeyAuth), ).resolves.toMatchObject({ structuredContent: { success: false, @@ -650,13 +712,21 @@ describe("MCP team and template tools", () => { tools.get("create_team")!.handler( { name: "Second Team" }, { - authInfo: { - clientId: "team-1", - user: { - id: "user-1", - email: "owner@example.com", - name: "Owner", - defaultOrganizationId: "org-1", + http: { + authInfo: { + token: "test-token", + clientId: "test-client", + scopes: [], + extra: { + authKind: "oauth", + teamId: "team-1", + user: { + id: "user-1", + email: "owner@example.com", + name: "Owner", + defaultOrganizationId: "org-1", + }, + }, }, }, }, diff --git a/apps/api/src/mcp/tools/transactional.ts b/apps/api/src/mcp/tools/transactional.ts index 504e606..8274fb2 100644 --- a/apps/api/src/mcp/tools/transactional.ts +++ b/apps/api/src/mcp/tools/transactional.ts @@ -1,4 +1,4 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { McpToolRegistrar } from "../tool-registry"; import { z } from "zod"; import { emailHeadersSchema, @@ -32,7 +32,7 @@ const transactionalEmailListSchema = z.object({ * so every `createTransactionalEmail` failure just becomes an `isError` * result with the same message a REST caller would have gotten in the body. */ -export function registerTransactionalTools(server: McpServer): void { +export function registerTransactionalTools(server: McpToolRegistrar): void { server.registerTool( "send_email", { diff --git a/apps/docs/content/docs/developers/authentication.mdx b/apps/docs/content/docs/developers/authentication.mdx index 388e81d..902ebda 100644 --- a/apps/docs/content/docs/developers/authentication.mdx +++ b/apps/docs/content/docs/developers/authentication.mdx @@ -20,11 +20,15 @@ GET /.well-known/oauth-authorization-server GET /.well-known/openid-configuration ``` -MCP clients may use Dynamic Client Registration to create a public client. A -registered public client must use Authorization Code with S256 PKCE. It may -request only SendLit's supported scopes: `openid`, `profile`, `email`, -`offline_access`, contact, template, media, broadcast, and sequence scopes. -Unauthenticated registration is rate-limited to 20 requests per IP per minute. +MCP clients may use a pre-registered OAuth client, a Client ID Metadata Document +(CIMD), or Dynamic Client Registration (DCR). With CIMD, the `client_id` is an +HTTPS metadata-document URL. Modern clients should prefer CIMD; DCR remains +available for existing clients. Clients must use Authorization Code +with S256 PKCE and request only the MCP scopes they need; the complete scope +list and tool groups are documented on the [MCP server page](/developers/mcp). +Authorization requests must include an explicit `scope`; SendLit rejects an +omitted or empty value instead of granting the client's complete capability set. +Unauthenticated DCR is rate-limited to 20 requests per IP per minute. For a multi-team account, SendLit asks the user to choose the team during the OAuth flow; the resulting token is restricted to that chosen team. diff --git a/apps/docs/content/docs/developers/mcp.mdx b/apps/docs/content/docs/developers/mcp.mdx index 72a2cfc..2a42607 100644 --- a/apps/docs/content/docs/developers/mcp.mdx +++ b/apps/docs/content/docs/developers/mcp.mdx @@ -3,7 +3,7 @@ title: MCP server description: Connect MCP clients to SendLit email operations. --- -SendLit exposes the same team-scoped email capabilities through a Model Context Protocol (MCP) server. MCP clients connect to the Streamable HTTP endpoint: +SendLit exposes the same team-scoped email capabilities through a Model Context Protocol (MCP) server over stateless Streamable HTTP at: ```text https://api.sendlit.app/mcp @@ -13,7 +13,11 @@ For a local installation, replace the origin with the API origin configured in y ## Connect and authenticate -The endpoint accepts JSON-RPC over HTTP. A client creates a session with the MCP `initialize` request, then sends later requests with the returned `Mcp-Session-Id` header. The server also supports JSON responses for clients that do not use server-sent events. +SendLit supports both the current `2026-07-28` protocol and the existing `2025-11-25` Streamable HTTP initialize handshake. Modern clients may call `server/discover`, then `tools/list` and `tools/call`; existing clients begin with `initialize`. Every request is self-contained: there is no MCP session ID or retained server-side transport state. + +The retired SSE transport is not supported. In particular, `GET /mcp` is not an MCP endpoint; clients must use HTTP `POST` to `/mcp`. + +Current clients construct the protocol envelope and the `MCP-Protocol-Version`, `Mcp-Method`, and `Mcp-Name` headers. Do not set those headers manually unless you are implementing an MCP client. Authenticate with one of these headers: @@ -36,18 +40,31 @@ GET /.well-known/oauth-authorization-server GET /.well-known/openid-configuration ``` -OAuth authorization, consent, token, introspection, revocation, and userinfo endpoints are provided under `/api/auth/oauth2/*`. +OAuth authorization, consent, token, introspection, revocation, and userinfo endpoints are provided under `/api/auth/oauth2/*`. SendLit supports pre-registered clients, Client ID Metadata Documents (CIMD), and Dynamic Client Registration (DCR). For CIMD, the OAuth `client_id` is the HTTPS URL of the client's metadata document. Modern clients should prefer CIMD; DCR remains available for existing clients such as MCP Inspector. -MCP clients may dynamically register a public OAuth client. Use Authorization -Code with S256 PKCE, request only the scopes your client needs, and keep any -refresh token in the client's secure credential store. Dynamic registration -does not grant access by itself: every authorization still requires a SendLit -user to sign in, select a team when necessary, and approve consent. +Use Authorization Code with S256 PKCE, request only the scopes your client needs, and keep any refresh token in the client's secure credential store. The authorization request must include an explicit, non-empty `scope`; SendLit does not turn a missing value into a full-access grant. Every authorization requires a SendLit user to sign in, select a team when necessary, and approve consent. -API keys always select one fixed team. Dashboard-session clients that can access multiple teams must provide `X-Sendlit-Team-Id`; clients with exactly one team may omit it. +API keys always select one fixed team and currently grant the complete MCP tool set for that team. Dashboard session cookies and `X-Sendlit-Team-Id` are not accepted as MCP authentication. OAuth clients (including MCP clients like Claude) have no way to send a custom header during authorization, so a multi-team account instead picks a team as part of the OAuth flow itself: after login, and before the consent screen, the account is shown a "select a team" step (mirroring how apps like Notion resolve their own multi-workspace ambiguity). The chosen team is baked into the issued access token and used for every subsequent request — no header needed. Accounts with exactly one team skip this step entirely. +### OAuth scopes + +OAuth access is default-deny per tool. Read and write permissions are separate: + +- `contacts:read`, `contacts:write` +- `templates:read`, `templates:write` +- `media:read`, `media:write` +- `sequences:read`, `sequences:write` +- `emails:read`, `emails:send` +- `settings:read`, `settings:write` +- `esp:read`, `esp:write` +- `teams:read`, `teams:write` +- `api_keys:read`, `api_keys:write` +- `feedback:read`, `feedback:write` +- `delivery_events:read` +- `suppressions:read`, `suppressions:write` + ## Available tools ### Contacts and segments @@ -144,4 +161,4 @@ Prefer the client’s secure secret-store or environment-variable mechanism. Do ## Operational limits -The MCP endpoint is rate limited to 60 requests per minute. A missing or invalid credential returns an HTTP 401 response; an unknown `Mcp-Session-Id` returns an MCP session-not-found error. Start a new MCP session when a client’s session has expired or been closed. +The MCP endpoint is rate limited to 60 requests per minute. High-impact tools such as sending email, activating sequences, testing ESPs, managing API keys, deleting teams, and releasing suppressions are limited to 20 requests per minute. A missing or invalid credential returns HTTP 401, a missing OAuth scope returns an `insufficient_scope` tool error, an unsupported protocol revision is rejected, and a non-JSON request returns HTTP 415. Because requests are stateless, retry only operations that are safe to repeat. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b86c266..8d0f87f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -252,15 +252,21 @@ importers: apps/api: dependencies: + '@better-auth/cimd': + specifier: 1.7.0-rc.4 + version: 1.7.0-rc.4(591ed4c01c545a546cd472036b6c7566) '@better-auth/oauth-provider': - specifier: ^1.6.23 - version: 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.3)(kysely@0.29.3)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.6.23(@opentelemetry/api@1.9.1)(drizzle-kit@0.28.1(supports-color@5.5.0))(drizzle-orm@0.45.2(@electric-sql/pglite@0.2.17)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.29.3)(pg@8.22.0))(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@22.20.0)(jsdom@25.0.1)(vite@8.1.3(@types/node@22.20.0)(esbuild@0.19.12)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))))(better-call@1.3.7(zod@3.25.76)) + specifier: 1.7.0-rc.4 + version: 1.7.0-rc.4(@better-auth/core@1.7.0-rc.4(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.3)(kysely@0.29.3)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.7.0-rc.4(@opentelemetry/api@1.9.1)(drizzle-kit@0.28.1(supports-color@5.5.0))(drizzle-orm@0.45.2(@electric-sql/pglite@0.2.17)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.29.3)(pg@8.22.0))(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@22.20.0)(jsdom@25.0.1(supports-color@5.5.0))(vite@8.1.3(@types/node@22.20.0)(esbuild@0.19.12)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))))(better-call@1.3.7(zod@3.25.76)) '@codelitdev/oauth-server-kit': specifier: 0.1.0-alpha.0 - version: 0.1.0-alpha.0(ce61357118989180888ec3ed2000cf6a) - '@modelcontextprotocol/sdk': - specifier: ^1.29.0 - version: 1.29.0(supports-color@5.5.0)(zod@3.25.76) + version: 0.1.0-alpha.0(6e1caef0c85a7dbb3bde5a382f6e4e4f) + '@modelcontextprotocol/node': + specifier: ^2.0.0 + version: 2.0.0(@modelcontextprotocol/server@2.0.0)(hono@4.12.27) + '@modelcontextprotocol/server': + specifier: ^2.0.0 + version: 2.0.0 '@sendlit/api-contract': specifier: workspace:* version: link:../../packages/api-contract @@ -277,8 +283,8 @@ importers: specifier: ^3.52.1 version: 3.52.1(@ts-rest/core@3.52.1(@types/node@22.20.0)(zod@3.25.76))(zod@3.25.76) better-auth: - specifier: ^1.6.23 - version: 1.6.23(@opentelemetry/api@1.9.1)(drizzle-kit@0.28.1(supports-color@5.5.0))(drizzle-orm@0.45.2(@electric-sql/pglite@0.2.17)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.29.3)(pg@8.22.0))(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@22.20.0)(jsdom@25.0.1)(vite@8.1.3(@types/node@22.20.0)(esbuild@0.19.12)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))) + specifier: 1.7.0-rc.4 + version: 1.7.0-rc.4(@opentelemetry/api@1.9.1)(drizzle-kit@0.28.1(supports-color@5.5.0))(drizzle-orm@0.45.2(@electric-sql/pglite@0.2.17)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.29.3)(pg@8.22.0))(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@22.20.0)(jsdom@25.0.1(supports-color@5.5.0))(vite@8.1.3(@types/node@22.20.0)(esbuild@0.19.12)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))) bullmq: specifier: ^5.34.0 version: 5.79.2(supports-color@5.5.0) @@ -302,7 +308,7 @@ importers: version: 5.11.1(supports-color@5.5.0) jsdom: specifier: ^25.0.1 - version: 25.0.1 + version: 25.0.1(supports-color@5.5.0) jsonwebtoken: specifier: ^9.0.2 version: 9.0.3 @@ -342,10 +348,16 @@ importers: zod: specifier: ^3.25.76 version: 3.25.76 + zod-to-json-schema: + specifier: ^3.25.2 + version: 3.25.2(zod@3.25.76) devDependencies: '@electric-sql/pglite': specifier: ^0.2.17 version: 0.2.17 + '@modelcontextprotocol/client': + specifier: ^2.0.0 + version: 2.0.0 '@types/cors': specifier: ^2.8.12 version: 2.8.19 @@ -384,7 +396,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.9 - version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@22.20.0)(jsdom@25.0.1)(vite@8.1.3(@types/node@22.20.0)(esbuild@0.19.12)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@22.20.0)(jsdom@25.0.1(supports-color@5.5.0))(vite@8.1.3(@types/node@22.20.0)(esbuild@0.19.12)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) apps/docs: dependencies: @@ -548,7 +560,7 @@ importers: version: 19.2.3(@types/react@19.2.17) jsdom: specifier: ^25.0.1 - version: 25.0.1 + version: 25.0.1(supports-color@5.5.0) postcss: specifier: ^8.4.35 version: 8.5.16 @@ -920,8 +932,16 @@ packages: resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} - '@better-auth/core@1.6.23': - resolution: {integrity: sha512-beEhOs0uVeOxYOZKUfIEBd/nQV2Bd4/6wyLxZ0OFkn6CMTK2Vi+hXuZLnyPBeB6RdHpebEoJWiHqwHxBIxgPDQ==} + '@better-auth/cimd@1.7.0-rc.4': + resolution: {integrity: sha512-BmMamG7HzcO6jlZ9klQF6ComLOeTIdL/sDaksLpLbievUXaXvB1HG7ltwANTmQXfjHOlqraiiS9gprctG/r81A==} + peerDependencies: + '@better-auth/core': ^1.7.0-rc.4 + '@better-auth/oauth-provider': ^1.7.0-rc.4 + better-auth: ^1.7.0-rc.4 + better-call: 1.3.7 + + '@better-auth/core@1.7.0-rc.4': + resolution: {integrity: sha512-hkxBHcEXU6qxGd08ovBVuaSsENB78A45sclB5dEbohUMckkCbTab1eydGknsMF7SfE/vGcgPER5vzAQwNzP9oQ==} peerDependencies: '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 @@ -937,55 +957,55 @@ packages: '@opentelemetry/api': optional: true - '@better-auth/drizzle-adapter@1.6.23': - resolution: {integrity: sha512-2+/PTVfIP9E7iz6af8TB3lhnowHUj9ljC66kECmHaFEdUqPgzHoWux9epotKwO7XDg2ui4ttWQ8CMeNFLvQeKQ==} + '@better-auth/drizzle-adapter@1.7.0-rc.4': + resolution: {integrity: sha512-UpBwR54jBJALnwqcezGS+CYj2i3qF6bl5PAzL8o0WDQleN6QHmqOC6fUHq8+las6R4Lj6BRozdUzbE4DaGusfg==} peerDependencies: - '@better-auth/core': ^1.6.23 + '@better-auth/core': ^1.7.0-rc.4 '@better-auth/utils': 0.4.2 - drizzle-orm: ^0.45.2 + drizzle-orm: ^0.45.2 || >=1.0.0-rc.1 <2.0.0 peerDependenciesMeta: drizzle-orm: optional: true - '@better-auth/kysely-adapter@1.6.23': - resolution: {integrity: sha512-zbNJsMbG09exfkGyvFqBLLqWoMPAUWjxCuUnEK5AsjbYoZeIjj/QGZgdf4CapVWryKxjA9Q6Jlr6fbiPpC3VAg==} + '@better-auth/kysely-adapter@1.7.0-rc.4': + resolution: {integrity: sha512-JNApE34ATqfxMfOLyE+e4KwqZzg7ZkhcN4gnKNuLIQPq7ja/NTt5FnZCgbFCOviGmN5kfOWIvpRyp/hW+YjLjA==} peerDependencies: - '@better-auth/core': ^1.6.23 + '@better-auth/core': ^1.7.0-rc.4 '@better-auth/utils': 0.4.2 kysely: ^0.28.17 || ^0.29.0 peerDependenciesMeta: kysely: optional: true - '@better-auth/memory-adapter@1.6.23': - resolution: {integrity: sha512-krIiR0pIVkaKlAzm690n5bcMW4NGbqeMg0HQSD9fz/KcQF/eWLqcq9gG/BhHTj2i/y96qH+W5JWPmaSOS5iTgQ==} + '@better-auth/memory-adapter@1.7.0-rc.4': + resolution: {integrity: sha512-/yIudwUCWRTEOyZteZ1dqa0cEk5/Dot4WiIw4TlMsGB2Rk0/HIVqtBULBWW551ihefWrz9AKGuFVtXQhRMXAZw==} peerDependencies: - '@better-auth/core': ^1.6.23 + '@better-auth/core': ^1.7.0-rc.4 '@better-auth/utils': 0.4.2 - '@better-auth/mongo-adapter@1.6.23': - resolution: {integrity: sha512-7+QdevitGlKBbP6JbiSk5SBnzPsKV/mDrQBGBn8hwByQLeJwqpqbuBPw7ZI8vzUlFfAAnyFiqwP3Eb8mxnp7pA==} + '@better-auth/mongo-adapter@1.7.0-rc.4': + resolution: {integrity: sha512-BRGwL0HfTa2hWW0WIHlsXpCBqwXVJUR6gQkaLA2Bm9a76KLaPVWLzigZJIfcVFZLaQJrFQ1ZdIh/zqbfC8m4Dw==} peerDependencies: - '@better-auth/core': ^1.6.23 + '@better-auth/core': ^1.7.0-rc.4 '@better-auth/utils': 0.4.2 mongodb: ^6.0.0 || ^7.0.0 peerDependenciesMeta: mongodb: optional: true - '@better-auth/oauth-provider@1.6.23': - resolution: {integrity: sha512-1sDN+N4Sztmpk8ziCU3MXicxOTfvYoHvHvhJMQ7PSfr+pLXnYN+dJFI9S3zBRwstmTeJx/OhRIZWrwFJ0TgBnA==} + '@better-auth/oauth-provider@1.7.0-rc.4': + resolution: {integrity: sha512-7br1e+gJsFAC0EB4IDuEefF005CeaB8hbY59c8FMpcqiR5Vp+8mT2e8dmYSSjfH13Y34ObGR+ORI7mGQhzxlHQ==} peerDependencies: - '@better-auth/core': ^1.6.23 + '@better-auth/core': ^1.7.0-rc.4 '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 - better-auth: ^1.6.23 + better-auth: ^1.7.0-rc.4 better-call: 1.3.7 - '@better-auth/prisma-adapter@1.6.23': - resolution: {integrity: sha512-2qSdzidq4tkb1eS5TTqb4Nzg0mdZWm3Qky9SYeXeb8PpVQbC2sxqJhEM5mK7y12uU6I8hc64wO9f7AFVNL+6UQ==} + '@better-auth/prisma-adapter@1.7.0-rc.4': + resolution: {integrity: sha512-SOmE17lirhGIXAJdvlThgNjt57Z98N5CewVFNRiPGHOv2w7llR3Fc6ASvoUTClB4pP0YyBjNZZwLVCwEoAKxwQ==} peerDependencies: - '@better-auth/core': ^1.6.23 + '@better-auth/core': ^1.7.0-rc.4 '@better-auth/utils': 0.4.2 '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 @@ -995,10 +1015,10 @@ packages: prisma: optional: true - '@better-auth/telemetry@1.6.23': - resolution: {integrity: sha512-/R2Kb+z2BpDOOWwVHqOk+c0VNpuwfCv4Hp5Yr9003WIZPax/zyNraGLB9CFE8qF2gZW8Dsz419k4I8CPrGzpDA==} + '@better-auth/telemetry@1.7.0-rc.4': + resolution: {integrity: sha512-IZM3cDmLCbrmdRtBKEJtZ6Gqmfu2RBvai0DLEzXBs6/nTG5BVk3oz7JbcITAdE0AoMCV8rMdZJ3C1k/nIhZdDg==} peerDependencies: - '@better-auth/core': ^1.6.23 + '@better-auth/core': ^1.7.0-rc.4 '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 @@ -2194,6 +2214,24 @@ packages: '@mdx-js/mdx@3.1.1': resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==} + '@modelcontextprotocol/client@2.0.0': + resolution: {integrity: sha512-8f1OghQ2rjzIOfqgUCP+8GiUWqRs89njoWLNqAe8kWmDePv3s1fZXseej+QXemssEuuOvLLmLO/kqM3IQHtISw==} + engines: {node: '>=20'} + + '@modelcontextprotocol/core@2.0.0': + resolution: {integrity: sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==} + engines: {node: '>=20'} + + '@modelcontextprotocol/node@2.0.0': + resolution: {integrity: sha512-Y4hAC2XdGDUdDOCbLDOCA4+aL3NUldjsOWlDL/YwpAxrPhRm1xHd7lZ+mLacvZ9t3PaH28wgNoaLQGrIk1P2pg==} + engines: {node: '>=20'} + peerDependencies: + '@modelcontextprotocol/server': ^2.0.0 + hono: ^4.11.4 + peerDependenciesMeta: + hono: + optional: true + '@modelcontextprotocol/sdk@1.29.0': resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} engines: {node: '>=18'} @@ -2204,6 +2242,10 @@ packages: '@cfworker/json-schema': optional: true + '@modelcontextprotocol/server@2.0.0': + resolution: {integrity: sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==} + engines: {node: '>=20'} + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': resolution: {integrity: sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==} cpu: [arm64] @@ -4563,8 +4605,8 @@ packages: engines: {node: '>=6.0.0'} hasBin: true - better-auth@1.6.23: - resolution: {integrity: sha512-4vOaRd9UiKGKm9R+ej0jjU1es3MiJIiNc9Qq3VCnYqOZ4/nb5272QqTxWYoDxyUXl5x6A2x2we5KZKQO9teTQQ==} + better-auth@1.7.0-rc.4: + resolution: {integrity: sha512-1ONP6b9715zc/NqQV2LFpk7HYakjOUtPeXYuBI20ou6tY8zQ5VUYQYJ65GBXnDYDua9CSrre9CbuYkZ8FpnzfQ==} peerDependencies: '@lynx-js/react': '*' '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 @@ -4572,7 +4614,7 @@ packages: '@tanstack/react-start': ^1.0.0 '@tanstack/solid-start': ^1.0.0 better-sqlite3: ^12.0.0 - drizzle-kit: '>=0.31.4' + drizzle-kit: '>=0.31.4 || >=1.0.0-beta.1' drizzle-orm: ^0.45.2 mongodb: ^6.0.0 || ^7.0.0 mysql2: ^3.0.0 @@ -8415,10 +8457,6 @@ packages: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'} - type-is@2.0.1: - resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} - engines: {node: '>= 0.6'} - type-is@2.1.0: resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} engines: {node: '>= 18'} @@ -9044,7 +9082,14 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.3)(kysely@0.29.3)(nanostores@1.4.0)': + '@better-auth/cimd@1.7.0-rc.4(591ed4c01c545a546cd472036b6c7566)': + dependencies: + '@better-auth/core': 1.7.0-rc.4(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.3)(kysely@0.29.3)(nanostores@1.4.0) + '@better-auth/oauth-provider': 1.7.0-rc.4(@better-auth/core@1.7.0-rc.4(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.3)(kysely@0.29.3)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.7.0-rc.4(@opentelemetry/api@1.9.1)(drizzle-kit@0.28.1(supports-color@5.5.0))(drizzle-orm@0.45.2(@electric-sql/pglite@0.2.17)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.29.3)(pg@8.22.0))(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@22.20.0)(jsdom@25.0.1(supports-color@5.5.0))(vite@8.1.3(@types/node@22.20.0)(esbuild@0.19.12)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))))(better-call@1.3.7(zod@3.25.76)) + better-auth: 1.7.0-rc.4(@opentelemetry/api@1.9.1)(drizzle-kit@0.28.1(supports-color@5.5.0))(drizzle-orm@0.45.2(@electric-sql/pglite@0.2.17)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.29.3)(pg@8.22.0))(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@22.20.0)(jsdom@25.0.1(supports-color@5.5.0))(vite@8.1.3(@types/node@22.20.0)(esbuild@0.19.12)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))) + better-call: 1.3.7(zod@3.25.76) + + '@better-auth/core@1.7.0-rc.4(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.3)(kysely@0.29.3)(nanostores@1.4.0)': dependencies: '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 @@ -9058,48 +9103,48 @@ snapshots: optionalDependencies: '@opentelemetry/api': 1.9.1 - '@better-auth/drizzle-adapter@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.3)(kysely@0.29.3)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@electric-sql/pglite@0.2.17)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.29.3)(pg@8.22.0))': + '@better-auth/drizzle-adapter@1.7.0-rc.4(@better-auth/core@1.7.0-rc.4(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.3)(kysely@0.29.3)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@electric-sql/pglite@0.2.17)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.29.3)(pg@8.22.0))': dependencies: - '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.3)(kysely@0.29.3)(nanostores@1.4.0) + '@better-auth/core': 1.7.0-rc.4(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.3)(kysely@0.29.3)(nanostores@1.4.0) '@better-auth/utils': 0.4.2 optionalDependencies: drizzle-orm: 0.45.2(@electric-sql/pglite@0.2.17)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.29.3)(pg@8.22.0) - '@better-auth/kysely-adapter@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.3)(kysely@0.29.3)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(kysely@0.29.3)': + '@better-auth/kysely-adapter@1.7.0-rc.4(@better-auth/core@1.7.0-rc.4(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.3)(kysely@0.29.3)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(kysely@0.29.3)': dependencies: - '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.3)(kysely@0.29.3)(nanostores@1.4.0) + '@better-auth/core': 1.7.0-rc.4(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.3)(kysely@0.29.3)(nanostores@1.4.0) '@better-auth/utils': 0.4.2 optionalDependencies: kysely: 0.29.3 - '@better-auth/memory-adapter@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.3)(kysely@0.29.3)(nanostores@1.4.0))(@better-auth/utils@0.4.2)': + '@better-auth/memory-adapter@1.7.0-rc.4(@better-auth/core@1.7.0-rc.4(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.3)(kysely@0.29.3)(nanostores@1.4.0))(@better-auth/utils@0.4.2)': dependencies: - '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.3)(kysely@0.29.3)(nanostores@1.4.0) + '@better-auth/core': 1.7.0-rc.4(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.3)(kysely@0.29.3)(nanostores@1.4.0) '@better-auth/utils': 0.4.2 - '@better-auth/mongo-adapter@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.3)(kysely@0.29.3)(nanostores@1.4.0))(@better-auth/utils@0.4.2)': + '@better-auth/mongo-adapter@1.7.0-rc.4(@better-auth/core@1.7.0-rc.4(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.3)(kysely@0.29.3)(nanostores@1.4.0))(@better-auth/utils@0.4.2)': dependencies: - '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.3)(kysely@0.29.3)(nanostores@1.4.0) + '@better-auth/core': 1.7.0-rc.4(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.3)(kysely@0.29.3)(nanostores@1.4.0) '@better-auth/utils': 0.4.2 - '@better-auth/oauth-provider@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.3)(kysely@0.29.3)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.6.23(@opentelemetry/api@1.9.1)(drizzle-kit@0.28.1(supports-color@5.5.0))(drizzle-orm@0.45.2(@electric-sql/pglite@0.2.17)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.29.3)(pg@8.22.0))(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@22.20.0)(jsdom@25.0.1)(vite@8.1.3(@types/node@22.20.0)(esbuild@0.19.12)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))))(better-call@1.3.7(zod@3.25.76))': + '@better-auth/oauth-provider@1.7.0-rc.4(@better-auth/core@1.7.0-rc.4(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.3)(kysely@0.29.3)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.7.0-rc.4(@opentelemetry/api@1.9.1)(drizzle-kit@0.28.1(supports-color@5.5.0))(drizzle-orm@0.45.2(@electric-sql/pglite@0.2.17)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.29.3)(pg@8.22.0))(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@22.20.0)(jsdom@25.0.1(supports-color@5.5.0))(vite@8.1.3(@types/node@22.20.0)(esbuild@0.19.12)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))))(better-call@1.3.7(zod@3.25.76))': dependencies: - '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.3)(kysely@0.29.3)(nanostores@1.4.0) + '@better-auth/core': 1.7.0-rc.4(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.3)(kysely@0.29.3)(nanostores@1.4.0) '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 - better-auth: 1.6.23(@opentelemetry/api@1.9.1)(drizzle-kit@0.28.1(supports-color@5.5.0))(drizzle-orm@0.45.2(@electric-sql/pglite@0.2.17)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.29.3)(pg@8.22.0))(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@22.20.0)(jsdom@25.0.1)(vite@8.1.3(@types/node@22.20.0)(esbuild@0.19.12)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))) + better-auth: 1.7.0-rc.4(@opentelemetry/api@1.9.1)(drizzle-kit@0.28.1(supports-color@5.5.0))(drizzle-orm@0.45.2(@electric-sql/pglite@0.2.17)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.29.3)(pg@8.22.0))(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@22.20.0)(jsdom@25.0.1(supports-color@5.5.0))(vite@8.1.3(@types/node@22.20.0)(esbuild@0.19.12)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))) better-call: 1.3.7(zod@3.25.76) jose: 6.2.3 zod: 4.4.3 - '@better-auth/prisma-adapter@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.3)(kysely@0.29.3)(nanostores@1.4.0))(@better-auth/utils@0.4.2)': + '@better-auth/prisma-adapter@1.7.0-rc.4(@better-auth/core@1.7.0-rc.4(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.3)(kysely@0.29.3)(nanostores@1.4.0))(@better-auth/utils@0.4.2)': dependencies: - '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.3)(kysely@0.29.3)(nanostores@1.4.0) + '@better-auth/core': 1.7.0-rc.4(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.3)(kysely@0.29.3)(nanostores@1.4.0) '@better-auth/utils': 0.4.2 - '@better-auth/telemetry@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.3)(kysely@0.29.3)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)': + '@better-auth/telemetry@1.7.0-rc.4(@better-auth/core@1.7.0-rc.4(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.3)(kysely@0.29.3)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)': dependencies: - '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.3)(kysely@0.29.3)(nanostores@1.4.0) + '@better-auth/core': 1.7.0-rc.4(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.3)(kysely@0.29.3)(nanostores@1.4.0) '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 @@ -9257,10 +9302,10 @@ snapshots: react: 19.2.7 react-dom: 19.2.7(react@19.2.7) - '@codelitdev/oauth-server-kit@0.1.0-alpha.0(ce61357118989180888ec3ed2000cf6a)': + '@codelitdev/oauth-server-kit@0.1.0-alpha.0(6e1caef0c85a7dbb3bde5a382f6e4e4f)': dependencies: - '@better-auth/oauth-provider': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.3)(kysely@0.29.3)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.6.23(@opentelemetry/api@1.9.1)(drizzle-kit@0.28.1(supports-color@5.5.0))(drizzle-orm@0.45.2(@electric-sql/pglite@0.2.17)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.29.3)(pg@8.22.0))(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@22.20.0)(jsdom@25.0.1)(vite@8.1.3(@types/node@22.20.0)(esbuild@0.19.12)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))))(better-call@1.3.7(zod@3.25.76)) - better-auth: 1.6.23(@opentelemetry/api@1.9.1)(drizzle-kit@0.28.1(supports-color@5.5.0))(drizzle-orm@0.45.2(@electric-sql/pglite@0.2.17)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.29.3)(pg@8.22.0))(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@22.20.0)(jsdom@25.0.1)(vite@8.1.3(@types/node@22.20.0)(esbuild@0.19.12)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))) + '@better-auth/oauth-provider': 1.7.0-rc.4(@better-auth/core@1.7.0-rc.4(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.3)(kysely@0.29.3)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.7.0-rc.4(@opentelemetry/api@1.9.1)(drizzle-kit@0.28.1(supports-color@5.5.0))(drizzle-orm@0.45.2(@electric-sql/pglite@0.2.17)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.29.3)(pg@8.22.0))(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@22.20.0)(jsdom@25.0.1(supports-color@5.5.0))(vite@8.1.3(@types/node@22.20.0)(esbuild@0.19.12)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))))(better-call@1.3.7(zod@3.25.76)) + better-auth: 1.7.0-rc.4(@opentelemetry/api@1.9.1)(drizzle-kit@0.28.1(supports-color@5.5.0))(drizzle-orm@0.45.2(@electric-sql/pglite@0.2.17)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.29.3)(pg@8.22.0))(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@22.20.0)(jsdom@25.0.1(supports-color@5.5.0))(vite@8.1.3(@types/node@22.20.0)(esbuild@0.19.12)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))) express: 4.22.2 '@csstools/color-helpers@5.1.0': {} @@ -10042,7 +10087,28 @@ snapshots: transitivePeerDependencies: - supports-color - '@modelcontextprotocol/sdk@1.29.0(supports-color@5.5.0)(zod@3.25.76)': + '@modelcontextprotocol/client@2.0.0': + dependencies: + '@modelcontextprotocol/core': 2.0.0 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.0 + jose: 6.2.3 + pkce-challenge: 5.0.1 + zod: 4.4.3 + + '@modelcontextprotocol/core@2.0.0': + dependencies: + zod: 4.4.3 + + '@modelcontextprotocol/node@2.0.0(@modelcontextprotocol/server@2.0.0)(hono@4.12.27)': + dependencies: + '@hono/node-server': 1.19.14(hono@4.12.27) + '@modelcontextprotocol/server': 2.0.0 + optionalDependencies: + hono: 4.12.27 + + '@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)': dependencies: '@hono/node-server': 1.19.14(hono@4.12.27) ajv: 8.20.0 @@ -10052,7 +10118,7 @@ snapshots: cross-spawn: 7.0.6 eventsource: 3.0.7 eventsource-parser: 3.1.0 - express: 5.2.1(supports-color@5.5.0) + express: 5.2.1 express-rate-limit: 8.5.2(express@5.2.1) hono: 4.12.27 jose: 6.2.3 @@ -10064,6 +10130,11 @@ snapshots: transitivePeerDependencies: - supports-color + '@modelcontextprotocol/server@2.0.0': + dependencies: + '@modelcontextprotocol/core': 2.0.0 + zod: 4.4.3 + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': optional: true @@ -12487,15 +12558,15 @@ snapshots: baseline-browser-mapping@2.10.40: {} - better-auth@1.6.23(@opentelemetry/api@1.9.1)(drizzle-kit@0.28.1(supports-color@5.5.0))(drizzle-orm@0.45.2(@electric-sql/pglite@0.2.17)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.29.3)(pg@8.22.0))(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@22.20.0)(jsdom@25.0.1)(vite@8.1.3(@types/node@22.20.0)(esbuild@0.19.12)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))): + better-auth@1.7.0-rc.4(@opentelemetry/api@1.9.1)(drizzle-kit@0.28.1(supports-color@5.5.0))(drizzle-orm@0.45.2(@electric-sql/pglite@0.2.17)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.29.3)(pg@8.22.0))(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@22.20.0)(jsdom@25.0.1(supports-color@5.5.0))(vite@8.1.3(@types/node@22.20.0)(esbuild@0.19.12)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))): dependencies: - '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.3)(kysely@0.29.3)(nanostores@1.4.0) - '@better-auth/drizzle-adapter': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.3)(kysely@0.29.3)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@electric-sql/pglite@0.2.17)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.29.3)(pg@8.22.0)) - '@better-auth/kysely-adapter': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.3)(kysely@0.29.3)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(kysely@0.29.3) - '@better-auth/memory-adapter': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.3)(kysely@0.29.3)(nanostores@1.4.0))(@better-auth/utils@0.4.2) - '@better-auth/mongo-adapter': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.3)(kysely@0.29.3)(nanostores@1.4.0))(@better-auth/utils@0.4.2) - '@better-auth/prisma-adapter': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.3)(kysely@0.29.3)(nanostores@1.4.0))(@better-auth/utils@0.4.2) - '@better-auth/telemetry': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.3)(kysely@0.29.3)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1) + '@better-auth/core': 1.7.0-rc.4(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.3)(kysely@0.29.3)(nanostores@1.4.0) + '@better-auth/drizzle-adapter': 1.7.0-rc.4(@better-auth/core@1.7.0-rc.4(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.3)(kysely@0.29.3)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@electric-sql/pglite@0.2.17)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.29.3)(pg@8.22.0)) + '@better-auth/kysely-adapter': 1.7.0-rc.4(@better-auth/core@1.7.0-rc.4(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.3)(kysely@0.29.3)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(kysely@0.29.3) + '@better-auth/memory-adapter': 1.7.0-rc.4(@better-auth/core@1.7.0-rc.4(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.3)(kysely@0.29.3)(nanostores@1.4.0))(@better-auth/utils@0.4.2) + '@better-auth/mongo-adapter': 1.7.0-rc.4(@better-auth/core@1.7.0-rc.4(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.3)(kysely@0.29.3)(nanostores@1.4.0))(@better-auth/utils@0.4.2) + '@better-auth/prisma-adapter': 1.7.0-rc.4(@better-auth/core@1.7.0-rc.4(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.3)(kysely@0.29.3)(nanostores@1.4.0))(@better-auth/utils@0.4.2) + '@better-auth/telemetry': 1.7.0-rc.4(@better-auth/core@1.7.0-rc.4(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.3)(kysely@0.29.3)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1) '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 '@noble/ciphers': 2.2.0 @@ -12513,7 +12584,7 @@ snapshots: pg: 8.22.0 react: 19.2.7 react-dom: 19.2.7(react@19.2.7) - vitest: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@22.20.0)(jsdom@25.0.1)(vite@8.1.3(@types/node@22.20.0)(esbuild@0.19.12)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + vitest: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@22.20.0)(jsdom@25.0.1(supports-color@5.5.0))(vite@8.1.3(@types/node@22.20.0)(esbuild@0.19.12)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) transitivePeerDependencies: - '@cloudflare/workers-types' - '@opentelemetry/api' @@ -12559,7 +12630,7 @@ snapshots: transitivePeerDependencies: - supports-color - body-parser@2.3.0(supports-color@5.5.0): + body-parser@2.3.0: dependencies: bytes: 3.1.2 content-type: 2.0.0 @@ -13727,7 +13798,7 @@ snapshots: express-rate-limit@8.5.2(express@5.2.1): dependencies: - express: 5.2.1(supports-color@5.5.0) + express: 5.2.1 ip-address: 10.2.0 express@4.22.2: @@ -13766,10 +13837,10 @@ snapshots: transitivePeerDependencies: - supports-color - express@5.2.1(supports-color@5.5.0): + express@5.2.1: dependencies: accepts: 2.0.0 - body-parser: 2.3.0(supports-color@5.5.0) + body-parser: 2.3.0 content-disposition: 1.1.0 content-type: 1.0.5 cookie: 0.7.2 @@ -13779,7 +13850,7 @@ snapshots: encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 - finalhandler: 2.1.1(supports-color@5.5.0) + finalhandler: 2.1.1 fresh: 2.0.0 http-errors: 2.0.1 merge-descriptors: 2.0.0 @@ -13790,11 +13861,11 @@ snapshots: proxy-addr: 2.0.7 qs: 6.15.3 range-parser: 1.2.1 - router: 2.2.0(supports-color@5.5.0) - send: 1.2.1(supports-color@5.5.0) + router: 2.2.0 + send: 1.2.1 serve-static: 2.2.1 statuses: 2.0.2 - type-is: 2.0.1 + type-is: 2.1.0 vary: 1.1.2 transitivePeerDependencies: - supports-color @@ -13879,7 +13950,7 @@ snapshots: transitivePeerDependencies: - supports-color - finalhandler@2.1.1(supports-color@5.5.0): + finalhandler@2.1.1: dependencies: debug: 4.4.3(supports-color@5.5.0) encodeurl: 2.0.0 @@ -14410,14 +14481,14 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 - http-proxy-agent@7.0.2: + http-proxy-agent@7.0.2(supports-color@5.5.0): dependencies: agent-base: 7.1.4 debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color - https-proxy-agent@7.0.6: + https-proxy-agent@7.0.6(supports-color@5.5.0): dependencies: agent-base: 7.1.4 debug: 4.4.3(supports-color@5.5.0) @@ -14735,15 +14806,15 @@ snapshots: dependencies: argparse: 2.0.1 - jsdom@25.0.1: + jsdom@25.0.1(supports-color@5.5.0): dependencies: cssstyle: 4.6.0 data-urls: 5.0.0 decimal.js: 10.6.0 form-data: 4.0.6 html-encoding-sniffer: 4.0.0 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 + http-proxy-agent: 7.0.2(supports-color@5.5.0) + https-proxy-agent: 7.0.6(supports-color@5.5.0) is-potential-custom-element-name: 1.0.1 nwsapi: 2.2.24 parse5: 7.3.0 @@ -16515,7 +16586,7 @@ snapshots: rou3@0.7.12: {} - router@2.2.0(supports-color@5.5.0): + router@2.2.0: dependencies: debug: 4.4.3(supports-color@5.5.0) depd: 2.0.0 @@ -16598,7 +16669,7 @@ snapshots: transitivePeerDependencies: - supports-color - send@1.2.1(supports-color@5.5.0): + send@1.2.1: dependencies: debug: 4.4.3(supports-color@5.5.0) encodeurl: 2.0.0 @@ -16628,7 +16699,7 @@ snapshots: encodeurl: 2.0.0 escape-html: 1.0.3 parseurl: 1.3.3 - send: 1.2.1(supports-color@5.5.0) + send: 1.2.1 transitivePeerDependencies: - supports-color @@ -16665,7 +16736,7 @@ snapshots: '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7) '@babel/preset-typescript': 7.29.7(@babel/core@7.29.7) '@dotenvx/dotenvx': 1.75.1 - '@modelcontextprotocol/sdk': 1.29.0(supports-color@5.5.0)(zod@3.25.76) + '@modelcontextprotocol/sdk': 1.29.0(zod@3.25.76) '@types/validate-npm-package-name': 4.0.2 browserslist: 4.28.4 commander: 14.0.3 @@ -17212,12 +17283,6 @@ snapshots: media-typer: 0.3.0 mime-types: 2.1.35 - type-is@2.0.1: - dependencies: - content-type: 1.0.5 - media-typer: 1.1.0 - mime-types: 3.0.2 - type-is@2.1.0: dependencies: content-type: 2.0.0 @@ -17486,7 +17551,7 @@ snapshots: tsx: 4.22.4 yaml: 2.9.0 - vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@22.20.0)(jsdom@25.0.1)(vite@8.1.3(@types/node@22.20.0)(esbuild@0.19.12)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): + vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@22.20.0)(jsdom@25.0.1(supports-color@5.5.0))(vite@8.1.3(@types/node@22.20.0)(esbuild@0.19.12)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.9 '@vitest/mocker': 4.1.9(vite@8.1.3(@types/node@22.20.0)(esbuild@0.19.12)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) @@ -17511,7 +17576,7 @@ snapshots: optionalDependencies: '@opentelemetry/api': 1.9.1 '@types/node': 22.20.0 - jsdom: 25.0.1 + jsdom: 25.0.1(supports-color@5.5.0) transitivePeerDependencies: - msw @@ -17540,7 +17605,7 @@ snapshots: optionalDependencies: '@opentelemetry/api': 1.9.1 '@types/node': 22.20.0 - jsdom: 25.0.1 + jsdom: 25.0.1(supports-color@5.5.0) transitivePeerDependencies: - msw @@ -17569,7 +17634,7 @@ snapshots: optionalDependencies: '@opentelemetry/api': 1.9.1 '@types/node': 22.20.0 - jsdom: 25.0.1 + jsdom: 25.0.1(supports-color@5.5.0) transitivePeerDependencies: - msw