diff --git a/apps/dashboard/.env.example b/apps/dashboard/.env.example index 7795fea4d..5e45d7c49 100644 --- a/apps/dashboard/.env.example +++ b/apps/dashboard/.env.example @@ -54,11 +54,6 @@ VERCEL_URL= PLAYGROUND_UNKEY_API_KEY= -# RUM server with separate clickhouse for self-host -CLICKHOUSE_URL= -CLICKHOUSE_USERNAME= -CLICKHOUSE_PASSWORD= - AUTH_SECRET=secret AUTH_DRIZZLE_URL=file://./../../openstatus-dev.db AUTH_GITHUB_ID= diff --git a/apps/dashboard/next-env.d.ts b/apps/dashboard/next-env.d.ts index c4b7818fb..a419cbe4e 100644 --- a/apps/dashboard/next-env.d.ts +++ b/apps/dashboard/next-env.d.ts @@ -1,6 +1,7 @@ /// /// import "./.next/dev/types/routes.d.ts"; +import "./.next/dev/types/root-params.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/dashboard/src/app/(dashboard)/settings/integrations/connected-apps-card.tsx b/apps/dashboard/src/app/(dashboard)/settings/integrations/connected-apps-card.tsx new file mode 100644 index 000000000..558dbfda1 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/settings/integrations/connected-apps-card.tsx @@ -0,0 +1,160 @@ +"use client"; + +import type { RouterOutputs } from "@openstatus/api"; +import { Badge } from "@openstatus/ui/components/ui/badge"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@openstatus/ui/components/ui/table"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { Link } from "@/components/common/link"; +import { + EmptyStateContainer, + EmptyStateDescription, + EmptyStateTitle, +} from "@/components/content/empty-state"; +import { QuickActions } from "@/components/dropdowns/quick-actions"; +import { + FormCard, + FormCardContent, + FormCardDescription, + FormCardFooter, + FormCardFooterInfo, + FormCardHeader, + FormCardTitle, +} from "@/components/forms/form-card"; +import { formatDate } from "@/lib/formatter"; +import { useTRPC } from "@/lib/trpc/client"; + +type Grant = RouterOutputs["oauth"]["listGrants"][number]; + +function scopeLabel(scope: Grant["scope"]): string { + return scope.includes("write") ? "Read & write" : "Read-only"; +} + +function userLabel(user: Grant["user"]): string { + if (!user) return "Removed user"; + const name = [user.firstName, user.lastName].filter(Boolean).join(" "); + return name || user.email || `User ${user.id}`; +} + +/** OAuth grants minted through `/oauth/authorize` (Claude, Cursor, ChatGPT and other MCP clients). */ +export function ConnectedAppsCard() { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const query = useQuery(trpc.oauth.listGrants.queryOptions()); + const grants = query.data ?? []; + + const revoke = useMutation( + trpc.oauth.revokeGrant.mutationOptions({ + onSuccess: () => + queryClient.invalidateQueries({ + queryKey: trpc.oauth.listGrants.queryKey(), + }), + }), + ); + + return ( + + + Connected apps + + MCP clients that members authorized with OAuth. Revoking signs the app + out immediately. + + + + {query.isPending ? ( +

+ Loading connected apps… +

+ ) : query.isError ? ( +

+ Could not load connected apps. Reload the page to try again. +

+ ) : grants.length === 0 ? ( + + No connected apps + + Add the openstatus MCP server to Claude, Cursor or ChatGPT and + approve the connection here. + + + ) : ( +
+ + + + App + Access + Connected by + Connected + Last used + + Actions + + + + + {grants.map((grant) => ( + + + {grant.clientName} + + + + {scopeLabel(grant.scope)} + + + + {userLabel(grant.user)} + + + {grant.createdAt ? formatDate(grant.createdAt) : "-"} + + + {grant.lastUsedAt + ? formatDate(grant.lastUsedAt) + : "Never"} + + +
+ + await revoke.mutateAsync({ grantId: grant.id }), + }} + /> +
+
+
+ ))} +
+
+
+ )} +
+ + + Members can revoke their own connections; owners and admins can revoke + any.{" "} + + MCP server docs + + . + +
+ + + ); +} diff --git a/apps/dashboard/src/app/(dashboard)/settings/integrations/layout.tsx b/apps/dashboard/src/app/(dashboard)/settings/integrations/layout.tsx index 7ea6ecf4d..c827db5eb 100644 --- a/apps/dashboard/src/app/(dashboard)/settings/integrations/layout.tsx +++ b/apps/dashboard/src/app/(dashboard)/settings/integrations/layout.tsx @@ -16,7 +16,10 @@ export default async function Layout({ children: React.ReactNode; }) { const queryClient = getQueryClient(); - await queryClient.prefetchQuery(trpc.integrationRouter.list.queryOptions()); + await Promise.all([ + queryClient.prefetchQuery(trpc.integrationRouter.list.queryOptions()), + queryClient.prefetchQuery(trpc.oauth.listGrants.queryOptions()), + ]); return ( diff --git a/apps/dashboard/src/app/(dashboard)/settings/integrations/page.tsx b/apps/dashboard/src/app/(dashboard)/settings/integrations/page.tsx index 4c9293c60..adb43aa45 100644 --- a/apps/dashboard/src/app/(dashboard)/settings/integrations/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/settings/integrations/page.tsx @@ -16,6 +16,7 @@ import { import { FormCardGroup } from "@/components/forms/form-card"; import { useTRPC } from "@/lib/trpc/client"; +import { ConnectedAppsCard } from "./connected-apps-card"; import { SlackIntegrationCard } from "./slack-card"; export default function Page() { @@ -62,6 +63,7 @@ export default function Page() { : null } /> + diff --git a/apps/dashboard/src/app/oauth/consent/client.tsx b/apps/dashboard/src/app/oauth/consent/client.tsx new file mode 100644 index 000000000..d8256be41 --- /dev/null +++ b/apps/dashboard/src/app/oauth/consent/client.tsx @@ -0,0 +1,202 @@ +"use client"; + +import type { RouterOutputs } from "@openstatus/api"; +import { Button } from "@openstatus/ui/components/ui/button"; +import { + RadioGroup, + RadioGroupItem, +} from "@openstatus/ui/components/ui/radio-group"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@openstatus/ui/components/ui/select"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { isTRPCClientError } from "@trpc/client"; +import Link from "next/link"; +import { useQueryStates } from "nuqs"; +import { useState } from "react"; +import { toast } from "sonner"; + +import { useTRPC } from "@/lib/trpc/client"; + +import { searchParamsParsers } from "./search-params"; + +type Scope = "read" | "write"; + +// The page fetched the session server-side and hydrated it, so this query +// resolves from cache; the guard below only covers a stale or missing id. +export function Client() { + const trpc = useTRPC(); + const [{ session: sessionId }] = useQueryStates(searchParamsParsers); + const { data } = useQuery({ + ...trpc.oauth.getSession.queryOptions({ id: sessionId ?? "" }), + enabled: Boolean(sessionId), + }); + if (!sessionId || !data) return null; + return ; +} + +function ConsentForm({ + sessionId, + data, +}: { + sessionId: string; + data: RouterOutputs["oauth"]["getSession"]; +}) { + const trpc = useTRPC(); + const { clientName, clientOrigin, scope: requestedScope } = data.session; + const { workspaces } = data; + const requestedWrite = requestedScope.includes("write"); + const [workspaceId, setWorkspaceId] = useState( + workspaces[0]?.id ?? null, + ); + const [scope, setScope] = useState(requestedWrite ? "write" : "read"); + + const decide = useMutation( + trpc.oauth.decide.mutationOptions({ + onSuccess: ({ redirectUrl }) => { + window.location.assign(redirectUrl); + }, + onError: (error) => { + toast.error( + isTRPCClientError(error) ? error.message : "Something went wrong", + ); + }, + }), + ); + + const approve = () => { + if (workspaceId === null) return; + decide.mutate({ id: sessionId, approved: true, workspaceId, scope }); + }; + const deny = () => decide.mutate({ id: sessionId, approved: false }); + const pending = decide.isPending ? decide.variables?.approved : undefined; + + if (workspaces.length === 0) { + return ( +
+

+ Create your first workspace +

+

+ {clientName} needs a workspace to connect to. Create + one, then start the connection again from your client. +

+
+ + +
+
+ ); + } + + return ( +
+
+

+ Connect {clientName} +

+

+ {clientName} wants to access your openstatus + workspace through the MCP server. +

+ {clientOrigin ? ( +

+ Verified app from {clientOrigin} +

+ ) : ( +

+ Self-registered app. Check the name matches the client you are + using. +

+ )} +
+
+
+ + +
+
+

+ Access +

+ setScope(value as Scope)} + className="gap-3" + > + + + +
+
+ + +
+
+

+ You can revoke this connection any time from{" "} + Settings / Integrations. +

+
+ ); +} diff --git a/apps/dashboard/src/app/oauth/consent/layout.tsx b/apps/dashboard/src/app/oauth/consent/layout.tsx new file mode 100644 index 000000000..2f1b57680 --- /dev/null +++ b/apps/dashboard/src/app/oauth/consent/layout.tsx @@ -0,0 +1,8 @@ +import { AuthLayout } from "@/components/layout/auth-layout"; + +// Outside the `(dashboard)` group, like `/login`: no sidebar, no active +// workspace requirement. The proxy middleware still sends signed-out users +// to `/login?redirectTo=…`. +export default function Layout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/apps/dashboard/src/app/oauth/consent/page.tsx b/apps/dashboard/src/app/oauth/consent/page.tsx new file mode 100644 index 000000000..59ff1af5d --- /dev/null +++ b/apps/dashboard/src/app/oauth/consent/page.tsx @@ -0,0 +1,55 @@ +import { TRPCError } from "@trpc/server"; +import type { Metadata } from "next"; +import type { SearchParams } from "nuqs/server"; + +import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; + +import { Client } from "./client"; +import { searchParamsCache } from "./search-params"; + +export const metadata: Metadata = { + title: "Authorize", + robots: { index: false, follow: false }, +}; + +function Unavailable() { + return ( +
+

Start again

+

+ This authorization request has expired or was already answered. Go back + to your client and connect again. +

+
+ ); +} + +export default async function Page(props: { + searchParams: Promise; +}) { + const { session } = searchParamsCache.parse(await props.searchParams); + if (!session) return ; + + // Blocking fetch: an expired or decided session renders server-side. + // Anything else (auth, internal) is a real failure and must surface. + const queryClient = getQueryClient(); + try { + await queryClient.fetchQuery( + trpc.oauth.getSession.queryOptions({ id: session }), + ); + } catch (error) { + if ( + error instanceof TRPCError && + (error.code === "NOT_FOUND" || error.code === "PRECONDITION_FAILED") + ) { + return ; + } + throw error; + } + + return ( + + + + ); +} diff --git a/apps/dashboard/src/app/oauth/consent/search-params.ts b/apps/dashboard/src/app/oauth/consent/search-params.ts new file mode 100644 index 000000000..ce9d0c6bf --- /dev/null +++ b/apps/dashboard/src/app/oauth/consent/search-params.ts @@ -0,0 +1,7 @@ +import { createSearchParamsCache, parseAsString } from "nuqs/server"; + +export const searchParamsParsers = { + session: parseAsString, +}; + +export const searchParamsCache = createSearchParamsCache(searchParamsParsers); diff --git a/apps/server/.env.example b/apps/server/.env.example index eacb114d2..48df71cda 100644 --- a/apps/server/.env.example +++ b/apps/server/.env.example @@ -19,4 +19,10 @@ SCREENSHOT_SERVICE_URL=http://your.endpoint QSTASH_TOKEN=your_token NEXT_PUBLIC_OPENPANEL_CLIENT_ID= -OPENPANEL_CLIENT_SECRET= \ No newline at end of file +OPENPANEL_CLIENT_SECRET= + +# OAuth authorization server (MCP clients). Both default to the production +# hosts when NODE_ENV=production, else http://localhost:3000 for the issuer and +# http://localhost:3001 for the dashboard. Set them when self-hosting. +OAUTH_ISSUER= +DASHBOARD_URL= diff --git a/apps/server/package.json b/apps/server/package.json index c76fb8649..400729e6c 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -6,7 +6,7 @@ "main": "src/index.ts", "scripts": { "env": "deno run -A env.ts", - "dev": "deno run -A --watch --sloppy-imports src/serve.ts", + "dev": "deno run -A --watch --sloppy-imports --env-file=.env src/serve.ts", "start": "NODE_ENV=production deno run -A --sloppy-imports src/serve.ts", "test": "deno test --parallel -A --no-check --sloppy-imports --env-file=.env.test --import-map=test.importmap.json", "check": "deno check --sloppy-imports src/serve.ts", diff --git a/apps/server/src/env.ts b/apps/server/src/env.ts index b0472b416..ba6a65d81 100644 --- a/apps/server/src/env.ts +++ b/apps/server/src/env.ts @@ -25,6 +25,10 @@ export const env = createEnv({ SLACK_CLIENT_SECRET: z.string().optional(), SLACK_REDIRECT_URI: z.string().optional(), AI_GATEWAY_API_KEY: z.string().optional(), + // Fixed, not derived from the request host, so OAuth metadata is stable + // behind proxies. Defaults resolve in `routes/oauth/config.ts`. + OAUTH_ISSUER: z.url().optional(), + DASHBOARD_URL: z.url().optional(), }, /** diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index e80fb143b..e80e2f80a 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -27,6 +27,8 @@ import { requestId } from "hono/request-id"; import { env } from "./env"; import { handleError } from "./libs/errors"; import { mcpRoute } from "./routes/mcp"; +import { createOAuthRoutes } from "./routes/oauth"; +import { oauthConfigFromEnv } from "./routes/oauth/config"; import { openapiRoute } from "./routes/openapi"; import { publicRoute } from "./routes/public"; import { mountRpcRoutes } from "./routes/rpc"; @@ -203,6 +205,12 @@ app.onError(handleError); mountRpcRoutes(app); +/** + * OAuth 2.1 authorization server for MCP clients: RFC 8414 / 9728 metadata, + * dynamic registration, authorize, token and revoke. + */ +app.route("/", createOAuthRoutes(oauthConfigFromEnv())); + /** * Public Routes */ diff --git a/apps/server/src/libs/middlewares/auth.ts b/apps/server/src/libs/middlewares/auth.ts index 4390e6e50..a49871957 100644 --- a/apps/server/src/libs/middlewares/auth.ts +++ b/apps/server/src/libs/middlewares/auth.ts @@ -11,6 +11,7 @@ import { verifyApiKeyHash, } from "@openstatus/db/src/utils/api-key"; import { retryRead } from "@openstatus/services"; +import { isAccessToken, verifyAccessToken } from "@openstatus/services/oauth"; import { UnkeyCore } from "@unkey/api/core"; import { keysVerifyKey } from "@unkey/api/funcs/keysVerifyKey"; import type { Context, Next } from "hono"; @@ -19,6 +20,8 @@ import { env } from "@/env"; import { OpenStatusApiError } from "@/libs/errors"; import type { Variables } from "@/types"; +import { MISSING_CREDENTIALS_MESSAGE, extractCredential } from "./credentials"; + const logger = getLogger("api-server"); export async function lookupWorkspace(workspaceId: number) { @@ -49,14 +52,14 @@ export async function authMiddleware( c: Context<{ Variables: Variables }, "/*">, next: Next, ) { - const key = c.req.header("x-openstatus-key"); - if (!key) + const credential = extractCredential(c.req.raw.headers); + if (!credential) throw new OpenStatusApiError({ code: "UNAUTHORIZED", - message: "Missing 'x-openstatus-key' header", + message: MISSING_CREDENTIALS_MESSAGE, }); - const { error, result } = await validateKey(key); + const { error, result } = await validateKey(credential.token); if (error) { throw new OpenStatusApiError({ @@ -146,6 +149,29 @@ export async function validateKey(key: string): Promise<{ }; error?: { message: string }; }> { + // Before the production gate so the OAuth flow works locally. `keyId` + // carries the grant id so audit rows attribute to the grant, and + // `createdById` is the consenting user. + if (isAccessToken(key)) { + const grant = await verifyAccessToken(key); + if (!grant) { + return { + result: { valid: false }, + error: { message: "Invalid or expired access token" }, + }; + } + return { + result: { + valid: true, + ownerId: String(grant.workspaceId), + authMethod: "oauth", + keyId: `oat_${grant.grantId}`, + createdById: grant.userId, + scopes: grant.scopes, + }, + }; + } + if (env.NODE_ENV === "production") { /** * Both custom and Unkey API keys use the `os_` prefix for seamless transition. diff --git a/apps/server/src/libs/middlewares/credentials.test.ts b/apps/server/src/libs/middlewares/credentials.test.ts new file mode 100644 index 000000000..4acffc5f1 --- /dev/null +++ b/apps/server/src/libs/middlewares/credentials.test.ts @@ -0,0 +1,48 @@ +import { expect } from "@std/expect"; +import { describe, test } from "@std/testing/bdd"; + +import { extractCredential } from "./credentials"; + +describe("extractCredential", () => { + test("reads x-openstatus-key", () => { + const headers = new Headers({ "x-openstatus-key": "os_abc" }); + expect(extractCredential(headers)).toEqual({ + token: "os_abc", + source: "x-openstatus-key", + }); + }); + + test("falls back to a bearer token, case-insensitively", () => { + expect( + extractCredential(new Headers({ Authorization: "Bearer os_oat_x" })), + ).toEqual({ + token: "os_oat_x", + source: "bearer", + }); + expect( + extractCredential(new Headers({ authorization: "bearer os_oat_y " })) + ?.token, + ).toBe("os_oat_y"); + }); + + test("prefers the header over the bearer token when both are present", () => { + const headers = new Headers({ + "x-openstatus-key": "os_key", + Authorization: "Bearer os_oat_x", + }); + expect(extractCredential(headers)?.token).toBe("os_key"); + }); + + test("ignores non-bearer authorization schemes and empty values", () => { + expect( + extractCredential(new Headers({ Authorization: "Basic abc" })), + ).toBeNull(); + expect( + extractCredential(new Headers({ Authorization: "Bearer" })), + ).toBeNull(); + expect( + extractCredential(new Headers({ Authorization: "Bearer a b" })), + ).toBeNull(); + expect(extractCredential(new Headers())).toBeNull(); + }); +}); diff --git a/apps/server/src/libs/middlewares/credentials.ts b/apps/server/src/libs/middlewares/credentials.ts new file mode 100644 index 000000000..001399c70 --- /dev/null +++ b/apps/server/src/libs/middlewares/credentials.ts @@ -0,0 +1,22 @@ +export type Credential = { + token: string; + source: "x-openstatus-key" | "bearer"; +}; + +export const MISSING_CREDENTIALS_MESSAGE = + "Missing credentials: send an 'x-openstatus-key' header or 'Authorization: Bearer '"; + +/** `x-openstatus-key` wins over `Authorization: Bearer`; both surfaces share this order. */ +export function extractCredential(headers: { + get(name: string): string | null | undefined; +}): Credential | null { + const key = headers.get("x-openstatus-key"); + if (key) return { token: key, source: "x-openstatus-key" }; + + const authorization = headers.get("authorization"); + if (authorization) { + const match = /^Bearer\s+(\S+)\s*$/i.exec(authorization); + if (match?.[1]) return { token: match[1], source: "bearer" }; + } + return null; +} diff --git a/apps/server/src/libs/middlewares/index.ts b/apps/server/src/libs/middlewares/index.ts index 9840dc3cc..1bdd02eed 100644 --- a/apps/server/src/libs/middlewares/index.ts +++ b/apps/server/src/libs/middlewares/index.ts @@ -2,3 +2,4 @@ export * from "./auth"; export * from "./require-scope"; export * from "./track"; export * from "./plan"; +export * from "./credentials"; diff --git a/apps/server/src/libs/middlewares/validate-key.test.ts b/apps/server/src/libs/middlewares/validate-key.test.ts index 58f5355a9..f4ecabba5 100644 --- a/apps/server/src/libs/middlewares/validate-key.test.ts +++ b/apps/server/src/libs/middlewares/validate-key.test.ts @@ -31,3 +31,11 @@ describe("validateKey (dev fallback)", () => { expect(result.authMethod).toBe("dev"); }); }); + +describe("validateKey (oauth access tokens)", () => { + test("rejects an unknown os_oat_ token before the dev fallback", async () => { + const { result, error } = await validateKey("os_oat_not-a-real-token"); + expect(result.valid).toBe(false); + expect(error?.message).toContain("access token"); + }); +}); diff --git a/apps/server/src/routes/mcp/index.ts b/apps/server/src/routes/mcp/index.ts index 6821a285d..97206f09f 100644 --- a/apps/server/src/routes/mcp/index.ts +++ b/apps/server/src/routes/mcp/index.ts @@ -1,22 +1,32 @@ import { StreamableHTTPTransport } from "@hono/mcp"; import type { Workspace } from "@openstatus/db/src/schema"; +import { resourceMetadataUrl } from "@openstatus/services/oauth"; import { Hono } from "hono"; import type { Context, Next } from "hono"; import { handleError } from "../../libs/errors"; import { authMiddleware } from "../../libs/middlewares/auth"; import type { Variables } from "../../types"; +import { oauthConfigFromEnv } from "../oauth/config"; import { toServiceCtx } from "./adapter"; import { createMcpServer, createPublicMcpServer } from "./server"; export const mcpRoute = new Hono<{ Variables: Variables }>({ strict: false }); +const wwwAuthenticate = `Bearer resource_metadata="${resourceMetadataUrl(oauthConfigFromEnv().issuer)}"`; + // Match production's global error handler at the sub-router level so // `OpenStatusApiError` (thrown by `authMiddleware` on bad/missing // keys) translates to the right HTTP status whether or not the parent // app has its own `onError` wired up. This makes the route portable // across mount points and self-contained for tests. -mcpRoute.onError(handleError); +// A 401 also carries `WWW-Authenticate` (RFC 9728) so OAuth clients can +// start discovery; the envelope body stays for header-based callers. +mcpRoute.onError((err, c) => { + const res = handleError(err, c); + if (res.status === 401) res.headers.set("WWW-Authenticate", wwwAuthenticate); + return res; +}); /** * An MCP client `initialize`s before it has a credential, and a handshake that @@ -32,7 +42,14 @@ async function optionalAuthMiddleware( // `=== undefined` rather than a truthiness check: a client that sends the // header with an empty value is misconfigured, not anonymous, and should be // told so instead of silently dropping to the public surface. - if (c.req.header("x-openstatus-key") === undefined) return next(); + // A bearer token is a credential too; it must be validated, never treated + // as anonymous. + if ( + c.req.header("x-openstatus-key") === undefined && + c.req.header("authorization") === undefined + ) { + return next(); + } return authMiddleware(c, next); } diff --git a/apps/server/src/routes/oauth/config.ts b/apps/server/src/routes/oauth/config.ts new file mode 100644 index 000000000..cf5387a24 --- /dev/null +++ b/apps/server/src/routes/oauth/config.ts @@ -0,0 +1,30 @@ +import type { ClientMetadataFetcher } from "@openstatus/services/oauth"; + +import { env } from "@/env"; + +export type OAuthConfig = { + /** Origin of the authorization server and the `/mcp` resource, no trailing slash. */ + issuer: string; + /** Origin of the dashboard that serves `/oauth/consent`, no trailing slash. */ + dashboardUrl: string; + /** URL client id resolver; production leaves it unset, tests stub it. */ + fetchClientMetadata?: ClientMetadataFetcher; +}; + +const trimSlash = (url: string) => url.replace(/\/+$/, ""); + +// `skipValidation` in env.ts means zod never runs: defaults do not apply and +// an empty string passes through, so fall back on falsy, not just nullish. +export function oauthConfigFromEnv(): OAuthConfig { + const production = env.NODE_ENV === "production"; + return { + issuer: trimSlash( + env.OAUTH_ISSUER || + (production ? "https://api.openstatus.dev" : "http://localhost:3000"), + ), + dashboardUrl: trimSlash( + env.DASHBOARD_URL || + (production ? "https://app.openstatus.dev" : "http://localhost:3001"), + ), + }; +} diff --git a/apps/server/src/routes/oauth/index.ts b/apps/server/src/routes/oauth/index.ts new file mode 100644 index 000000000..bbbe7faca --- /dev/null +++ b/apps/server/src/routes/oauth/index.ts @@ -0,0 +1,225 @@ +import { getLogger } from "@logtape/logtape"; +import { ServiceError } from "@openstatus/services"; +import { + GRANT_TYPES, + OAuthError, + authorizationServerMetadata, + createSession, + exchangeCode, + mcpResource, + protectedResourceMetadata, + refreshGrant, + registerClient, + revokeToken, +} from "@openstatus/services/oauth"; +import { type Context, Hono } from "hono"; +import { cors } from "hono/cors"; +import { ZodError } from "zod"; + +import type { OAuthConfig } from "./config"; + +const logger = getLogger("api-server"); + +const NO_STORE = { "Cache-Control": "no-store", Pragma: "no-cache" }; + +type Body = Record; + +/** Token and revoke accept form-encoded (RFC 6749) and JSON bodies. */ +async function readBody(c: Context): Promise { + const contentType = c.req.header("content-type") ?? ""; + try { + if (contentType.includes("application/json")) { + const json: unknown = await c.req.json(); + if (!json || typeof json !== "object" || Array.isArray(json)) { + throw new OAuthError("invalid_request", "Body must be a JSON object"); + } + const body: Body = {}; + for (const [k, v] of Object.entries(json)) { + if (typeof v === "string") body[k] = v; + } + return body; + } + const form = await c.req.parseBody(); + const body: Body = {}; + for (const [k, v] of Object.entries(form)) { + if (typeof v === "string") body[k] = v; + } + return body; + } catch (err) { + if (err instanceof OAuthError) throw err; + throw new OAuthError("invalid_request", "Malformed request body"); + } +} + +function require(body: Body, name: string): string { + const value = body[name]; + if (!value) throw new OAuthError("invalid_request", `${name} is required`); + return value; +} + +/** + * RFC 6749 error bodies, never the openstatus envelope: MCP clients treat + * anything else as a transport failure. Authorize-time errors with a valid + * redirect target go back to the client via the redirect. + */ +function handleOAuthError(err: Error, c: Context): Response { + if (err instanceof OAuthError) { + if (err.redirectUri) { + const url = new URL(err.redirectUri); + url.searchParams.set("error", err.oauthCode); + url.searchParams.set("error_description", err.message); + if (err.state !== undefined) url.searchParams.set("state", err.state); + return c.redirect(url.toString(), 302); + } + // 401 is the token/revoke contract; an authorize-time unknown client is + // shown to the resource owner as a plain 400. + const clientAuthEndpoint = /\/oauth\/(token|revoke)$/.test(c.req.path); + return c.json( + { error: err.oauthCode, error_description: err.message }, + err.oauthCode === "invalid_client" && clientAuthEndpoint ? 401 : 400, + NO_STORE, + ); + } + if (err instanceof ZodError) { + return c.json( + { error: "invalid_request", error_description: err.message }, + 400, + NO_STORE, + ); + } + if (err instanceof ServiceError) { + switch (err.code) { + case "UNAUTHORIZED": + return c.json( + { error: "invalid_client", error_description: err.message }, + 401, + NO_STORE, + ); + case "FORBIDDEN": + return c.json( + { error: "access_denied", error_description: err.message }, + 400, + NO_STORE, + ); + case "NOT_FOUND": + case "VALIDATION": + case "CONFLICT": + case "PRECONDITION_FAILED": + case "LIMIT_EXCEEDED": + return c.json( + { error: "invalid_request", error_description: err.message }, + 400, + NO_STORE, + ); + case "INTERNAL": + break; + } + } + logger.error("OAuth route error", { + error: { name: err.name, message: err.message, stack: err.stack }, + method: c.req.method, + url: c.req.url, + }); + return c.json( + { error: "server_error", error_description: "Something went wrong" }, + 500, + NO_STORE, + ); +} + +export function createOAuthRoutes(config: OAuthConfig) { + const app = new Hono({ strict: false }); + const resource = mcpResource(config.issuer); + + // Browser-based MCP clients fetch metadata and tokens cross-origin. Scoped + // to these paths: the sub-app mounts at "/", so "*" would cover every route. + const oauthCors = cors({ + origin: "*", + allowMethods: ["GET", "POST", "OPTIONS"], + allowHeaders: ["Content-Type", "Authorization", "mcp-protocol-version"], + maxAge: 86400, + }); + app.use("/.well-known/*", oauthCors); + app.use("/oauth/*", oauthCors); + app.onError(handleOAuthError); + + // RFC 8414 + app.get("/.well-known/oauth-authorization-server", (c) => + c.json(authorizationServerMetadata(config.issuer)), + ); + + // RFC 9728, path-suffixed for the `/mcp` resource. + app.get("/.well-known/oauth-protected-resource/mcp", (c) => + c.json(protectedResourceMetadata(config.issuer)), + ); + + // RFC 7591 + app.post("/oauth/register", async (c) => { + const json: unknown = await c.req.json().catch(() => null); + if (!json || typeof json !== "object" || Array.isArray(json)) { + throw new OAuthError( + "invalid_client_metadata", + "Body must be a JSON object", + ); + } + const client = await registerClient({ + input: json as Parameters[0]["input"], + }); + return c.json(client, 201, NO_STORE); + }); + + app.get("/oauth/authorize", async (c) => { + const { id } = await createSession({ + input: { ...c.req.query(), expectedResource: resource }, + fetchClientMetadata: config.fetchClientMetadata, + }); + const consent = new URL("/oauth/consent", config.dashboardUrl); + consent.searchParams.set("session", id); + return c.redirect(consent.toString(), 302); + }); + + app.post("/oauth/token", async (c) => { + const body = await readBody(c); + const grantType = require(body, "grant_type"); + const clientId = require(body, "client_id"); + + switch (grantType) { + case "authorization_code": { + const tokens = await exchangeCode({ + input: { + clientId, + code: require(body, "code"), + codeVerifier: require(body, "code_verifier"), + redirectUri: require(body, "redirect_uri"), + }, + }); + return c.json(tokens, 200, NO_STORE); + } + case "refresh_token": { + const tokens = await refreshGrant({ + input: { clientId, refreshToken: require(body, "refresh_token") }, + }); + return c.json(tokens, 200, NO_STORE); + } + default: + throw new OAuthError( + "unsupported_grant_type", + `grant_type must be one of ${GRANT_TYPES.join(", ")}`, + ); + } + }); + + // RFC 7009: 200 whether or not the token was known. + app.post("/oauth/revoke", async (c) => { + const body = await readBody(c); + await revokeToken({ + input: { + clientId: require(body, "client_id"), + token: require(body, "token"), + }, + }); + return c.body(null, 200, NO_STORE); + }); + + return app; +} diff --git a/apps/server/src/routes/oauth/oauth.test.ts b/apps/server/src/routes/oauth/oauth.test.ts new file mode 100644 index 000000000..89a45a763 --- /dev/null +++ b/apps/server/src/routes/oauth/oauth.test.ts @@ -0,0 +1,671 @@ +import { db, eq, inArray } from "@openstatus/db"; +import { + oauthAuthorizationCode, + oauthClient, + oauthGrant, + oauthSession, +} from "@openstatus/db/src/schema"; +import { createTestWorkspace } from "@openstatus/db/src/test/factories"; +import { + OAuthError, + decideSession, + parseClientMetadataDocument, + pkceChallenge, +} from "@openstatus/services/oauth"; +import { clearAuditLogFor } from "@openstatus/services/test/helpers"; +import { expect } from "@std/expect"; +import { afterAll, beforeAll, describe, test } from "@std/testing/bdd"; +import { Hono } from "hono"; + +import { app } from "../../index"; +import { oauthConfigFromEnv } from "./config"; +import * as routes from "./index"; + +const VERIFIER = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; +const REDIRECT = "http://127.0.0.1:43111/callback"; +const config = oauthConfigFromEnv(); + +let workspaceId: number; +let userId: number; +const clientIds: string[] = []; + +beforeAll(async () => { + const fixture = await createTestWorkspace({ plan: "team" }); + workspaceId = fixture.workspace.id; + userId = fixture.user.id; +}); + +afterAll(async () => { + if (clientIds.length === 0) return; + const grants = await db + .select({ id: oauthGrant.id }) + .from(oauthGrant) + .where(inArray(oauthGrant.clientId, clientIds)) + .all(); + await clearAuditLogFor({ + entityType: "oauth_grant", + entityIds: grants.map((g) => g.id), + }); + await db.delete(oauthGrant).where(inArray(oauthGrant.clientId, clientIds)); + await db + .delete(oauthAuthorizationCode) + .where(inArray(oauthAuthorizationCode.clientId, clientIds)); + await db + .delete(oauthSession) + .where(inArray(oauthSession.clientId, clientIds)); + await db.delete(oauthClient).where(inArray(oauthClient.clientId, clientIds)); +}); + +function json(path: string, body: unknown, init: RequestInit = {}) { + return app.request(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + ...init, + }); +} + +function form(path: string, fields: Record) { + return app.request(path, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams(fields).toString(), + }); +} + +async function registerClient(name = "Test MCP Client"): Promise { + const res = await json("/oauth/register", { + client_name: name, + redirect_uris: [REDIRECT], + token_endpoint_auth_method: "none", + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + }); + expect(res.status).toBe(201); + const body = await res.json(); + clientIds.push(body.client_id); + return body.client_id; +} + +async function authorize(clientId: string, extra: Record = {}) { + const params = new URLSearchParams({ + response_type: "code", + client_id: clientId, + redirect_uri: REDIRECT, + scope: "read write", + state: "s-1", + code_challenge: await pkceChallenge(VERIFIER), + code_challenge_method: "S256", + ...extra, + }); + return app.request(`/oauth/authorize?${params.toString()}`, { + redirect: "manual", + }); +} + +/** Dashboard consent, driven through the service the tRPC router wraps. */ +async function consent(sessionId: string, scope?: ("read" | "write")[]) { + const { redirectUrl } = await decideSession({ + input: { id: sessionId, approved: true, userId, workspaceId, scope }, + }); + const code = new URL(redirectUrl).searchParams.get("code"); + if (!code) throw new Error("no code"); + return code; +} + +async function sessionIdFromAuthorize(clientId: string): Promise { + const res = await authorize(clientId); + expect(res.status).toBe(302); + const location = new URL(res.headers.get("location") ?? ""); + expect(location.origin).toBe(new URL(config.dashboardUrl).origin); + expect(location.pathname).toBe("/oauth/consent"); + const id = location.searchParams.get("session"); + if (!id) throw new Error("consent redirect carries no session id"); + return id; +} + +async function mintTokens(clientId: string, scope?: ("read" | "write")[]) { + const sessionId = await sessionIdFromAuthorize(clientId); + const code = await consent(sessionId, scope); + const res = await form("/oauth/token", { + grant_type: "authorization_code", + client_id: clientId, + code, + code_verifier: VERIFIER, + redirect_uri: REDIRECT, + }); + expect(res.status).toBe(200); + return res.json() as Promise<{ + access_token: string; + refresh_token: string; + token_type: string; + expires_in: number; + scope: string; + }>; +} + +function mcp(headers: Record = {}) { + return app.request("/mcp", { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + ...headers, + }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }), + }); +} + +async function toolNames(res: Response): Promise { + const text = await res.text(); + const match = text.match(/data:\s*({.*})/); + if (!match) throw new Error(`no data frame in SSE response: ${text}`); + const body = JSON.parse(match[1]) as { + result?: { tools: { name: string }[] }; + }; + return (body.result?.tools ?? []).map((t) => t.name).sort(); +} + +function rpc(headers: Record = {}) { + return app.request("/rpc/openstatus.monitor.v1.MonitorService/ListMonitors", { + method: "POST", + headers: { "Content-Type": "application/json", ...headers }, + body: JSON.stringify({}), + }); +} + +describe("well-known metadata", () => { + test("authorization server metadata (RFC 8414)", async () => { + const res = await app.request("/.well-known/oauth-authorization-server"); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toMatchObject({ + issuer: config.issuer, + authorization_endpoint: `${config.issuer}/oauth/authorize`, + token_endpoint: `${config.issuer}/oauth/token`, + registration_endpoint: `${config.issuer}/oauth/register`, + revocation_endpoint: `${config.issuer}/oauth/revoke`, + response_types_supported: ["code"], + grant_types_supported: ["authorization_code", "refresh_token"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + scopes_supported: ["read", "write"], + }); + }); + + test("protected resource metadata for /mcp (RFC 9728)", async () => { + const res = await app.request("/.well-known/oauth-protected-resource/mcp"); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ + resource: `${config.issuer}/mcp`, + authorization_servers: [config.issuer], + bearer_methods_supported: ["header"], + scopes_supported: ["read", "write"], + }); + }); + + test("answers CORS preflight", async () => { + const res = await app.request("/oauth/token", { + method: "OPTIONS", + headers: { + Origin: "https://claude.ai", + "Access-Control-Request-Method": "POST", + }, + }); + expect(res.status).toBe(204); + expect(res.headers.get("access-control-allow-origin")).toBe("*"); + }); + + test("does not leak the wildcard CORS policy onto other routes", async () => { + const res = await app.request("/ping", { + headers: { Origin: "https://claude.ai" }, + }); + expect(res.status).toBe(200); + expect(res.headers.get("access-control-allow-origin")).toBeNull(); + }); +}); + +describe("POST /oauth/register", () => { + test("registers a public client", async () => { + const res = await json("/oauth/register", { + client_name: "Claude", + redirect_uris: [REDIRECT], + }); + expect(res.status).toBe(201); + const body = await res.json(); + clientIds.push(body.client_id); + expect(body).toMatchObject({ + client_name: "Claude", + redirect_uris: [REDIRECT], + token_endpoint_auth_method: "none", + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + }); + expect(body.client_id).toMatch(/^[a-f0-9]{32}$/); + expect(body.client_secret).toBeUndefined(); + expect(res.headers.get("cache-control")).toBe("no-store"); + }); + + test("rejects off-allowlist redirect URIs with an RFC 7591 error body", async () => { + const res = await json("/oauth/register", { + redirect_uris: ["https://attacker.example/cb"], + }); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toBe("invalid_redirect_uri"); + expect(typeof body.error_description).toBe("string"); + expect(body.code).toBeUndefined(); + }); + + test("rejects non-object bodies and confidential clients", async () => { + expect((await json("/oauth/register", ["x"])).status).toBe(400); + const res = await json("/oauth/register", { + redirect_uris: [REDIRECT], + token_endpoint_auth_method: "client_secret_basic", + }); + expect(res.status).toBe(400); + expect((await res.json()).error).toBe("invalid_client_metadata"); + }); +}); + +describe("GET /oauth/authorize", () => { + test("stores a session and redirects to the dashboard consent page", async () => { + const clientId = await registerClient(); + const id = await sessionIdFromAuthorize(clientId); + const row = await db + .select() + .from(oauthSession) + .where(eq(oauthSession.id, id)) + .get(); + expect(row?.clientId).toBe(clientId); + expect(row?.scope).toEqual(["write"]); + expect(row?.state).toBe("s-1"); + }); + + test("unknown client or wrong redirect_uri is a 400, not a redirect", async () => { + const clientId = await registerClient(); + const unknown = await authorize("0000", {}); + expect(unknown.status).toBe(400); + expect((await unknown.json()).error).toBe("invalid_client"); + + const mismatch = await authorize(clientId, { + redirect_uri: "http://127.0.0.1:43111/elsewhere", + }); + expect(mismatch.status).toBe(400); + expect((await mismatch.json()).error).toBe("invalid_redirect_uri"); + }); + + test("missing PKCE redirects back to the client with error and state", async () => { + const clientId = await registerClient(); + const res = await authorize(clientId, { code_challenge: "" }); + expect(res.status).toBe(302); + const url = new URL(res.headers.get("location") ?? ""); + expect(`${url.origin}${url.pathname}`).toBe(REDIRECT); + expect(url.searchParams.get("error")).toBe("invalid_request"); + expect(url.searchParams.get("state")).toBe("s-1"); + }); + + test("a resource parameter must name the MCP endpoint", async () => { + const clientId = await registerClient(); + const bad = await authorize(clientId, { + resource: "https://other.example/mcp", + }); + expect(bad.status).toBe(302); + expect( + new URL(bad.headers.get("location") ?? "").searchParams.get("error"), + ).toBe("invalid_target"); + const good = await authorize(clientId, { + resource: `${config.issuer}/mcp`, + }); + expect(new URL(good.headers.get("location") ?? "").pathname).toBe( + "/oauth/consent", + ); + }); +}); + +describe("POST /oauth/token", () => { + test("authorization_code with a form body returns a bearer pair", async () => { + const clientId = await registerClient(); + const tokens = await mintTokens(clientId); + expect(tokens.token_type).toBe("bearer"); + expect(tokens.expires_in).toBe(3600); + expect(tokens.scope).toBe("write"); + expect(tokens.access_token).toMatch(/^os_oat_/); + }); + + test("authorization_code with a JSON body and a narrowed scope", async () => { + const clientId = await registerClient(); + const sessionId = await sessionIdFromAuthorize(clientId); + const code = await consent(sessionId, ["read"]); + const res = await json("/oauth/token", { + grant_type: "authorization_code", + client_id: clientId, + code, + code_verifier: VERIFIER, + redirect_uri: REDIRECT, + }); + expect(res.status).toBe(200); + expect(res.headers.get("cache-control")).toBe("no-store"); + expect((await res.json()).scope).toBe("read"); + }); + + test("wrong verifier, replayed code and unsupported grant types answer RFC 6749 errors", async () => { + const clientId = await registerClient(); + const sessionId = await sessionIdFromAuthorize(clientId); + const code = await consent(sessionId); + const base = { + grant_type: "authorization_code", + client_id: clientId, + code, + redirect_uri: REDIRECT, + }; + + const bad = await form("/oauth/token", { + ...base, + code_verifier: `${VERIFIER.slice(0, -1)}A`, + }); + expect(bad.status).toBe(400); + expect((await bad.json()).error).toBe("invalid_grant"); + + const ok = await form("/oauth/token", { ...base, code_verifier: VERIFIER }); + expect(ok.status).toBe(200); + const tokens = await ok.json(); + + const replay = await form("/oauth/token", { + ...base, + code_verifier: VERIFIER, + }); + expect(replay.status).toBe(400); + expect((await replay.json()).error).toBe("invalid_grant"); + // replay revoked the grant it produced + expect( + (await mcp({ Authorization: `Bearer ${tokens.access_token}` })).status, + ).toBe(401); + + const unsupported = await form("/oauth/token", { + grant_type: "client_credentials", + client_id: clientId, + }); + expect(unsupported.status).toBe(400); + expect((await unsupported.json()).error).toBe("unsupported_grant_type"); + + const missing = await form("/oauth/token", { + grant_type: "authorization_code", + }); + expect(missing.status).toBe(400); + expect((await missing.json()).error).toBe("invalid_request"); + + const unknownClient = await form("/oauth/token", { + ...base, + client_id: "nope", + code_verifier: VERIFIER, + }); + expect(unknownClient.status).toBe(401); + expect((await unknownClient.json()).error).toBe("invalid_client"); + }); + + test("refresh_token rotates the pair and retires the old access token", async () => { + const clientId = await registerClient(); + const first = await mintTokens(clientId); + expect( + (await mcp({ Authorization: `Bearer ${first.access_token}` })).status, + ).toBe(200); + + const res = await form("/oauth/token", { + grant_type: "refresh_token", + client_id: clientId, + refresh_token: first.refresh_token, + }); + expect(res.status).toBe(200); + const second = await res.json(); + expect(second.access_token).not.toBe(first.access_token); + expect(second.refresh_token).not.toBe(first.refresh_token); + expect( + (await mcp({ Authorization: `Bearer ${first.access_token}` })).status, + ).toBe(401); + expect( + (await mcp({ Authorization: `Bearer ${second.access_token}` })).status, + ).toBe(200); + + const unknown = await form("/oauth/token", { + grant_type: "refresh_token", + client_id: clientId, + refresh_token: "nope", + }); + expect(unknown.status).toBe(400); + expect((await unknown.json()).error).toBe("invalid_grant"); + }); +}); + +describe("POST /oauth/revoke", () => { + test("revokes by access token and always answers 200", async () => { + const clientId = await registerClient(); + const tokens = await mintTokens(clientId); + expect( + (await mcp({ Authorization: `Bearer ${tokens.access_token}` })).status, + ).toBe(200); + + const res = await form("/oauth/revoke", { + client_id: clientId, + token: tokens.access_token, + }); + expect(res.status).toBe(200); + expect( + (await mcp({ Authorization: `Bearer ${tokens.access_token}` })).status, + ).toBe(401); + + const again = await form("/oauth/revoke", { + client_id: clientId, + token: "unknown", + }); + expect(again.status).toBe(200); + }); +}); + +describe("bearer tokens on the resource surfaces", () => { + test("/mcp without any credential falls to the public surface", async () => { + // Anonymous requests are served the public documents; OAuth discovery + // therefore starts from the 401 an invalid or empty credential produces. + const res = await mcp(); + expect(res.status).toBe(200); + expect(await toolNames(res)).toEqual([]); + }); + + test("/mcp with an empty Authorization header is a 401 carrying WWW-Authenticate", async () => { + const res = await mcp({ Authorization: "" }); + expect(res.status).toBe(401); + expect(res.headers.get("www-authenticate")).toBe( + `Bearer resource_metadata="${config.issuer}/.well-known/oauth-protected-resource/mcp"`, + ); + const body = await res.json(); + expect(body.code).toBe("UNAUTHORIZED"); + }); + + test("/mcp rejects an invalid bearer token with the same header", async () => { + const res = await mcp({ Authorization: "Bearer os_oat_bogus" }); + expect(res.status).toBe(401); + expect(res.headers.get("www-authenticate")).toContain("resource_metadata="); + }); + + test("/mcp lists tools for a valid bearer and filters by granted scope", async () => { + const clientId = await registerClient(); + const write = await mintTokens(clientId); + const writeRes = await mcp({ + Authorization: `Bearer ${write.access_token}`, + }); + expect(writeRes.status).toBe(200); + const writeTools = await toolNames(writeRes); + expect(writeTools).toContain("list_monitors"); + expect(writeTools).toContain("create_status_report"); + + const readOnly = await mintTokens(await registerClient("Read only"), [ + "read", + ]); + const readRes = await mcp({ + Authorization: `Bearer ${readOnly.access_token}`, + }); + expect(readRes.status).toBe(200); + const readTools = await toolNames(readRes); + expect(readTools).toContain("list_monitors"); + expect(readTools).not.toContain("create_status_report"); + }); + + test("x-openstatus-key still wins when both headers are present", async () => { + const res = await mcp({ + "x-openstatus-key": String(workspaceId), + Authorization: "Bearer os_oat_bogus", + }); + expect(res.status).toBe(200); + }); + + test("/rpc accepts a bearer token and rejects a missing credential", async () => { + const clientId = await registerClient(); + const tokens = await mintTokens(clientId); + const ok = await rpc({ Authorization: `Bearer ${tokens.access_token}` }); + expect(ok.status).toBe(200); + const missing = await rpc(); + expect(missing.status).toBe(401); + expect(await missing.text()).toContain("credentials"); + }); +}); + +describe("URL client ids (CIMD)", () => { + const CIMD_ID = "https://partner.example/.well-known/oauth-client"; + const CIMD_REDIRECT = "https://partner.example/oauth/callback"; + + function cimdApp(document: Record | null) { + const { createOAuthRoutes } = routes; + const local = new Hono(); + local.route( + "/", + createOAuthRoutes({ + ...config, + fetchClientMetadata: async (clientId) => { + if (!document) { + throw new OAuthError( + "invalid_client", + "Client metadata document responded with HTTP 404", + ); + } + return parseClientMetadataDocument(clientId, document); + }, + }), + ); + return local; + } + + async function cimdAuthorize( + local: Hono, + extra: Record = {}, + ) { + const params = new URLSearchParams({ + response_type: "code", + client_id: CIMD_ID, + redirect_uri: CIMD_REDIRECT, + scope: "read", + state: "cimd", + code_challenge: await pkceChallenge(VERIFIER), + code_challenge_method: "S256", + ...extra, + }); + return local.request(`/oauth/authorize?${params.toString()}`, { + redirect: "manual", + }); + } + + afterAll(async () => { + const grants = await db + .select({ id: oauthGrant.id }) + .from(oauthGrant) + .where(eq(oauthGrant.clientId, CIMD_ID)) + .all(); + await clearAuditLogFor({ + entityType: "oauth_grant", + entityIds: grants.map((g) => g.id), + }); + await db.delete(oauthGrant).where(eq(oauthGrant.clientId, CIMD_ID)); + await db + .delete(oauthAuthorizationCode) + .where(eq(oauthAuthorizationCode.clientId, CIMD_ID)); + await db.delete(oauthSession).where(eq(oauthSession.clientId, CIMD_ID)); + await db.delete(oauthClient).where(eq(oauthClient.clientId, CIMD_ID)); + }); + + test("metadata advertises client_id_metadata_document_supported", async () => { + const res = await app.request("/.well-known/oauth-authorization-server"); + expect((await res.json()).client_id_metadata_document_supported).toBe(true); + }); + + test("authorize with a URL client id resolves the document and completes the flow on the shared token endpoint", async () => { + const local = cimdApp({ + client_id: CIMD_ID, + client_name: "Partner", + redirect_uris: [CIMD_REDIRECT], + }); + const res = await cimdAuthorize(local); + expect(res.status).toBe(302); + const sessionId = + new URL(res.headers.get("location") ?? "").searchParams.get("session") ?? + ""; + expect(sessionId).not.toBe(""); + + const { redirectUrl } = await decideSession({ + input: { id: sessionId, approved: true, userId, workspaceId }, + }); + const code = new URL(redirectUrl).searchParams.get("code") ?? ""; + const token = await form("/oauth/token", { + grant_type: "authorization_code", + client_id: CIMD_ID, + code, + code_verifier: VERIFIER, + redirect_uri: CIMD_REDIRECT, + }); + expect(token.status).toBe(200); + const tokens = await token.json(); + expect( + (await mcp({ Authorization: `Bearer ${tokens.access_token}` })).status, + ).toBe(200); + }); + + test("an unreachable or mismatched document is a 400 invalid_client", async () => { + const missing = await cimdAuthorize(cimdApp(null)); + expect(missing.status).toBe(400); + expect((await missing.json()).error).toBe("invalid_client"); + + const mismatched = await cimdAuthorize( + cimdApp({ + client_id: "https://other.example/c", + redirect_uris: [CIMD_REDIRECT], + }), + ); + expect(mismatched.status).toBe(400); + expect((await mismatched.json()).error).toBe("invalid_client"); + }); + + test("a URL client id on a private host is refused before any fetch", async () => { + let fetched = false; + const local = new Hono(); + local.route( + "/", + routes.createOAuthRoutes({ + ...config, + fetchClientMetadata: async () => { + fetched = true; + throw new Error("should not run"); + }, + }), + ); + const params = new URLSearchParams({ + response_type: "code", + client_id: "https://169.254.169.254/latest/meta-data", + redirect_uri: CIMD_REDIRECT, + code_challenge: await pkceChallenge(VERIFIER), + code_challenge_method: "S256", + }); + const res = await local.request(`/oauth/authorize?${params.toString()}`, { + redirect: "manual", + }); + expect(res.status).toBe(400); + expect(fetched).toBe(false); + }); +}); diff --git a/apps/server/src/routes/rpc/interceptors/auth.ts b/apps/server/src/routes/rpc/interceptors/auth.ts index 71f647e51..325b9b536 100644 --- a/apps/server/src/routes/rpc/interceptors/auth.ts +++ b/apps/server/src/routes/rpc/interceptors/auth.ts @@ -2,13 +2,17 @@ import { Code, ConnectError, type Interceptor } from "@connectrpc/connect"; import { nanoid } from "nanoid"; import { lookupWorkspace, validateKey } from "../../../libs/middlewares/auth"; +import { + MISSING_CREDENTIALS_MESSAGE, + extractCredential, +} from "../../../libs/middlewares/credentials"; import { RPC_CONTEXT_KEY, type RpcContext } from "./context"; export { RPC_CONTEXT_KEY, type RpcContext, getRpcContext } from "./context"; /** * Authentication interceptor for ConnectRPC. - * Validates the x-openstatus-key header and sets workspace context. + * Validates `x-openstatus-key` or a bearer token and sets workspace context. * Skips authentication for HealthService endpoints. */ export function authInterceptor(): Interceptor { @@ -18,16 +22,13 @@ export function authInterceptor(): Interceptor { return next(req); } - const apiKey = req.header.get("x-openstatus-key"); + const credential = extractCredential(req.header); - if (!apiKey) { - throw new ConnectError( - "Missing 'x-openstatus-key' header", - Code.Unauthenticated, - ); + if (!credential) { + throw new ConnectError(MISSING_CREDENTIALS_MESSAGE, Code.Unauthenticated); } - const { error, result } = await validateKey(apiKey); + const { error, result } = await validateKey(credential.token); if (error) { throw new ConnectError(error.message, Code.Unauthenticated); diff --git a/apps/web/.env.example b/apps/web/.env.example index 39c4b5afc..b72b4c161 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -59,11 +59,6 @@ VERCEL_URL=https://app.openstatus.dev PLAYGROUND_UNKEY_API_KEY= -# RUM server with separate clickhouse for self-host -CLICKHOUSE_URL= -CLICKHOUSE_USERNAME= -CLICKHOUSE_PASSWORD= - AUTH_SECRET=secret AUTH_DRIZZLE_URL=file://./../../openstatus-dev.db AUTH_GITHUB_ID= diff --git a/apps/web/src/env.ts b/apps/web/src/env.ts index 9d6c11fc2..a01428b25 100644 --- a/apps/web/src/env.ts +++ b/apps/web/src/env.ts @@ -20,9 +20,6 @@ export const env = createEnv({ GCP_CLIENT_EMAIL: z.string(), GCP_PRIVATE_KEY: z.string(), CRON_SECRET: z.string(), - CLICKHOUSE_URL: z.string(), - CLICKHOUSE_USERNAME: z.string(), - CLICKHOUSE_PASSWORD: z.string(), PAGERDUTY_APP_ID: z.string().optional(), SLACK_FEEDBACK_WEBHOOK_URL: z.string().optional(), }, @@ -54,9 +51,6 @@ export const env = createEnv({ GCP_CLIENT_EMAIL: process.env.GCP_CLIENT_EMAIL, GCP_PRIVATE_KEY: process.env.GCP_PRIVATE_KEY, CRON_SECRET: process.env.CRON_SECRET, - CLICKHOUSE_URL: process.env.CLICKHOUSE_URL, - CLICKHOUSE_USERNAME: process.env.CLICKHOUSE_USERNAME, - CLICKHOUSE_PASSWORD: process.env.CLICKHOUSE_PASSWORD, PAGERDUTY_APP_ID: process.env.PAGERDUTY_APP_ID, SLACK_FEEDBACK_WEBHOOK_URL: process.env.SLACK_FEEDBACK_WEBHOOK_URL, }, diff --git a/apps/workflows/src/cron/index.ts b/apps/workflows/src/cron/index.ts index 0768ac6e3..c00dbb5c2 100644 --- a/apps/workflows/src/cron/index.ts +++ b/apps/workflows/src/cron/index.ts @@ -16,6 +16,7 @@ import { StepPaused, workflowStepSchema, } from "./monitor"; +import { handleOAuthPruneCron } from "./oauth-prune"; import { handleOutboxDrainCron, handleOutboxRetentionCron, @@ -101,6 +102,10 @@ app.get("/external-incidents-prune", async (c) => { return handleExternalIncidentsPruneCron(c); }); +app.get("/oauth-prune", async (c) => { + return handleOAuthPruneCron(c); +}); + app.get("/uptime-freeze", async (c) => { return handleUptimeFreezeCron(c); }); diff --git a/apps/workflows/src/cron/oauth-prune.ts b/apps/workflows/src/cron/oauth-prune.ts new file mode 100644 index 000000000..fb763e471 --- /dev/null +++ b/apps/workflows/src/cron/oauth-prune.ts @@ -0,0 +1,51 @@ +import { getLogger } from "@logtape/logtape"; +import { db } from "@openstatus/db"; +import { + type PruneExpiredResult, + pruneExpired, +} from "@openstatus/services/oauth"; +import { Effect } from "effect"; +import type { Context } from "hono"; + +import { reportBackgroundError, runSentryCron } from "../lib/sentry"; + +const logger = getLogger(["workflow", "oauth-prune"]); + +export async function runOAuthPruneTick(): Promise { + return pruneExpired({ ctx: { db } }); +} + +export async function handleOAuthPruneCron(c: Context) { + const { cronCompleted, cronFailed } = runSentryCron("oauth-prune"); + + void Effect.runPromise( + Effect.tryPromise({ + try: () => runOAuthPruneTick(), + catch: (e) => + new Error( + `oauth-prune tick failed: ${e instanceof Error ? e.message : String(e)}`, + ), + }).pipe( + Effect.tap((res) => + Effect.sync(() => { + logger.info( + "oauth-prune tick complete: sessions={sessions} codes={codes} grants={grants} clients={clients}", + res, + ); + void cronCompleted(); + }), + ), + Effect.catch((e) => + Effect.sync(() => { + logger.error("oauth-prune tick errored: {message}", { + message: e.message, + }); + void reportBackgroundError(e.message); + void cronFailed(); + }), + ), + ), + ); + + return c.json({ success: true }, 200); +} diff --git a/apps/workflows/src/cron/scheduler.ts b/apps/workflows/src/cron/scheduler.ts index fe5414149..41141ee46 100644 --- a/apps/workflows/src/cron/scheduler.ts +++ b/apps/workflows/src/cron/scheduler.ts @@ -2,6 +2,7 @@ import { getLogger } from "@logtape/logtape"; import * as Sentry from "@sentry/deno"; import { Effect, Fiber, Schedule } from "effect"; +import { runOAuthPruneTick } from "./oauth-prune"; import { handleOutboxDrainCron, handleOutboxRetentionCron } from "./outbox"; import { handleStatusDriftCron } from "./status-drift"; @@ -35,6 +36,11 @@ export const SCHEDULED_TASKS: ScheduledTask[] = [ expression: "17 3 * * *", run: handleOutboxRetentionCron, }, + { + name: "oauth-prune", + expression: "41 3 * * *", + run: runOAuthPruneTick, + }, ]; type RunningTask = Fiber.Fiber; diff --git a/deno.lock b/deno.lock index 457658cf3..58ec016ae 100644 --- a/deno.lock +++ b/deno.lock @@ -8,7 +8,8 @@ "jsr:@std/internal@^1.0.12": "1.0.14", "jsr:@std/internal@^1.0.13": "1.0.14", "jsr:@std/path@^1.1.4": "1.1.4", - "jsr:@std/testing@*": "1.0.19" + "jsr:@std/testing@*": "1.0.19", + "jsr:@std/yaml@^1.0.9": "1.2.0" }, "jsr": { "@std/assert@1.0.19": { @@ -47,6 +48,9 @@ "jsr:@std/async", "jsr:@std/data-structures" ] + }, + "@std/yaml@1.2.0": { + "integrity": "20beb41e4983ba3437dbefac62b14061ab058e8a187596f19d28ff9035f6e6cf" } }, "workspace": { diff --git a/packages/api/src/auth/resolve-active-workspace.ts b/packages/api/src/auth/resolve-active-workspace.ts index 46cdedc7d..45855b8b4 100644 --- a/packages/api/src/auth/resolve-active-workspace.ts +++ b/packages/api/src/auth/resolve-active-workspace.ts @@ -1,4 +1,4 @@ -import { db, eq, schema } from "@openstatus/db"; +import { and, db, eq, isNull, schema } from "@openstatus/db"; import type { User, Workspace } from "@openstatus/db/src/schema"; /** @@ -24,6 +24,27 @@ export type ResolveActiveWorkspaceResult = | { ok: true; value: ActiveWorkspace } | { ok: false; error: ResolveActiveWorkspaceFailure }; +/** + * User plus every workspace they belong to, without electing an active one. + * Soft-deleted accounts resolve to nothing even if a session cookie survives. + */ +export async function resolveUserWorkspaces(args: { + userId: number; +}): Promise<{ user: User; workspaces: Workspace[] } | null> { + const row = await db.query.user.findFirst({ + where: and(eq(schema.user.id, args.userId), isNull(schema.user.deletedAt)), + with: { usersToWorkspaces: { with: { workspace: true } } }, + }); + if (!row) return null; + const { usersToWorkspaces, ...userProps } = row; + return { + user: schema.selectUserSchema.parse(userProps), + workspaces: (usersToWorkspaces ?? []).map((m) => + schema.selectWorkspaceSchema.parse(m.workspace), + ), + }; +} + /** * Canonical workspace-from-cookie resolver — looks up the * `workspace-slug` cookie and falls back to the user's first workspace @@ -33,35 +54,18 @@ export async function resolveActiveWorkspace(args: { userId: number; workspaceSlug?: string; }): Promise { - const userAndWorkspace = await db.query.user.findFirst({ - where: eq(schema.user.id, args.userId), - with: { - usersToWorkspaces: { - with: { workspace: true }, - }, - }, - }); - - if (!userAndWorkspace) { + const resolved = await resolveUserWorkspaces({ userId: args.userId }); + if (!resolved) { return { ok: false, error: { kind: "user_not_found" } }; } + const { user, workspaces } = resolved; - const { usersToWorkspaces, ...userProps } = userAndWorkspace; - - const activeWorkspace = - usersToWorkspaces?.find(({ workspace }) => { - if (args.workspaceSlug) return workspace.slug === args.workspaceSlug; - return true; - })?.workspace ?? usersToWorkspaces?.[0]?.workspace; - - if (!activeWorkspace) { + const workspace = + workspaces.find((w) => + args.workspaceSlug ? w.slug === args.workspaceSlug : true, + ) ?? workspaces[0]; + if (!workspace) { return { ok: false, error: { kind: "workspace_not_found" } }; } - - const user = schema.selectUserSchema.parse(userProps); - const workspace = schema.selectWorkspaceSchema.parse(activeWorkspace); - const workspaces = (usersToWorkspaces ?? []).map((row) => - schema.selectWorkspaceSchema.parse(row.workspace), - ); return { ok: true, value: { user, workspace, workspaces } }; } diff --git a/packages/api/src/edge.ts b/packages/api/src/edge.ts index d64a6a155..00ecd8a0b 100644 --- a/packages/api/src/edge.ts +++ b/packages/api/src/edge.ts @@ -12,6 +12,7 @@ import { memberRouter } from "./router/member"; import { monitorRouter } from "./router/monitor"; import { monitorTagRouter } from "./router/monitorTag"; import { notificationRouter } from "./router/notification"; +import { oauthRouter } from "./router/oauth"; import { pageRouter } from "./router/page"; import { pageComponentRouter } from "./router/pageComponent"; import { pageSubscriberRouter } from "./router/pageSubscriber"; @@ -48,4 +49,5 @@ export const edgeRouter = createTRPCRouter({ import: importRouter, auditLog: auditLogRouter, chatSession: chatSessionRouter, + oauth: oauthRouter, }); diff --git a/packages/api/src/router/oauth.test.ts b/packages/api/src/router/oauth.test.ts new file mode 100644 index 000000000..cc88a3ef0 --- /dev/null +++ b/packages/api/src/router/oauth.test.ts @@ -0,0 +1,199 @@ +import { db, eq } from "@openstatus/db"; +import { oauthClient, oauthGrant } from "@openstatus/db/src/schema"; +import { + createTestWorkspace, + createUser, +} from "@openstatus/db/src/test/factories"; +import { + createSession, + exchangeCode, + pkceChallenge, + registerClient, + verifyAccessToken, +} from "@openstatus/services/oauth"; +import { clearAuditLogFor } from "@openstatus/services/test/helpers"; +import { expect } from "@std/expect"; +import { afterAll, beforeAll, describe, test } from "@std/testing/bdd"; +import { NextRequest } from "next/server.js"; + +import { edgeRouter } from "../edge"; +import { createInnerTRPCContext, createTRPCContext } from "../trpc"; + +const VERIFIER = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; +const REDIRECT = "http://localhost:43112/cb"; + +let workspaceId: number; +let workspaceSlug: string; +let ownerId: number; +let clientId: string; + +beforeAll(async () => { + const fixture = await createTestWorkspace({ plan: "team" }); + workspaceId = fixture.workspace.id; + workspaceSlug = fixture.workspace.slug; + ownerId = fixture.user.id; + clientId = ( + await registerClient({ + input: { client_name: "Claude", redirect_uris: [REDIRECT] }, + }) + ).client_id; +}); + +afterAll(async () => { + const grants = await db + .select({ id: oauthGrant.id }) + .from(oauthGrant) + .where(eq(oauthGrant.clientId, clientId)) + .all(); + await clearAuditLogFor({ + entityType: "oauth_grant", + entityIds: grants.map((g) => g.id), + }); + await db.delete(oauthClient).where(eq(oauthClient.clientId, clientId)); +}); + +async function callerFor(userId: number) { + const ctx = await createTRPCContext({ + req: new NextRequest("http://localhost:3000/api/trpc/edge", { + headers: { cookie: `workspace-slug=${workspaceSlug}` }, + }), + auth: async () => ({ user: { id: String(userId) } }), + }); + return edgeRouter.createCaller(ctx); +} + +async function newSession(scope = "read write", now?: Date) { + const { id } = await createSession({ + now, + input: { + response_type: "code", + client_id: clientId, + redirect_uri: REDIRECT, + scope, + state: "st", + code_challenge: await pkceChallenge(VERIFIER), + code_challenge_method: "S256", + }, + }); + return id; +} + +describe("oauth router", () => { + test("getSession and decide require a session", async () => { + const caller = edgeRouter.createCaller( + createInnerTRPCContext({ session: null }), + ); + await expect(caller.oauth.getSession({ id: "x" })).rejects.toThrow( + "UNAUTHORIZED", + ); + await expect( + caller.oauth.decide({ id: "x", approved: false }), + ).rejects.toThrow("UNAUTHORIZED"); + }); + + test("getSession returns the pending request and the user's workspaces", async () => { + const caller = await callerFor(ownerId); + const id = await newSession("read"); + const result = await caller.oauth.getSession({ id }); + expect(result.session.clientName).toBe("Claude"); + expect(result.session.scope).toEqual(["read"]); + expect(result.workspaces.map((w) => w.id)).toContain(workspaceId); + }); + + test("getSession works for a user with no workspace", async () => { + const orphan = await createUser(); + const caller = await callerFor(orphan.id); + const id = await newSession(); + const result = await caller.oauth.getSession({ id }); + expect(result.workspaces).toEqual([]); + }); + + test("getSession maps expired sessions to PRECONDITION_FAILED", async () => { + const caller = await callerFor(ownerId); + const id = await newSession("read", new Date(Date.now() - 60 * 60 * 1000)); + await expect(caller.oauth.getSession({ id })).rejects.toMatchObject({ + code: "PRECONDITION_FAILED", + }); + }); + + test("a soft-deleted account cannot use the consent procedures", async () => { + const ghost = await createUser({ deletedAt: new Date() }); + const caller = await callerFor(ghost.id); + const id = await newSession(); + await expect(caller.oauth.getSession({ id })).rejects.toMatchObject({ + code: "UNAUTHORIZED", + }); + }); + + test("getSession maps decided and unknown sessions to tRPC errors", async () => { + const caller = await callerFor(ownerId); + await expect( + caller.oauth.getSession({ id: "missing" }), + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + const id = await newSession(); + await caller.oauth.decide({ id, approved: false }); + await expect(caller.oauth.getSession({ id })).rejects.toMatchObject({ + code: "PRECONDITION_FAILED", + }); + }); + + test("decide approve mints a code for the chosen workspace and scope", async () => { + const caller = await callerFor(ownerId); + const id = await newSession(); + const { redirectUrl } = await caller.oauth.decide({ + id, + approved: true, + workspaceId, + scope: "read", + }); + const url = new URL(redirectUrl); + expect(url.searchParams.get("state")).toBe("st"); + const code = url.searchParams.get("code") ?? ""; + const tokens = await exchangeCode({ + input: { clientId, code, codeVerifier: VERIFIER, redirectUri: REDIRECT }, + }); + expect(tokens.scope).toBe("read"); + const verified = await verifyAccessToken(tokens.access_token); + expect(verified?.workspaceId).toBe(workspaceId); + expect(verified?.userId).toBe(ownerId); + }); + + test("decide rejects a workspace the caller does not belong to", async () => { + const orphan = await createUser(); + const caller = await callerFor(orphan.id); + const id = await newSession(); + await expect( + caller.oauth.decide({ id, approved: true, workspaceId }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); + + test("listGrants and revokeGrant manage the workspace's connected apps", async () => { + const caller = await callerFor(ownerId); + const id = await newSession(); + const { redirectUrl } = await caller.oauth.decide({ + id, + approved: true, + workspaceId, + }); + const code = new URL(redirectUrl).searchParams.get("code") ?? ""; + const tokens = await exchangeCode({ + input: { clientId, code, codeVerifier: VERIFIER, redirectUri: REDIRECT }, + }); + const grantId = + (await verifyAccessToken(tokens.access_token))?.grantId ?? -1; + + const listed = await caller.oauth.listGrants(); + const found = listed.find((g) => g.id === grantId); + expect(found?.clientName).toBe("Claude"); + expect(found?.user?.id).toBe(ownerId); + + await caller.oauth.revokeGrant({ grantId }); + expect( + (await caller.oauth.listGrants()).find((g) => g.id === grantId), + ).toBeUndefined(); + expect(await verifyAccessToken(tokens.access_token)).toBeNull(); + await expect(caller.oauth.revokeGrant({ grantId })).rejects.toMatchObject({ + code: "NOT_FOUND", + }); + }); +}); diff --git a/packages/api/src/router/oauth.ts b/packages/api/src/router/oauth.ts new file mode 100644 index 000000000..9fdca4b3b --- /dev/null +++ b/packages/api/src/router/oauth.ts @@ -0,0 +1,85 @@ +import { apiKeySettableScopes } from "@openstatus/db/src/schema/api-keys/constants"; +import { + GetSessionInput, + RevokeGrantInput, + decideSession, + getSession, + isUrlClientId, + listGrants, + revokeGrant, +} from "@openstatus/services/oauth"; +import { z } from "zod"; + +import { toServiceCtx, toTRPCError } from "../service-adapter"; +import { createTRPCRouter, protectedProcedure, userProcedure } from "../trpc"; + +// URL client ids prove domain ownership; surface that host so a user can spot +// a lookalike name. DCR clients are self-described and carry no origin. +function clientOrigin(clientId: string): string | null { + return isUrlClientId(clientId) ? new URL(clientId).hostname : null; +} + +const DecideInput = z.object({ + id: z.string().min(1), + approved: z.boolean(), + workspaceId: z.number().int().optional(), + scope: z.enum(apiKeySettableScopes).optional(), +}); + +/** + * Consent runs on `userProcedure`: a user removed from every workspace must + * still see the page (with a create-workspace prompt) instead of a 401. + */ +export const oauthRouter = createTRPCRouter({ + getSession: userProcedure + .input(GetSessionInput) + .query(async ({ ctx, input }) => { + try { + const session = await getSession({ input }); + return { + session: { ...session, clientOrigin: clientOrigin(session.clientId) }, + workspaces: (ctx.workspaces ?? []).map((w) => ({ + id: w.id, + name: w.name, + slug: w.slug, + })), + }; + } catch (err) { + toTRPCError(err); + } + }), + + decide: userProcedure.input(DecideInput).mutation(async ({ ctx, input }) => { + try { + return await decideSession({ + input: { + id: input.id, + approved: input.approved, + userId: ctx.user.id, + workspaceId: input.workspaceId, + scope: input.scope ? [input.scope] : undefined, + }, + }); + } catch (err) { + toTRPCError(err); + } + }), + + listGrants: protectedProcedure.query(async ({ ctx }) => { + try { + return await listGrants({ ctx: toServiceCtx(ctx) }); + } catch (err) { + toTRPCError(err); + } + }), + + revokeGrant: protectedProcedure + .input(RevokeGrantInput) + .mutation(async ({ ctx, input }) => { + try { + await revokeGrant({ ctx: toServiceCtx(ctx), input }); + } catch (err) { + toTRPCError(err); + } + }), +}); diff --git a/packages/api/src/trpc.ts b/packages/api/src/trpc.ts index a10d20f70..c36e4e81a 100644 --- a/packages/api/src/trpc.ts +++ b/packages/api/src/trpc.ts @@ -14,6 +14,7 @@ import { ZodError, treeifyError } from "zod"; import { type ResolveActiveWorkspaceResult, resolveActiveWorkspace, + resolveUserWorkspaces, } from "./auth/resolve-active-workspace"; // Generic session type that works with both User and Viewer @@ -298,6 +299,31 @@ export const formdataMiddleware = t.middleware(async (opts) => { }); }); +/** + * Signed-in user without an active workspace. `ctx.workspaces` may be empty; + * `ctx.workspace` stays null. Only for surfaces that must render for a user + * who belongs to no workspace — everything else uses `protectedProcedure`. + */ +const enforceUserIsSignedIn = t.middleware(async (opts) => { + const { ctx } = opts; + if (!ctx.session?.user?.id) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + const resolved = await resolveUserWorkspaces({ + userId: Number(ctx.session.user.id), + }); + if (!resolved) { + throw new TRPCError({ code: "UNAUTHORIZED", message: "User Not Found" }); + } + return opts.next({ + ctx: { ...ctx, user: resolved.user, workspaces: resolved.workspaces }, + }); +}); + +export const userProcedure = t.procedure + .use(timingMiddleware) + .use(enforceUserIsSignedIn); + /** * Protected (authed) procedure * diff --git a/packages/db/drizzle/0086_robust_quicksilver.sql b/packages/db/drizzle/0086_robust_quicksilver.sql new file mode 100644 index 000000000..f60b4450c --- /dev/null +++ b/packages/db/drizzle/0086_robust_quicksilver.sql @@ -0,0 +1,74 @@ +CREATE TABLE `oauth_client` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `client_id` text NOT NULL, + `name` text DEFAULT 'MCP Client' NOT NULL, + `redirect_uris` text NOT NULL, + `revoked_at` integer, + `created_at` integer DEFAULT (strftime('%s', 'now')) +); +--> statement-breakpoint +CREATE UNIQUE INDEX `oauth_client_client_id_unique` ON `oauth_client` (`client_id`);--> statement-breakpoint +CREATE TABLE `oauth_session` ( + `id` text PRIMARY KEY NOT NULL, + `client_id` text NOT NULL, + `redirect_uri` text NOT NULL, + `scope` text NOT NULL, + `state` text, + `resource` text, + `code_challenge` text NOT NULL, + `code_challenge_method` text DEFAULT 'S256' NOT NULL, + `decided_at` integer, + `expires_at` integer NOT NULL, + FOREIGN KEY (`client_id`) REFERENCES `oauth_client`(`client_id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `oauth_session_client_id_idx` ON `oauth_session` (`client_id`);--> statement-breakpoint +CREATE INDEX `oauth_session_expires_at_idx` ON `oauth_session` (`expires_at`);--> statement-breakpoint +CREATE TABLE `oauth_authorization_code` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `hash` text NOT NULL, + `client_id` text NOT NULL, + `user_id` integer NOT NULL, + `workspace_id` integer NOT NULL, + `scope` text NOT NULL, + `redirect_uri` text NOT NULL, + `code_challenge` text NOT NULL, + `expires_at` integer NOT NULL, + `consumed_at` integer, + `grant_id` integer, + FOREIGN KEY (`client_id`) REFERENCES `oauth_client`(`client_id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`workspace_id`) REFERENCES `workspace`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`grant_id`) REFERENCES `oauth_grant`(`id`) ON UPDATE no action ON DELETE set null +); +--> statement-breakpoint +CREATE UNIQUE INDEX `oauth_authorization_code_hash_unique` ON `oauth_authorization_code` (`hash`);--> statement-breakpoint +CREATE INDEX `oauth_authorization_code_expires_at_idx` ON `oauth_authorization_code` (`expires_at`);--> statement-breakpoint +CREATE INDEX `oauth_authorization_code_client_id_idx` ON `oauth_authorization_code` (`client_id`);--> statement-breakpoint +CREATE TABLE `oauth_grant` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `client_id` text NOT NULL, + `user_id` integer NOT NULL, + `workspace_id` integer NOT NULL, + `scope` text NOT NULL, + `access_token_hash` text NOT NULL, + `access_token_expires_at` integer NOT NULL, + `refresh_token_hash` text NOT NULL, + `refresh_token_expires_at` integer NOT NULL, + `previous_refresh_token_hash` text, + `rotated_at` integer, + `last_used_at` integer, + `revoked_at` integer, + `created_at` integer DEFAULT (strftime('%s', 'now')), + FOREIGN KEY (`client_id`) REFERENCES `oauth_client`(`client_id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`workspace_id`) REFERENCES `workspace`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `oauth_grant_access_token_hash_unique` ON `oauth_grant` (`access_token_hash`);--> statement-breakpoint +CREATE UNIQUE INDEX `oauth_grant_refresh_token_hash_unique` ON `oauth_grant` (`refresh_token_hash`);--> statement-breakpoint +CREATE INDEX `oauth_grant_workspace_id_idx` ON `oauth_grant` (`workspace_id`);--> statement-breakpoint +CREATE INDEX `oauth_grant_client_id_user_id_idx` ON `oauth_grant` (`client_id`,`user_id`);--> statement-breakpoint +CREATE INDEX `oauth_grant_user_id_idx` ON `oauth_grant` (`user_id`);--> statement-breakpoint +CREATE INDEX `oauth_grant_previous_refresh_token_hash_idx` ON `oauth_grant` (`previous_refresh_token_hash`);--> statement-breakpoint +CREATE INDEX `oauth_grant_refresh_token_expires_at_idx` ON `oauth_grant` (`refresh_token_expires_at`); \ No newline at end of file diff --git a/packages/db/drizzle/meta/0086_snapshot.json b/packages/db/drizzle/meta/0086_snapshot.json new file mode 100644 index 000000000..892e8f60c --- /dev/null +++ b/packages/db/drizzle/meta/0086_snapshot.json @@ -0,0 +1,6053 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "2333506c-1926-4dd8-9855-243404e86332", + "prevId": "10787b50-ac75-4913-ba21-f3d2da7acc6e", + "tables": { + "workspace": { + "name": "workspace", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripe_id": { + "name": "stripe_id", + "type": "text(256)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subscription_id": { + "name": "subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ends_at": { + "name": "ends_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "paid_until": { + "name": "paid_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "limits": { + "name": "limits", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "workos_organization_id": { + "name": "workos_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sso_enabled": { + "name": "sso_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "dsn": { + "name": "dsn", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "workspace_slug_unique": { + "name": "workspace_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "workspace_stripe_id_unique": { + "name": "workspace_stripe_id_unique", + "columns": [ + "stripe_id" + ], + "isUnique": true + }, + "workspace_workos_organization_id_unique": { + "name": "workspace_workos_organization_id_unique", + "columns": [ + "workos_organization_id" + ], + "isUnique": true + }, + "workspace_id_dsn_unique": { + "name": "workspace_id_dsn_unique", + "columns": [ + "id", + "dsn" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspace_sso_domain": { + "name": "workspace_sso_domain", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "verified_at": { + "name": "verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "workspace_sso_domain_domain_unique": { + "name": "workspace_sso_domain_domain_unique", + "columns": [ + "domain" + ], + "isUnique": true + }, + "workspace_sso_domain_workspace_id_idx": { + "name": "workspace_sso_domain_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "workspace_sso_domain_workspace_id_workspace_id_fk": { + "name": "workspace_sso_domain_workspace_id_workspace_id_fk", + "tableFrom": "workspace_sso_domain", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "account": { + "name": "account", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "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": { + "account_provider_provider_account_id_pk": { + "columns": [ + "provider", + "provider_account_id" + ], + "name": "account_provider_provider_account_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "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": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text(256)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "photo_url": { + "name": "photo_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "emailVerified": { + "name": "emailVerified", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "user_tenant_id_unique": { + "name": "user_tenant_id_unique", + "columns": [ + "tenant_id" + ], + "isUnique": true + }, + "user_email_idx": { + "name": "user_email_idx", + "columns": [ + "email" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users_to_workspaces": { + "name": "users_to_workspaces", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "users_to_workspaces_workspace_id_idx": { + "name": "users_to_workspaces_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "users_to_workspaces_user_id_user_id_fk": { + "name": "users_to_workspaces_user_id_user_id_fk", + "tableFrom": "users_to_workspaces", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "users_to_workspaces_workspace_id_workspace_id_fk": { + "name": "users_to_workspaces_workspace_id_workspace_id_fk", + "tableFrom": "users_to_workspaces", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "users_to_workspaces_user_id_workspace_id_pk": { + "columns": [ + "user_id", + "workspace_id" + ], + "name": "users_to_workspaces_user_id_workspace_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification_token": { + "name": "verification_token", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verification_token_identifier_token_pk": { + "columns": [ + "identifier", + "token" + ], + "name": "verification_token_identifier_token_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "status_report": { + "name": "status_report", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "page_id": { + "name": "page_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "status_report_workspace_created_idx": { + "name": "status_report_workspace_created_idx", + "columns": [ + "workspace_id", + "created_at" + ], + "isUnique": false + }, + "status_report_page_id_idx": { + "name": "status_report_page_id_idx", + "columns": [ + "page_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "status_report_workspace_id_workspace_id_fk": { + "name": "status_report_workspace_id_workspace_id_fk", + "tableFrom": "status_report", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "status_report_page_id_page_id_fk": { + "name": "status_report_page_id_page_id_fk", + "tableFrom": "status_report", + "tableTo": "page", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "status_report_update": { + "name": "status_report_update", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status_report_id": { + "name": "status_report_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "status_report_update_status_report_id_idx": { + "name": "status_report_update_status_report_id_idx", + "columns": [ + "status_report_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "status_report_update_status_report_id_status_report_id_fk": { + "name": "status_report_update_status_report_id_status_report_id_fk", + "tableFrom": "status_report_update", + "tableTo": "status_report", + "columnsFrom": [ + "status_report_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "integration": { + "name": "integration", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "credential": { + "name": "credential", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "integration_workspace_id_idx": { + "name": "integration_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "integration_workspace_id_workspace_id_fk": { + "name": "integration_workspace_id_workspace_id_fk", + "tableFrom": "integration", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "page": { + "name": "page", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text(256)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "slug": { + "name": "slug", + "type": "text(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "custom_domain": { + "name": "custom_domain", + "type": "text(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "published": { + "name": "published", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "force_theme": { + "name": "force_theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'system'" + }, + "custom_theme": { + "name": "custom_theme", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text(256)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password_protected": { + "name": "password_protected", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "access_type": { + "name": "access_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'public'" + }, + "auth_email_domains": { + "name": "auth_email_domains", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allowed_ip_ranges": { + "name": "allowed_ip_ranges", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "homepage_url": { + "name": "homepage_url", + "type": "text(256)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "contact_url": { + "name": "contact_url", + "type": "text(256)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_locale": { + "name": "default_locale", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'en'" + }, + "locales": { + "name": "locales", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "legacy_page": { + "name": "legacy_page", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "configuration": { + "name": "configuration", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allow_index": { + "name": "allow_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "show_monitor_values": { + "name": "show_monitor_values", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "page_slug_unique": { + "name": "page_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "page_lower_slug_idx": { + "name": "page_lower_slug_idx", + "columns": [ + "LOWER(\"slug\")" + ], + "isUnique": false + }, + "page_lower_custom_domain_idx": { + "name": "page_lower_custom_domain_idx", + "columns": [ + "LOWER(\"custom_domain\")" + ], + "isUnique": false + }, + "page_workspace_id_idx": { + "name": "page_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "page_workspace_id_workspace_id_fk": { + "name": "page_workspace_id_workspace_id_fk", + "tableFrom": "page", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "monitor": { + "name": "monitor", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "job_type": { + "name": "job_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'http'" + }, + "periodicity": { + "name": "periodicity", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'other'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "active": { + "name": "active", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "regions": { + "name": "regions", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "url": { + "name": "url", + "type": "text(2048)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "external_name": { + "name": "external_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "headers": { + "name": "headers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'GET'" + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 45000 + }, + "degraded_after": { + "name": "degraded_after", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assertions": { + "name": "assertions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "otel_endpoint": { + "name": "otel_endpoint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "otel_headers": { + "name": "otel_headers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "public": { + "name": "public", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "retry": { + "name": "retry", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 3 + }, + "follow_redirects": { + "name": "follow_redirects", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "grpc_service": { + "name": "grpc_service", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "grpc_tls": { + "name": "grpc_tls", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'tls'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "monitor_workspace_id_active_idx": { + "name": "monitor_workspace_id_active_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false, + "where": "\"monitor\".\"deleted_at\" IS NULL" + } + }, + "foreignKeys": { + "monitor_workspace_id_workspace_id_fk": { + "name": "monitor_workspace_id_workspace_id_fk", + "tableFrom": "monitor", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "page_subscriber": { + "name": "page_subscriber", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "page_id": { + "name": "page_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "channel_type": { + "name": "channel_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'email'" + }, + "webhook_url": { + "name": "webhook_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "channel_config": { + "name": "channel_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "slack_channel_id": { + "name": "slack_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'self_signup'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "accepted_at": { + "name": "accepted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "unsubscribed_at": { + "name": "unsubscribed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "page_subscriber_page_id_idx": { + "name": "page_subscriber_page_id_idx", + "columns": [ + "page_id" + ], + "isUnique": false + }, + "idx_page_subscriber_email_page_active": { + "name": "idx_page_subscriber_email_page_active", + "columns": [ + "LOWER(\"email\")", + "page_id" + ], + "isUnique": true, + "where": "\"page_subscriber\".\"unsubscribed_at\" IS NULL AND \"page_subscriber\".\"channel_type\" = 'email'" + }, + "idx_page_subscriber_webhook_page_active": { + "name": "idx_page_subscriber_webhook_page_active", + "columns": [ + "LOWER(\"webhook_url\")", + "page_id" + ], + "isUnique": true, + "where": "\"page_subscriber\".\"unsubscribed_at\" IS NULL AND \"page_subscriber\".\"channel_type\" = 'webhook'" + }, + "idx_page_subscriber_slack_channel_page_active": { + "name": "idx_page_subscriber_slack_channel_page_active", + "columns": [ + "slack_channel_id", + "page_id" + ], + "isUnique": true, + "where": "\"page_subscriber\".\"unsubscribed_at\" IS NULL AND \"page_subscriber\".\"channel_type\" = 'slack'" + } + }, + "foreignKeys": { + "page_subscriber_page_id_page_id_fk": { + "name": "page_subscriber_page_id_page_id_fk", + "tableFrom": "page_subscriber", + "tableTo": "page", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "page_subscriber_channel_check": { + "name": "page_subscriber_channel_check", + "value": "(\"page_subscriber\".\"channel_type\" = 'email' AND \"page_subscriber\".\"email\" IS NOT NULL AND \"page_subscriber\".\"webhook_url\" IS NULL) OR (\"page_subscriber\".\"channel_type\" = 'webhook' AND \"page_subscriber\".\"webhook_url\" IS NOT NULL AND \"page_subscriber\".\"email\" IS NULL) OR (\"page_subscriber\".\"channel_type\" = 'slack' AND \"page_subscriber\".\"slack_channel_id\" IS NOT NULL AND \"page_subscriber\".\"email\" IS NULL AND \"page_subscriber\".\"webhook_url\" IS NULL)" + } + } + }, + "page_subscriber_to_page_component": { + "name": "page_subscriber_to_page_component", + "columns": { + "page_subscriber_id": { + "name": "page_subscriber_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "page_component_id": { + "name": "page_component_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": {}, + "foreignKeys": { + "page_subscriber_to_page_component_page_subscriber_id_page_subscriber_id_fk": { + "name": "page_subscriber_to_page_component_page_subscriber_id_page_subscriber_id_fk", + "tableFrom": "page_subscriber_to_page_component", + "tableTo": "page_subscriber", + "columnsFrom": [ + "page_subscriber_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "page_subscriber_to_page_component_page_component_id_page_component_id_fk": { + "name": "page_subscriber_to_page_component_page_component_id_page_component_id_fk", + "tableFrom": "page_subscriber_to_page_component", + "tableTo": "page_component", + "columnsFrom": [ + "page_component_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "page_subscriber_to_page_component_page_subscriber_id_page_component_id_pk": { + "columns": [ + "page_subscriber_id", + "page_component_id" + ], + "name": "page_subscriber_to_page_component_page_subscriber_id_page_component_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notification": { + "name": "notification", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'{}'" + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "notification_workspace_id_idx": { + "name": "notification_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "notification_workspace_id_workspace_id_fk": { + "name": "notification_workspace_id_workspace_id_fk", + "tableFrom": "notification", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notification_trigger": { + "name": "notification_trigger", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notification_id": { + "name": "notification_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cron_timestamp": { + "name": "cron_timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "notification_id_monitor_id_crontimestampe": { + "name": "notification_id_monitor_id_crontimestampe", + "columns": [ + "notification_id", + "monitor_id", + "cron_timestamp" + ], + "isUnique": true + } + }, + "foreignKeys": { + "notification_trigger_monitor_id_monitor_id_fk": { + "name": "notification_trigger_monitor_id_monitor_id_fk", + "tableFrom": "notification_trigger", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notification_trigger_notification_id_notification_id_fk": { + "name": "notification_trigger_notification_id_notification_id_fk", + "tableFrom": "notification_trigger", + "tableTo": "notification", + "columnsFrom": [ + "notification_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notifications_to_monitors": { + "name": "notifications_to_monitors", + "columns": { + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "notification_id": { + "name": "notification_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "notifications_to_monitors_notification_id_idx": { + "name": "notifications_to_monitors_notification_id_idx", + "columns": [ + "notification_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "notifications_to_monitors_monitor_id_monitor_id_fk": { + "name": "notifications_to_monitors_monitor_id_monitor_id_fk", + "tableFrom": "notifications_to_monitors", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notifications_to_monitors_notification_id_notification_id_fk": { + "name": "notifications_to_monitors_notification_id_notification_id_fk", + "tableFrom": "notifications_to_monitors", + "tableTo": "notification", + "columnsFrom": [ + "notification_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "notifications_to_monitors_monitor_id_notification_id_pk": { + "columns": [ + "monitor_id", + "notification_id" + ], + "name": "notifications_to_monitors_monitor_id_notification_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notification_dead_letter": { + "name": "notification_dead_letter", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "outbox_id": { + "name": "outbox_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dedup_key": { + "name": "dedup_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notification_id": { + "name": "notification_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from_status": { + "name": "from_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "to_status": { + "name": "to_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cron_timestamp": { + "name": "cron_timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "incident_id": { + "name": "incident_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "final_error": { + "name": "final_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "died_at": { + "name": "died_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "notification_dead_letter_dedup_key_idx": { + "name": "notification_dead_letter_dedup_key_idx", + "columns": [ + "dedup_key" + ], + "isUnique": true + }, + "notification_dead_letter_workspace_id_died_at_idx": { + "name": "notification_dead_letter_workspace_id_died_at_idx", + "columns": [ + "workspace_id", + "died_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "notification_dead_letter_monitor_id_monitor_id_fk": { + "name": "notification_dead_letter_monitor_id_monitor_id_fk", + "tableFrom": "notification_dead_letter", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notification_dead_letter_workspace_id_workspace_id_fk": { + "name": "notification_dead_letter_workspace_id_workspace_id_fk", + "tableFrom": "notification_dead_letter", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notification_outbox": { + "name": "notification_outbox", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "dedup_key": { + "name": "dedup_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notification_id": { + "name": "notification_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from_status": { + "name": "from_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "to_status": { + "name": "to_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cron_timestamp": { + "name": "cron_timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "incident_id": { + "name": "incident_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "delivery_status": { + "name": "delivery_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deadline_at": { + "name": "deadline_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locked_by": { + "name": "locked_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "locked_until": { + "name": "locked_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "delivered_at": { + "name": "delivered_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "notification_outbox_dedup_key_idx": { + "name": "notification_outbox_dedup_key_idx", + "columns": [ + "dedup_key" + ], + "isUnique": true + }, + "notification_outbox_claim_idx": { + "name": "notification_outbox_claim_idx", + "columns": [ + "next_attempt_at" + ], + "isUnique": false, + "where": "\"notification_outbox\".\"delivery_status\" = 'pending'" + }, + "notification_outbox_notification_id_cron_timestamp_idx": { + "name": "notification_outbox_notification_id_cron_timestamp_idx", + "columns": [ + "notification_id", + "cron_timestamp" + ], + "isUnique": false + }, + "notification_outbox_channel_idx": { + "name": "notification_outbox_channel_idx", + "columns": [ + "monitor_id", + "notification_id" + ], + "isUnique": false, + "where": "\"notification_outbox\".\"delivery_status\" = 'pending'" + } + }, + "foreignKeys": { + "notification_outbox_monitor_id_monitor_id_fk": { + "name": "notification_outbox_monitor_id_monitor_id_fk", + "tableFrom": "notification_outbox", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notification_outbox_workspace_id_workspace_id_fk": { + "name": "notification_outbox_workspace_id_workspace_id_fk", + "tableFrom": "notification_outbox", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "notification_outbox_notification_id_notification_id_fk": { + "name": "notification_outbox_notification_id_notification_id_fk", + "tableFrom": "notification_outbox", + "tableTo": "notification", + "columnsFrom": [ + "notification_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notification_outbox_incident_id_incident_id_fk": { + "name": "notification_outbox_incident_id_incident_id_fk", + "tableFrom": "notification_outbox", + "tableTo": "incident", + "columnsFrom": [ + "incident_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "monitor_status": { + "name": "monitor_status", + "columns": { + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "cron_timestamp": { + "name": "cron_timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "monitor_status_idx": { + "name": "monitor_status_idx", + "columns": [ + "monitor_id", + "region" + ], + "isUnique": false + } + }, + "foreignKeys": { + "monitor_status_monitor_id_monitor_id_fk": { + "name": "monitor_status_monitor_id_monitor_id_fk", + "tableFrom": "monitor_status", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "monitor_status_monitor_id_region_pk": { + "columns": [ + "monitor_id", + "region" + ], + "name": "monitor_status_monitor_id_region_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invitation": { + "name": "invitation", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "accepted_at": { + "name": "accepted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "invitation_workspace_id_idx": { + "name": "invitation_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "incident": { + "name": "incident", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'triage'" + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "acknowledged_at": { + "name": "acknowledged_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "acknowledged_by": { + "name": "acknowledged_by", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolved_by": { + "name": "resolved_by", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "incident_screenshot_url": { + "name": "incident_screenshot_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recovery_screenshot_url": { + "name": "recovery_screenshot_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auto_resolved": { + "name": "auto_resolved", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "incident_workspace_id_started_at_idx": { + "name": "incident_workspace_id_started_at_idx", + "columns": [ + "workspace_id", + "started_at" + ], + "isUnique": false + }, + "incident_open_idx": { + "name": "incident_open_idx", + "columns": [ + "monitor_id" + ], + "isUnique": true, + "where": "\"incident\".\"resolved_at\" IS NULL" + }, + "incident_monitor_id_started_at_unique": { + "name": "incident_monitor_id_started_at_unique", + "columns": [ + "monitor_id", + "started_at" + ], + "isUnique": true + } + }, + "foreignKeys": { + "incident_monitor_id_monitor_id_fk": { + "name": "incident_monitor_id_monitor_id_fk", + "tableFrom": "incident", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set default", + "onUpdate": "no action" + }, + "incident_workspace_id_workspace_id_fk": { + "name": "incident_workspace_id_workspace_id_fk", + "tableFrom": "incident", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "incident_acknowledged_by_user_id_fk": { + "name": "incident_acknowledged_by_user_id_fk", + "tableFrom": "incident", + "tableTo": "user", + "columnsFrom": [ + "acknowledged_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "incident_resolved_by_user_id_fk": { + "name": "incident_resolved_by_user_id_fk", + "tableFrom": "incident", + "tableTo": "user", + "columnsFrom": [ + "resolved_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "monitor_tag": { + "name": "monitor_tag", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "monitor_tag_workspace_id_idx": { + "name": "monitor_tag_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "monitor_tag_workspace_id_workspace_id_fk": { + "name": "monitor_tag_workspace_id_workspace_id_fk", + "tableFrom": "monitor_tag", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "monitor_tag_to_monitor": { + "name": "monitor_tag_to_monitor", + "columns": { + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "monitor_tag_id": { + "name": "monitor_tag_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "monitor_tag_to_monitor_monitor_tag_id_idx": { + "name": "monitor_tag_to_monitor_monitor_tag_id_idx", + "columns": [ + "monitor_tag_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "monitor_tag_to_monitor_monitor_id_monitor_id_fk": { + "name": "monitor_tag_to_monitor_monitor_id_monitor_id_fk", + "tableFrom": "monitor_tag_to_monitor", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "monitor_tag_to_monitor_monitor_tag_id_monitor_tag_id_fk": { + "name": "monitor_tag_to_monitor_monitor_tag_id_monitor_tag_id_fk", + "tableFrom": "monitor_tag_to_monitor", + "tableTo": "monitor_tag", + "columnsFrom": [ + "monitor_tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "monitor_tag_to_monitor_monitor_id_monitor_tag_id_pk": { + "columns": [ + "monitor_id", + "monitor_tag_id" + ], + "name": "monitor_tag_to_monitor_monitor_id_monitor_tag_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "application": { + "name": "application", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dsn": { + "name": "dsn", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "application_dsn_unique": { + "name": "application_dsn_unique", + "columns": [ + "dsn" + ], + "isUnique": true + }, + "application_workspace_id_idx": { + "name": "application_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "application_workspace_id_workspace_id_fk": { + "name": "application_workspace_id_workspace_id_fk", + "tableFrom": "application", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "maintenance": { + "name": "maintenance", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from": { + "name": "from", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "to": { + "name": "to", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "page_id": { + "name": "page_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "maintenance_page_id_idx": { + "name": "maintenance_page_id_idx", + "columns": [ + "page_id" + ], + "isUnique": false + }, + "maintenance_workspace_id_idx": { + "name": "maintenance_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "maintenance_workspace_id_workspace_id_fk": { + "name": "maintenance_workspace_id_workspace_id_fk", + "tableFrom": "maintenance", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "maintenance_page_id_page_id_fk": { + "name": "maintenance_page_id_page_id_fk", + "tableFrom": "maintenance", + "tableTo": "page", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "check": { + "name": "check", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "regions": { + "name": "regions", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "url": { + "name": "url", + "type": "text(4096)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "headers": { + "name": "headers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'GET'" + }, + "count_requests": { + "name": "count_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 1 + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "check_workspace_id_idx": { + "name": "check_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "check_workspace_id_workspace_id_fk": { + "name": "check_workspace_id_workspace_id_fk", + "tableFrom": "check", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "monitor_run": { + "name": "monitor_run", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "runned_at": { + "name": "runned_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "monitor_run_workspace_id_created_at_idx": { + "name": "monitor_run_workspace_id_created_at_idx", + "columns": [ + "workspace_id", + "created_at" + ], + "isUnique": false + }, + "monitor_run_monitor_id_idx": { + "name": "monitor_run_monitor_id_idx", + "columns": [ + "monitor_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "monitor_run_workspace_id_workspace_id_fk": { + "name": "monitor_run_workspace_id_workspace_id_fk", + "tableFrom": "monitor_run", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "monitor_run_monitor_id_monitor_id_fk": { + "name": "monitor_run_monitor_id_monitor_id_fk", + "tableFrom": "monitor_run", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "private_location_monitor_status": { + "name": "private_location_monitor_status", + "columns": { + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "private_location_id": { + "name": "private_location_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "cron_timestamp": { + "name": "cron_timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "private_location_monitor_status_pl_id_idx": { + "name": "private_location_monitor_status_pl_id_idx", + "columns": [ + "private_location_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "private_location_monitor_status_monitor_id_monitor_id_fk": { + "name": "private_location_monitor_status_monitor_id_monitor_id_fk", + "tableFrom": "private_location_monitor_status", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "private_location_monitor_status_private_location_id_private_location_id_fk": { + "name": "private_location_monitor_status_private_location_id_private_location_id_fk", + "tableFrom": "private_location_monitor_status", + "tableTo": "private_location", + "columnsFrom": [ + "private_location_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "private_location_monitor_status_monitor_id_private_location_id_pk": { + "columns": [ + "monitor_id", + "private_location_id" + ], + "name": "private_location_monitor_status_monitor_id_private_location_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "private_location": { + "name": "private_location", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'error'" + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "private_location_workspace_id_idx": { + "name": "private_location_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "private_location_workspace_id_workspace_id_fk": { + "name": "private_location_workspace_id_workspace_id_fk", + "tableFrom": "private_location", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "private_location_to_monitor": { + "name": "private_location_to_monitor", + "columns": { + "private_location_id": { + "name": "private_location_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "private_location_to_monitor_private_location_id_idx": { + "name": "private_location_to_monitor_private_location_id_idx", + "columns": [ + "private_location_id" + ], + "isUnique": false + }, + "private_location_to_monitor_monitor_id_idx": { + "name": "private_location_to_monitor_monitor_id_idx", + "columns": [ + "monitor_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "private_location_to_monitor_private_location_id_private_location_id_fk": { + "name": "private_location_to_monitor_private_location_id_private_location_id_fk", + "tableFrom": "private_location_to_monitor", + "tableTo": "private_location", + "columnsFrom": [ + "private_location_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "private_location_to_monitor_monitor_id_monitor_id_fk": { + "name": "private_location_to_monitor_monitor_id_monitor_id_fk", + "tableFrom": "private_location_to_monitor", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "monitor_group": { + "name": "monitor_group", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "page_id": { + "name": "page_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "monitor_group_workspace_id_idx": { + "name": "monitor_group_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + }, + "monitor_group_page_id_idx": { + "name": "monitor_group_page_id_idx", + "columns": [ + "page_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "monitor_group_workspace_id_workspace_id_fk": { + "name": "monitor_group_workspace_id_workspace_id_fk", + "tableFrom": "monitor_group", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "monitor_group_page_id_page_id_fk": { + "name": "monitor_group_page_id_page_id_fk", + "tableFrom": "monitor_group", + "tableTo": "page", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "viewer": { + "name": "viewer", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "viewer_email_unique": { + "name": "viewer_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "viewer_accounts": { + "name": "viewer_accounts", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "viewer_accounts_user_id_viewer_id_fk": { + "name": "viewer_accounts_user_id_viewer_id_fk", + "tableFrom": "viewer_accounts", + "tableTo": "viewer", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "viewer_accounts_provider_providerAccountId_pk": { + "columns": [ + "provider", + "providerAccountId" + ], + "name": "viewer_accounts_provider_providerAccountId_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "viewer_session": { + "name": "viewer_session", + "columns": { + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "viewer_session_user_id_viewer_id_fk": { + "name": "viewer_session_user_id_viewer_id_fk", + "tableFrom": "viewer_session", + "tableTo": "viewer", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "api_key": { + "name": "api_key", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hashed_token": { + "name": "hashed_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[\"write\"]'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "api_key_prefix_unique": { + "name": "api_key_prefix_unique", + "columns": [ + "prefix" + ], + "isUnique": true + }, + "api_key_hashed_token_unique": { + "name": "api_key_hashed_token_unique", + "columns": [ + "hashed_token" + ], + "isUnique": true + }, + "api_key_prefix_idx": { + "name": "api_key_prefix_idx", + "columns": [ + "prefix" + ], + "isUnique": false + }, + "api_key_workspace_id_idx": { + "name": "api_key_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "api_key_workspace_id_workspace_id_fk": { + "name": "api_key_workspace_id_workspace_id_fk", + "tableFrom": "api_key", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_created_by_id_user_id_fk": { + "name": "api_key_created_by_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "maintenance_to_page_component": { + "name": "maintenance_to_page_component", + "columns": { + "maintenance_id": { + "name": "maintenance_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "page_component_id": { + "name": "page_component_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "maintenance_to_page_component_page_component_id_idx": { + "name": "maintenance_to_page_component_page_component_id_idx", + "columns": [ + "page_component_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "maintenance_to_page_component_maintenance_id_maintenance_id_fk": { + "name": "maintenance_to_page_component_maintenance_id_maintenance_id_fk", + "tableFrom": "maintenance_to_page_component", + "tableTo": "maintenance", + "columnsFrom": [ + "maintenance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "maintenance_to_page_component_page_component_id_page_component_id_fk": { + "name": "maintenance_to_page_component_page_component_id_page_component_id_fk", + "tableFrom": "maintenance_to_page_component", + "tableTo": "page_component", + "columnsFrom": [ + "page_component_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "maintenance_to_page_component_maintenance_id_page_component_id_pk": { + "columns": [ + "maintenance_id", + "page_component_id" + ], + "name": "maintenance_to_page_component_maintenance_id_page_component_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "page_component": { + "name": "page_component", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "page_id": { + "name": "page_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'monitor'" + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "group_id": { + "name": "group_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "group_order": { + "name": "group_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "page_component_workspace_id_idx": { + "name": "page_component_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + }, + "page_component_page_id_monitor_id_unique": { + "name": "page_component_page_id_monitor_id_unique", + "columns": [ + "page_id", + "monitor_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "page_component_workspace_id_workspace_id_fk": { + "name": "page_component_workspace_id_workspace_id_fk", + "tableFrom": "page_component", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "page_component_page_id_page_id_fk": { + "name": "page_component_page_id_page_id_fk", + "tableFrom": "page_component", + "tableTo": "page", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "page_component_monitor_id_monitor_id_fk": { + "name": "page_component_monitor_id_monitor_id_fk", + "tableFrom": "page_component", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "page_component_group_id_page_component_groups_id_fk": { + "name": "page_component_group_id_page_component_groups_id_fk", + "tableFrom": "page_component", + "tableTo": "page_component_groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "page_component_type_check": { + "name": "page_component_type_check", + "value": "\"page_component\".\"type\" = 'monitor' AND \"page_component\".\"monitor_id\" IS NOT NULL OR \"page_component\".\"type\" = 'static' AND \"page_component\".\"monitor_id\" IS NULL" + } + } + }, + "status_report_update_to_page_component": { + "name": "status_report_update_to_page_component", + "columns": { + "status_report_update_id": { + "name": "status_report_update_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "page_component_id": { + "name": "page_component_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "impact": { + "name": "impact", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "status_report_update_to_page_component_page_component_id_idx": { + "name": "status_report_update_to_page_component_page_component_id_idx", + "columns": [ + "page_component_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "status_report_update_to_page_component_status_report_update_id_status_report_update_id_fk": { + "name": "status_report_update_to_page_component_status_report_update_id_status_report_update_id_fk", + "tableFrom": "status_report_update_to_page_component", + "tableTo": "status_report_update", + "columnsFrom": [ + "status_report_update_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "status_report_update_to_page_component_page_component_id_page_component_id_fk": { + "name": "status_report_update_to_page_component_page_component_id_page_component_id_fk", + "tableFrom": "status_report_update_to_page_component", + "tableTo": "page_component", + "columnsFrom": [ + "page_component_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "status_report_update_to_page_component_status_report_update_id_page_component_id_pk": { + "columns": [ + "status_report_update_id", + "page_component_id" + ], + "name": "status_report_update_to_page_component_status_report_update_id_page_component_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "status_report_to_page_component": { + "name": "status_report_to_page_component", + "columns": { + "status_report_id": { + "name": "status_report_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "page_component_id": { + "name": "page_component_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "status_report_to_page_component_page_component_id_idx": { + "name": "status_report_to_page_component_page_component_id_idx", + "columns": [ + "page_component_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "status_report_to_page_component_status_report_id_status_report_id_fk": { + "name": "status_report_to_page_component_status_report_id_status_report_id_fk", + "tableFrom": "status_report_to_page_component", + "tableTo": "status_report", + "columnsFrom": [ + "status_report_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "status_report_to_page_component_page_component_id_page_component_id_fk": { + "name": "status_report_to_page_component_page_component_id_page_component_id_fk", + "tableFrom": "status_report_to_page_component", + "tableTo": "page_component", + "columnsFrom": [ + "page_component_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "status_report_to_page_component_status_report_id_page_component_id_pk": { + "columns": [ + "status_report_id", + "page_component_id" + ], + "name": "status_report_to_page_component_status_report_id_page_component_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "page_component_groups": { + "name": "page_component_groups", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "page_id": { + "name": "page_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "default_open": { + "name": "default_open", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "page_component_groups_page_id_idx": { + "name": "page_component_groups_page_id_idx", + "columns": [ + "page_id" + ], + "isUnique": false + }, + "page_component_groups_workspace_id_idx": { + "name": "page_component_groups_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "page_component_groups_workspace_id_workspace_id_fk": { + "name": "page_component_groups_workspace_id_workspace_id_fk", + "tableFrom": "page_component_groups", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "page_component_groups_page_id_page_id_fk": { + "name": "page_component_groups_page_id_page_id_fk", + "tableFrom": "page_component_groups", + "tableTo": "page", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "feedback": { + "name": "feedback", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "blocker": { + "name": "blocker", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "feedback_workspace_id_idx": { + "name": "feedback_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "feedback_workspace_id_workspace_id_fk": { + "name": "feedback_workspace_id_workspace_id_fk", + "tableFrom": "feedback", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "feedback_user_id_user_id_fk": { + "name": "feedback_user_id_user_id_fk", + "tableFrom": "feedback", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audit_log": { + "name": "audit_log", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_type": { + "name": "actor_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "before": { + "name": "before", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "after": { + "name": "after", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "changed_fields": { + "name": "changed_fields", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "audit_log_workspace_created_idx": { + "name": "audit_log_workspace_created_idx", + "columns": [ + "workspace_id", + "created_at" + ], + "isUnique": false + }, + "audit_log_entity_idx": { + "name": "audit_log_entity_idx", + "columns": [ + "workspace_id", + "entity_type", + "entity_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_service": { + "name": "external_service", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "aliases": { + "name": "aliases", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(json_array())" + }, + "name": { + "name": "name", + "type": "text(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status_page_url": { + "name": "status_page_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "industry": { + "name": "industry", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "api_config": { + "name": "api_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "external_service_slug_unique": { + "name": "external_service_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "external_service_deleted_at_idx": { + "name": "external_service_deleted_at_idx", + "columns": [ + "deleted_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_service_component": { + "name": "external_service_component", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "external_service_id": { + "name": "external_service_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "upstream_component_id": { + "name": "upstream_component_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "aliases": { + "name": "aliases", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(json_array())" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "group_name": { + "name": "group_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "indicator": { + "name": "indicator", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "external_service_component_unique_idx": { + "name": "external_service_component_unique_idx", + "columns": [ + "external_service_id", + "upstream_component_id" + ], + "isUnique": true + }, + "external_service_component_slug_unique_idx": { + "name": "external_service_component_slug_unique_idx", + "columns": [ + "external_service_id", + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": { + "external_service_component_external_service_id_external_service_id_fk": { + "name": "external_service_component_external_service_id_external_service_id_fk", + "tableFrom": "external_service_component", + "tableTo": "external_service", + "columnsFrom": [ + "external_service_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_service_incident": { + "name": "external_service_incident", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "external_service_id": { + "name": "external_service_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_incident_id": { + "name": "provider_incident_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "impact": { + "name": "impact", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "shortlink": { + "name": "shortlink", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "affected_component_ids": { + "name": "affected_component_ids", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "raw_payload": { + "name": "raw_payload", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "raw_payload_purged_at": { + "name": "raw_payload_purged_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "external_service_incident_unique_idx": { + "name": "external_service_incident_unique_idx", + "columns": [ + "external_service_id", + "provider_incident_id" + ], + "isUnique": true + }, + "external_service_incident_started_at_idx": { + "name": "external_service_incident_started_at_idx", + "columns": [ + "external_service_id", + "started_at" + ], + "isUnique": false + }, + "external_service_incident_resolved_at_idx": { + "name": "external_service_incident_resolved_at_idx", + "columns": [ + "resolved_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "external_service_incident_external_service_id_external_service_id_fk": { + "name": "external_service_incident_external_service_id_external_service_id_fk", + "tableFrom": "external_service_incident", + "tableTo": "external_service", + "columnsFrom": [ + "external_service_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_service_report": { + "name": "external_service_report", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "external_service_id": { + "name": "external_service_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "external_service_component_id": { + "name": "external_service_component_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reporter_hash": { + "name": "reporter_hash", + "type": "text(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "country": { + "name": "country", + "type": "text(2)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "external_service_report_service_idx": { + "name": "external_service_report_service_idx", + "columns": [ + "external_service_id", + "created_at" + ], + "isUnique": false + }, + "external_service_report_component_idx": { + "name": "external_service_report_component_idx", + "columns": [ + "external_service_component_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "external_service_report_external_service_id_external_service_id_fk": { + "name": "external_service_report_external_service_id_external_service_id_fk", + "tableFrom": "external_service_report", + "tableTo": "external_service", + "columnsFrom": [ + "external_service_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "external_service_report_external_service_component_id_external_service_component_id_fk": { + "name": "external_service_report_external_service_component_id_external_service_component_id_fk", + "tableFrom": "external_service_report", + "tableTo": "external_service_component", + "columnsFrom": [ + "external_service_component_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "chat_session": { + "name": "chat_session", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "messages": { + "name": "messages", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "chat_session_workspace_user_updated_idx": { + "name": "chat_session_workspace_user_updated_idx", + "columns": [ + "workspace_id", + "user_id", + "updated_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "chat_session_workspace_id_workspace_id_fk": { + "name": "chat_session_workspace_id_workspace_id_fk", + "tableFrom": "chat_session", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_session_user_id_user_id_fk": { + "name": "chat_session_user_id_user_id_fk", + "tableFrom": "chat_session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "frozen_monitor_uptime": { + "name": "frozen_monitor_uptime", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "month": { + "name": "month", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "days": { + "name": "days", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "frozen_monitor_uptime_workspace_id_idx": { + "name": "frozen_monitor_uptime_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + }, + "frozen_monitor_uptime_monitor_id_month_unique": { + "name": "frozen_monitor_uptime_monitor_id_month_unique", + "columns": [ + "monitor_id", + "month" + ], + "isUnique": true + } + }, + "foreignKeys": { + "frozen_monitor_uptime_workspace_id_workspace_id_fk": { + "name": "frozen_monitor_uptime_workspace_id_workspace_id_fk", + "tableFrom": "frozen_monitor_uptime", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "frozen_monitor_uptime_monitor_id_monitor_id_fk": { + "name": "frozen_monitor_uptime_monitor_id_monitor_id_fk", + "tableFrom": "frozen_monitor_uptime", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "monitor_transition": { + "name": "monitor_transition", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cron_timestamp": { + "name": "cron_timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from_status": { + "name": "from_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "to_status": { + "name": "to_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "quorum_count": { + "name": "quorum_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "region_count": { + "name": "region_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "transitioned": { + "name": "transitioned", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "outbox_rows": { + "name": "outbox_rows", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "monitor_transition_monitor_id_cron_timestamp_idx": { + "name": "monitor_transition_monitor_id_cron_timestamp_idx", + "columns": [ + "monitor_id", + "cron_timestamp" + ], + "isUnique": false + }, + "monitor_transition_created_at_idx": { + "name": "monitor_transition_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "monitor_transition_monitor_id_monitor_id_fk": { + "name": "monitor_transition_monitor_id_monitor_id_fk", + "tableFrom": "monitor_transition", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_client": { + "name": "oauth_client", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'MCP Client'" + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "oauth_client_client_id_unique": { + "name": "oauth_client_client_id_unique", + "columns": [ + "client_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_session": { + "name": "oauth_session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirect_uri": { + "name": "redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "code_challenge": { + "name": "code_challenge", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "code_challenge_method": { + "name": "code_challenge_method", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'S256'" + }, + "decided_at": { + "name": "decided_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "oauth_session_client_id_idx": { + "name": "oauth_session_client_id_idx", + "columns": [ + "client_id" + ], + "isUnique": false + }, + "oauth_session_expires_at_idx": { + "name": "oauth_session_expires_at_idx", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "oauth_session_client_id_oauth_client_client_id_fk": { + "name": "oauth_session_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_session", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_authorization_code": { + "name": "oauth_authorization_code", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirect_uri": { + "name": "redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "code_challenge": { + "name": "code_challenge", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "consumed_at": { + "name": "consumed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "grant_id": { + "name": "grant_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "oauth_authorization_code_hash_unique": { + "name": "oauth_authorization_code_hash_unique", + "columns": [ + "hash" + ], + "isUnique": true + }, + "oauth_authorization_code_expires_at_idx": { + "name": "oauth_authorization_code_expires_at_idx", + "columns": [ + "expires_at" + ], + "isUnique": false + }, + "oauth_authorization_code_client_id_idx": { + "name": "oauth_authorization_code_client_id_idx", + "columns": [ + "client_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "oauth_authorization_code_client_id_oauth_client_client_id_fk": { + "name": "oauth_authorization_code_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_authorization_code", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_authorization_code_user_id_user_id_fk": { + "name": "oauth_authorization_code_user_id_user_id_fk", + "tableFrom": "oauth_authorization_code", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_authorization_code_workspace_id_workspace_id_fk": { + "name": "oauth_authorization_code_workspace_id_workspace_id_fk", + "tableFrom": "oauth_authorization_code", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_authorization_code_grant_id_oauth_grant_id_fk": { + "name": "oauth_authorization_code_grant_id_oauth_grant_id_fk", + "tableFrom": "oauth_authorization_code", + "tableTo": "oauth_grant", + "columnsFrom": [ + "grant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_grant": { + "name": "oauth_grant", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token_hash": { + "name": "access_token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token_hash": { + "name": "refresh_token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "previous_refresh_token_hash": { + "name": "previous_refresh_token_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rotated_at": { + "name": "rotated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "oauth_grant_access_token_hash_unique": { + "name": "oauth_grant_access_token_hash_unique", + "columns": [ + "access_token_hash" + ], + "isUnique": true + }, + "oauth_grant_refresh_token_hash_unique": { + "name": "oauth_grant_refresh_token_hash_unique", + "columns": [ + "refresh_token_hash" + ], + "isUnique": true + }, + "oauth_grant_workspace_id_idx": { + "name": "oauth_grant_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + }, + "oauth_grant_client_id_user_id_idx": { + "name": "oauth_grant_client_id_user_id_idx", + "columns": [ + "client_id", + "user_id" + ], + "isUnique": false + }, + "oauth_grant_user_id_idx": { + "name": "oauth_grant_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "oauth_grant_previous_refresh_token_hash_idx": { + "name": "oauth_grant_previous_refresh_token_hash_idx", + "columns": [ + "previous_refresh_token_hash" + ], + "isUnique": false + }, + "oauth_grant_refresh_token_expires_at_idx": { + "name": "oauth_grant_refresh_token_expires_at_idx", + "columns": [ + "refresh_token_expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "oauth_grant_client_id_oauth_client_client_id_fk": { + "name": "oauth_grant_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_grant", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_grant_user_id_user_id_fk": { + "name": "oauth_grant_user_id_user_id_fk", + "tableFrom": "oauth_grant", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_grant_workspace_id_workspace_id_fk": { + "name": "oauth_grant_workspace_id_workspace_id_fk", + "tableFrom": "oauth_grant", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": { + "page_lower_slug_idx": { + "columns": { + "LOWER(\"slug\")": { + "isExpression": true + } + } + }, + "page_lower_custom_domain_idx": { + "columns": { + "LOWER(\"custom_domain\")": { + "isExpression": true + } + } + }, + "idx_page_subscriber_email_page_active": { + "columns": { + "LOWER(\"email\")": { + "isExpression": true + } + } + }, + "idx_page_subscriber_webhook_page_active": { + "columns": { + "LOWER(\"webhook_url\")": { + "isExpression": true + } + } + } + } + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 54eb81185..0fa8fef8e 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -603,6 +603,13 @@ "when": 1788374582005, "tag": "0085_daily_glorian", "breakpoints": true + }, + { + "idx": 86, + "version": "6", + "when": 1788721405307, + "tag": "0086_robust_quicksilver", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/env.mjs b/packages/db/env.mjs index 069a71f54..d619807d6 100644 --- a/packages/db/env.mjs +++ b/packages/db/env.mjs @@ -5,9 +5,6 @@ export const env = createEnv({ server: { DATABASE_URL: z.string().min(1), DATABASE_AUTH_TOKEN: z.string().min(1), - CLICKHOUSE_URL: z.string(), - CLICKHOUSE_USERNAME: z.string(), - CLICKHOUSE_PASSWORD: z.string(), }, runtimeEnv: { // Tests fall back to the conventional local sqld port, but an explicit @@ -16,9 +13,6 @@ export const env = createEnv({ process.env.DATABASE_URL ?? (process.env.NODE_ENV === "test" ? "http://127.0.0.1:8080" : undefined), DATABASE_AUTH_TOKEN: process.env.DATABASE_AUTH_TOKEN, - CLICKHOUSE_URL: process.env.CLICKHOUSE_URL, - CLICKHOUSE_USERNAME: process.env.CLICKHOUSE_USERNAME, - CLICKHOUSE_PASSWORD: process.env.CLICKHOUSE_PASSWORD, }, skipValidation: true, }); diff --git a/packages/db/src/schema/audit_logs/validation.ts b/packages/db/src/schema/audit_logs/validation.ts index aefc03d83..6349bb7ce 100644 --- a/packages/db/src/schema/audit_logs/validation.ts +++ b/packages/db/src/schema/audit_logs/validation.ts @@ -90,6 +90,17 @@ const apiKeyActions = [ action("api_key.delete", "api_key", intId), ] as const; +// `delete` covers every revocation path: manual, re-consent, member removal +// and account deletion. `metadata` carries client name and scope on create. +const oauthGrantActions = [ + action("oauth_grant.create", "oauth_grant", intId, { + optionalMetadata: true, + }), + action("oauth_grant.delete", "oauth_grant", intId, { + optionalMetadata: true, + }), +] as const; + const notificationActions = [ action("notification.create", "notification", intId), action("notification.update", "notification", intId), @@ -206,6 +217,7 @@ export const auditActionSchema = z.discriminatedUnion("action", [ ...pageComponentGroupActions, ...pageSubscriberActions, ...apiKeyActions, + ...oauthGrantActions, ...notificationActions, ...userActions, ...workspaceActions, diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 7dd0664a3..d2760b213 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -27,3 +27,4 @@ export * from "./external_services"; export * from "./chat_sessions"; export * from "./frozen_uptime"; export * from "./monitor_transition"; +export * from "./oauth"; diff --git a/packages/db/src/schema/oauth/index.ts b/packages/db/src/schema/oauth/index.ts new file mode 100644 index 000000000..e0be1a93a --- /dev/null +++ b/packages/db/src/schema/oauth/index.ts @@ -0,0 +1,5 @@ +export * from "./oauth_client"; +export * from "./oauth_session"; +export * from "./oauth_authorization_code"; +export * from "./oauth_grant"; +export * from "./validation"; diff --git a/packages/db/src/schema/oauth/oauth_authorization_code.ts b/packages/db/src/schema/oauth/oauth_authorization_code.ts new file mode 100644 index 000000000..e4923c21a --- /dev/null +++ b/packages/db/src/schema/oauth/oauth_authorization_code.ts @@ -0,0 +1,60 @@ +import { relations } from "drizzle-orm"; +import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; + +import type { Scope } from "../api-keys/constants"; +import { user } from "../users"; +import { workspace } from "../workspaces"; +import { oauthClient } from "./oauth_client"; +import { oauthGrant } from "./oauth_grant"; + +export const oauthAuthorizationCode = sqliteTable( + "oauth_authorization_code", + { + id: integer("id").primaryKey({ autoIncrement: true }), + hash: text("hash").notNull().unique(), + clientId: text("client_id") + .notNull() + .references(() => oauthClient.clientId, { onDelete: "cascade" }), + userId: integer("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + workspaceId: integer("workspace_id") + .notNull() + .references(() => workspace.id, { onDelete: "cascade" }), + scope: text("scope", { mode: "json" }).$type().notNull(), + redirectUri: text("redirect_uri").notNull(), + codeChallenge: text("code_challenge").notNull(), + expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(), + consumedAt: integer("consumed_at", { mode: "timestamp" }), + // Set on exchange so a replayed code can revoke the grant it produced. + grantId: integer("grant_id").references(() => oauthGrant.id, { + onDelete: "set null", + }), + }, + (t) => [ + index("oauth_authorization_code_expires_at_idx").on(t.expiresAt), + index("oauth_authorization_code_client_id_idx").on(t.clientId), + ], +); + +export const oauthAuthorizationCodeRelations = relations( + oauthAuthorizationCode, + ({ one }) => ({ + client: one(oauthClient, { + fields: [oauthAuthorizationCode.clientId], + references: [oauthClient.clientId], + }), + grant: one(oauthGrant, { + fields: [oauthAuthorizationCode.grantId], + references: [oauthGrant.id], + }), + user: one(user, { + fields: [oauthAuthorizationCode.userId], + references: [user.id], + }), + workspace: one(workspace, { + fields: [oauthAuthorizationCode.workspaceId], + references: [workspace.id], + }), + }), +); diff --git a/packages/db/src/schema/oauth/oauth_client.ts b/packages/db/src/schema/oauth/oauth_client.ts new file mode 100644 index 000000000..f6b1fd03c --- /dev/null +++ b/packages/db/src/schema/oauth/oauth_client.ts @@ -0,0 +1,27 @@ +import { relations, sql } from "drizzle-orm"; +import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; + +import { oauthAuthorizationCode } from "./oauth_authorization_code"; +import { oauthGrant } from "./oauth_grant"; +import { oauthSession } from "./oauth_session"; + +// Every client is public (token_endpoint_auth_method "none"), so there is +// no secret column. Confidential clients are a follow-up. +export const oauthClient = sqliteTable("oauth_client", { + id: integer("id").primaryKey({ autoIncrement: true }), + clientId: text("client_id").notNull().unique(), + name: text("name").notNull().default("MCP Client"), + redirectUris: text("redirect_uris", { mode: "json" }) + .$type() + .notNull(), + revokedAt: integer("revoked_at", { mode: "timestamp" }), + createdAt: integer("created_at", { mode: "timestamp" }).default( + sql`(strftime('%s', 'now'))`, + ), +}); + +export const oauthClientRelations = relations(oauthClient, ({ many }) => ({ + sessions: many(oauthSession), + authorizationCodes: many(oauthAuthorizationCode), + grants: many(oauthGrant), +})); diff --git a/packages/db/src/schema/oauth/oauth_grant.ts b/packages/db/src/schema/oauth/oauth_grant.ts new file mode 100644 index 000000000..062ed3694 --- /dev/null +++ b/packages/db/src/schema/oauth/oauth_grant.ts @@ -0,0 +1,71 @@ +import { relations, sql } from "drizzle-orm"; +import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; + +import type { Scope } from "../api-keys/constants"; +import { user } from "../users"; +import { workspace } from "../workspaces"; +import { oauthClient } from "./oauth_client"; + +// One row per consent. Access and refresh token share the row because they +// are one-to-one and rotate together. +export const oauthGrant = sqliteTable( + "oauth_grant", + { + id: integer("id").primaryKey({ autoIncrement: true }), + clientId: text("client_id") + .notNull() + .references(() => oauthClient.clientId, { onDelete: "cascade" }), + userId: integer("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + workspaceId: integer("workspace_id") + .notNull() + .references(() => workspace.id, { onDelete: "cascade" }), + scope: text("scope", { mode: "json" }).$type().notNull(), + accessTokenHash: text("access_token_hash").notNull().unique(), + accessTokenExpiresAt: integer("access_token_expires_at", { + mode: "timestamp", + }).notNull(), + refreshTokenHash: text("refresh_token_hash").notNull().unique(), + refreshTokenExpiresAt: integer("refresh_token_expires_at", { + mode: "timestamp", + }).notNull(), + // Grace window: the hash rotated out at `rotatedAt` stays valid briefly + // so two concurrent refreshes do not kill the grant. + previousRefreshTokenHash: text("previous_refresh_token_hash"), + rotatedAt: integer("rotated_at", { mode: "timestamp" }), + lastUsedAt: integer("last_used_at", { mode: "timestamp" }), + revokedAt: integer("revoked_at", { mode: "timestamp" }), + createdAt: integer("created_at", { mode: "timestamp" }).default( + sql`(strftime('%s', 'now'))`, + ), + }, + (t) => [ + index("oauth_grant_workspace_id_idx").on(t.workspaceId), + // One client id is shared by every user of Claude / Cursor, so the + // re-consent lookup needs the user in the key. + index("oauth_grant_client_id_user_id_idx").on(t.clientId, t.userId), + index("oauth_grant_user_id_idx").on(t.userId), + index("oauth_grant_previous_refresh_token_hash_idx").on( + t.previousRefreshTokenHash, + ), + index("oauth_grant_refresh_token_expires_at_idx").on( + t.refreshTokenExpiresAt, + ), + ], +); + +export const oauthGrantRelations = relations(oauthGrant, ({ one }) => ({ + client: one(oauthClient, { + fields: [oauthGrant.clientId], + references: [oauthClient.clientId], + }), + user: one(user, { + fields: [oauthGrant.userId], + references: [user.id], + }), + workspace: one(workspace, { + fields: [oauthGrant.workspaceId], + references: [workspace.id], + }), +})); diff --git a/packages/db/src/schema/oauth/oauth_session.ts b/packages/db/src/schema/oauth/oauth_session.ts new file mode 100644 index 000000000..92c82be2f --- /dev/null +++ b/packages/db/src/schema/oauth/oauth_session.ts @@ -0,0 +1,38 @@ +import { relations } from "drizzle-orm"; +import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; + +import type { Scope } from "../api-keys/constants"; +import { oauthClient } from "./oauth_client"; + +// Pending authorize request. Lives in a row (not memory) because the server +// runs several instances and the consent page is served by the dashboard. +export const oauthSession = sqliteTable( + "oauth_session", + { + id: text("id").primaryKey(), + clientId: text("client_id") + .notNull() + .references(() => oauthClient.clientId, { onDelete: "cascade" }), + redirectUri: text("redirect_uri").notNull(), + scope: text("scope", { mode: "json" }).$type().notNull(), + state: text("state"), + resource: text("resource"), + codeChallenge: text("code_challenge").notNull(), + codeChallengeMethod: text("code_challenge_method", { enum: ["S256"] }) + .notNull() + .default("S256"), + decidedAt: integer("decided_at", { mode: "timestamp" }), + expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(), + }, + (t) => [ + index("oauth_session_client_id_idx").on(t.clientId), + index("oauth_session_expires_at_idx").on(t.expiresAt), + ], +); + +export const oauthSessionRelations = relations(oauthSession, ({ one }) => ({ + client: one(oauthClient, { + fields: [oauthSession.clientId], + references: [oauthClient.clientId], + }), +})); diff --git a/packages/db/src/schema/oauth/validation.ts b/packages/db/src/schema/oauth/validation.ts new file mode 100644 index 000000000..daf1122ab --- /dev/null +++ b/packages/db/src/schema/oauth/validation.ts @@ -0,0 +1,32 @@ +import { createSelectSchema } from "drizzle-zod"; +import { z } from "zod"; + +import { apiKeySettableScopes } from "../api-keys/constants"; +import { oauthAuthorizationCode } from "./oauth_authorization_code"; +import { oauthClient } from "./oauth_client"; +import { oauthGrant } from "./oauth_grant"; +import { oauthSession } from "./oauth_session"; + +// Grants never carry '*': that scope is synthesized by middleware only. +export const oauthScopesSchema = z.array(z.enum(apiKeySettableScopes)).min(1); + +export const selectOAuthClientSchema = createSelectSchema(oauthClient, { + redirectUris: z.array(z.string()), +}); +export const selectOAuthSessionSchema = createSelectSchema(oauthSession, { + scope: oauthScopesSchema, +}); +export const selectOAuthAuthorizationCodeSchema = createSelectSchema( + oauthAuthorizationCode, + { scope: oauthScopesSchema }, +); +export const selectOAuthGrantSchema = createSelectSchema(oauthGrant, { + scope: oauthScopesSchema, +}); + +export type OAuthClient = z.infer; +export type OAuthSession = z.infer; +export type OAuthAuthorizationCode = z.infer< + typeof selectOAuthAuthorizationCodeSchema +>; +export type OAuthGrant = z.infer; diff --git a/packages/db/src/utils/api-key.test.ts b/packages/db/src/utils/api-key.test.ts index 8fae1da27..4d987dda4 100644 --- a/packages/db/src/utils/api-key.test.ts +++ b/packages/db/src/utils/api-key.test.ts @@ -187,6 +187,14 @@ describe("API Key Utilities", () => { expect(shouldUpdateLastUsed(oneSecondAgo, 0)).toBe(true); }); + it("should measure against an injected clock when provided", () => { + const lastUsed = new Date("2026-01-01T00:00:00Z"); + const twoMinutesLater = new Date("2026-01-01T00:02:00Z"); + const tenMinutesLater = new Date("2026-01-01T00:10:00Z"); + expect(shouldUpdateLastUsed(lastUsed, 5, twoMinutesLater)).toBe(false); + expect(shouldUpdateLastUsed(lastUsed, 5, tenMinutesLater)).toBe(true); + }); + it("should handle very long debounce periods", () => { const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000); expect(shouldUpdateLastUsed(oneHourAgo, 120)).toBe(false); // 2 hours diff --git a/packages/db/src/utils/api-key.ts b/packages/db/src/utils/api-key.ts index c2a4c19a0..3b5194ac0 100644 --- a/packages/db/src/utils/api-key.ts +++ b/packages/db/src/utils/api-key.ts @@ -1,5 +1,5 @@ // oxlint-disable-next-line unicorn/prefer-node-protocol -import crypto from "crypto"; +import crypto from "node:crypto"; import bcrypt from "bcryptjs"; @@ -52,13 +52,15 @@ export async function verifyApiKeyHash( * Determines if lastUsedAt should be updated based on debounce period * @param lastUsedAt - The last time the key was used (or null) * @param debounceMinutes - Minutes to wait before updating again (default: 5) + * @param now - Reference time (default: wall clock); callers with an injected clock pass theirs * @returns True if lastUsedAt should be updated */ export function shouldUpdateLastUsed( lastUsedAt: Date | null, debounceMinutes = 5, + now: Date = new Date(), ): boolean { if (!lastUsedAt) return true; - const diffMs = Date.now() - lastUsedAt.getTime(); + const diffMs = now.getTime() - lastUsedAt.getTime(); return diffMs > debounceMinutes * 60 * 1000; } diff --git a/packages/services/package.json b/packages/services/package.json index 66ec2400f..f7ad2b7a6 100644 --- a/packages/services/package.json +++ b/packages/services/package.json @@ -69,6 +69,10 @@ "import": "./src/api-key/index.ts", "types": "./src/api-key/index.ts" }, + "./oauth": { + "import": "./src/oauth/index.ts", + "types": "./src/oauth/index.ts" + }, "./chat-session": { "import": "./src/chat-session/index.ts", "types": "./src/chat-session/index.ts" diff --git a/packages/services/src/member/internal.ts b/packages/services/src/member/internal.ts index 1d50f2d64..29b74d363 100644 --- a/packages/services/src/member/internal.ts +++ b/packages/services/src/member/internal.ts @@ -4,6 +4,7 @@ import { z } from "zod"; import { emitAudit } from "../audit"; import { type DB, type ServiceContext } from "../context"; +import { revokeGrantsForUser } from "../oauth/revoke"; // Composite-PK rows: drizzle's createSelectSchema would flatten the join, // but the membership row has no auto-generated columns we'd want to drop @@ -52,4 +53,12 @@ export async function removeMemberInWorkspace(args: { entityId: userId, before: memberRowSnapshot.parse(removed), }); + + await revokeGrantsForUser({ + tx, + ctx, + userId, + workspaceId: ctx.workspace.id, + reason: "member_removed", + }); } diff --git a/packages/services/src/member/membership.ts b/packages/services/src/member/membership.ts new file mode 100644 index 000000000..c0de4f9f6 --- /dev/null +++ b/packages/services/src/member/membership.ts @@ -0,0 +1,23 @@ +import { and, eq } from "@openstatus/db"; +import { usersToWorkspaces } from "@openstatus/db/src/schema"; +import type { WorkspaceRole } from "@openstatus/db/src/schema"; + +import type { DB } from "../context"; + +/** Membership row for (user, workspace), or `undefined` when the user is not a member. */ +export async function getMembership( + db: DB, + userId: number, + workspaceId: number, +): Promise<{ role: WorkspaceRole } | undefined> { + return db + .select({ role: usersToWorkspaces.role }) + .from(usersToWorkspaces) + .where( + and( + eq(usersToWorkspaces.userId, userId), + eq(usersToWorkspaces.workspaceId, workspaceId), + ), + ) + .get(); +} diff --git a/packages/services/src/oauth/__tests__/cimd.test.ts b/packages/services/src/oauth/__tests__/cimd.test.ts new file mode 100644 index 000000000..66e089c91 --- /dev/null +++ b/packages/services/src/oauth/__tests__/cimd.test.ts @@ -0,0 +1,346 @@ +import { eq } from "@openstatus/db"; +import { oauthClient } from "@openstatus/db/src/schema"; +import { expect } from "@std/expect"; +import { beforeAll, describe, test } from "@std/testing/bdd"; + +import { + createWorkspaceFixture, + withTestTransaction, +} from "../../../test/helpers"; +import type { Workspace } from "../../types"; +import { + type ClientMetadataDocument, + isUrlClientId, + parseClientMetadataDocument, +} from "../cimd"; +import { pkceChallenge } from "../crypto"; +import { OAuthError } from "../errors"; +import { + createSession, + decideSession, + exchangeCode, + getSession, + verifyAccessToken, +} from "../index"; +import { authorizationServerMetadata } from "../metadata"; + +const CLIENT_ID = "https://partner.example/.well-known/oauth-client"; +const REDIRECT = "https://partner.example/oauth/callback"; +const VERIFIER = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; + +let team: Workspace; +let ownerId: number; + +beforeAll(async () => { + const fixture = await createWorkspaceFixture("team"); + team = fixture.workspace; + ownerId = fixture.userId; +}); + +function doc( + overrides: Partial = {}, +): ClientMetadataDocument { + return { + client_id: CLIENT_ID, + client_name: "Partner App", + redirect_uris: [REDIRECT], + ...overrides, + }; +} + +const stub = + (document: ClientMetadataDocument | Error) => async (clientId: string) => { + if (document instanceof Error) throw document; + return parseClientMetadataDocument(clientId, document); + }; + +async function authorizeAs( + clientId: string, + fetcher: ReturnType, + tx: Parameters[0]>[0], + overrides: Record = {}, +) { + return createSession({ + input: { + response_type: "code", + client_id: clientId, + redirect_uri: REDIRECT, + scope: "read", + code_challenge: await pkceChallenge(VERIFIER), + code_challenge_method: "S256", + ...overrides, + }, + db: tx, + fetchClientMetadata: fetcher, + }); +} + +describe("isUrlClientId", () => { + test("accepts public https URLs with a path", () => { + expect(isUrlClientId(CLIENT_ID)).toBe(true); + expect(isUrlClientId("https://app.partner.example:8443/client.json")).toBe( + true, + ); + }); + + test("rejects plain DCR ids, http, root paths, credentials and fragments", () => { + expect(isUrlClientId("0123456789abcdef0123456789abcdef")).toBe(false); + expect(isUrlClientId("http://partner.example/client")).toBe(false); + expect(isUrlClientId("https://partner.example/")).toBe(false); + expect(isUrlClientId("https://partner.example")).toBe(false); + expect(isUrlClientId("https://user:pw@partner.example/client")).toBe(false); + expect(isUrlClientId("https://partner.example/client#x")).toBe(false); + }); + + test("rejects loopback, private and link-local targets", () => { + for (const host of [ + "localhost", + "api.localhost", + "127.0.0.1", + "10.0.0.5", + "172.16.3.4", + "192.168.1.1", + "169.254.169.254", + "100.64.0.1", + "[::1]", + "[fd00::1]", + "metadata.google.internal", + "printer.local", + "intranet", + ]) { + expect(isUrlClientId(`https://${host}/client`)).toBe(false); + } + expect(isUrlClientId("https://8.8.8.8/client")).toBe(true); + }); + + test("a trailing dot does not bypass the hostname checks", () => { + expect(isUrlClientId("https://localhost./client")).toBe(false); + expect(isUrlClientId("https://intranet./client")).toBe(false); + expect(isUrlClientId("https://partner.example./client")).toBe(true); + }); + + test("a bare trailing # counts as a fragment", () => { + expect(isUrlClientId(`${CLIENT_ID}#`)).toBe(false); + }); +}); + +describe("parseClientMetadataDocument", () => { + test("accepts a public client document", () => { + const parsed = parseClientMetadataDocument( + CLIENT_ID, + doc({ token_endpoint_auth_method: "none" }), + ); + expect(parsed.client_name).toBe("Partner App"); + }); + + test("rejects a document whose client_id differs from its URL", () => { + expect(() => + parseClientMetadataDocument( + CLIENT_ID, + doc({ client_id: "https://other.example/c" }), + ), + ).toThrow(OAuthError); + }); + + test("rejects missing or non-https redirect URIs and confidential clients", () => { + expect(() => + parseClientMetadataDocument(CLIENT_ID, doc({ redirect_uris: [] })), + ).toThrow(OAuthError); + expect(() => + parseClientMetadataDocument( + CLIENT_ID, + doc({ redirect_uris: ["http://partner.example/cb"] }), + ), + ).toThrow(OAuthError); + expect(() => + parseClientMetadataDocument( + CLIENT_ID, + doc({ redirect_uris: ["myapp://cb"] }), + ), + ).toThrow(OAuthError); + expect(() => + parseClientMetadataDocument( + CLIENT_ID, + doc({ redirect_uris: ["https://user:pw@partner.example/cb"] }), + ), + ).toThrow(OAuthError); + expect(() => + parseClientMetadataDocument( + CLIENT_ID, + doc({ redirect_uris: ["https://partner.example/cb#x"] }), + ), + ).toThrow(OAuthError); + expect(() => + parseClientMetadataDocument(CLIENT_ID, { + ...doc(), + token_endpoint_auth_method: "client_secret_basic", + }), + ).toThrow(OAuthError); + expect(() => parseClientMetadataDocument(CLIENT_ID, "nope")).toThrow( + OAuthError, + ); + }); + + test("allows loopback http redirects for native partners", () => { + const parsed = parseClientMetadataDocument( + CLIENT_ID, + doc({ redirect_uris: ["http://127.0.0.1:9000/cb", REDIRECT] }), + ); + expect(parsed.redirect_uris.length).toBe(2); + }); +}); + +describe("authorize with a URL client id", () => { + test("advertises support in the server metadata", () => { + expect( + authorizationServerMetadata("https://api.example") + .client_id_metadata_document_supported, + ).toBe(true); + }); + + test("fetches the document, upserts the client and completes the flow", async () => { + await withTestTransaction(async (tx) => { + const { id } = await authorizeAs(CLIENT_ID, stub(doc()), tx); + const row = await tx + .select() + .from(oauthClient) + .where(eq(oauthClient.clientId, CLIENT_ID)) + .get(); + expect(row?.name).toBe("Partner App"); + expect(row?.redirectUris).toEqual([REDIRECT]); + + const pending = await getSession({ input: { id }, db: tx }); + expect(pending.clientId).toBe(CLIENT_ID); + + const { redirectUrl } = await decideSession({ + input: { id, approved: true, userId: ownerId, workspaceId: team.id }, + db: tx, + }); + const code = new URL(redirectUrl).searchParams.get("code") ?? ""; + const tokens = await exchangeCode({ + input: { + clientId: CLIENT_ID, + code, + codeVerifier: VERIFIER, + redirectUri: REDIRECT, + }, + db: tx, + }); + expect( + (await verifyAccessToken(tokens.access_token, { db: tx }))?.workspaceId, + ).toBe(team.id); + }); + }); + + test("re-fetches on every authorize so document changes take effect", async () => { + await withTestTransaction(async (tx) => { + await authorizeAs(CLIENT_ID, stub(doc()), tx); + const renamed = stub( + doc({ + client_name: "Partner App v2", + redirect_uris: [REDIRECT, "https://partner.example/cb2"], + }), + ); + await authorizeAs(CLIENT_ID, renamed, tx); + const row = await tx + .select() + .from(oauthClient) + .where(eq(oauthClient.clientId, CLIENT_ID)) + .get(); + expect(row?.name).toBe("Partner App v2"); + expect(row?.redirectUris).toEqual([ + REDIRECT, + "https://partner.example/cb2", + ]); + const count = await tx + .select() + .from(oauthClient) + .where(eq(oauthClient.clientId, CLIENT_ID)) + .all(); + expect(count.length).toBe(1); + }); + }); + + test("falls back to the hostname when the document has no client_name", async () => { + await withTestTransaction(async (tx) => { + await authorizeAs(CLIENT_ID, stub(doc({ client_name: undefined })), tx); + const row = await tx + .select() + .from(oauthClient) + .where(eq(oauthClient.clientId, CLIENT_ID)) + .get(); + expect(row?.name).toBe("partner.example"); + }); + }); + + test("a redirect_uri missing from the document is rejected without a redirect", async () => { + await withTestTransaction(async (tx) => { + const err = await authorizeAs(CLIENT_ID, stub(doc()), tx, { + redirect_uri: "https://partner.example/elsewhere", + }).catch((e) => e); + expect(err).toBeInstanceOf(OAuthError); + expect(err.oauthCode).toBe("invalid_redirect_uri"); + expect(err.redirectUri).toBeUndefined(); + }); + }); + + test("fetch failures surface as invalid_client", async () => { + await withTestTransaction(async (tx) => { + const err = await authorizeAs( + CLIENT_ID, + stub( + new OAuthError( + "invalid_client", + "Client metadata document responded with HTTP 404", + ), + ), + tx, + ).catch((e) => e); + expect(err.oauthCode).toBe("invalid_client"); + expect( + await tx + .select() + .from(oauthClient) + .where(eq(oauthClient.clientId, CLIENT_ID)) + .get(), + ).toBeUndefined(); + }); + }); + + test("an operator-revoked URL client stays blocked even with a valid document", async () => { + await withTestTransaction(async (tx) => { + await authorizeAs(CLIENT_ID, stub(doc()), tx); + await tx + .update(oauthClient) + .set({ revokedAt: new Date() }) + .where(eq(oauthClient.clientId, CLIENT_ID)); + let fetched = false; + const err = await authorizeAs( + CLIENT_ID, + async (id) => { + fetched = true; + return parseClientMetadataDocument(id, doc()); + }, + tx, + ).catch((e) => e); + expect(err.oauthCode).toBe("invalid_client"); + expect(fetched).toBe(false); + }); + }); + + test("the stub is never consulted for plain DCR client ids", async () => { + await withTestTransaction(async (tx) => { + let fetched = false; + const err = await authorizeAs( + "0123456789abcdef0123456789abcdef", + async (id) => { + fetched = true; + return parseClientMetadataDocument(id, doc()); + }, + tx, + ).catch((e) => e); + expect(err.oauthCode).toBe("invalid_client"); + expect(fetched).toBe(false); + }); + }); +}); diff --git a/packages/services/src/oauth/__tests__/crypto.test.ts b/packages/services/src/oauth/__tests__/crypto.test.ts new file mode 100644 index 000000000..9bc0f3714 --- /dev/null +++ b/packages/services/src/oauth/__tests__/crypto.test.ts @@ -0,0 +1,71 @@ +import { expect } from "@std/expect"; +import { describe, test } from "@std/testing/bdd"; + +import { + constantTimeEqual, + pkceChallenge, + randomBase64Url, + randomHex, + sha256Hex, + toBase64Url, + toHex, + verifyPkce, +} from "../crypto"; + +// RFC 7636 appendix B test vector. +const VERIFIER = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; +const CHALLENGE = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"; + +describe("oauth crypto", () => { + test("pkceChallenge matches the RFC 7636 vector", async () => { + expect(await pkceChallenge(VERIFIER)).toBe(CHALLENGE); + }); + + test("verifyPkce accepts the matching verifier", async () => { + expect(await verifyPkce(VERIFIER, CHALLENGE)).toBe(true); + }); + + test("verifyPkce rejects a wrong verifier", async () => { + expect(await verifyPkce(`${VERIFIER.slice(0, -1)}A`, CHALLENGE)).toBe( + false, + ); + }); + + test("verifyPkce rejects verifiers outside 43..128 chars", async () => { + expect(await verifyPkce("short", CHALLENGE)).toBe(false); + expect(await verifyPkce("a".repeat(129), CHALLENGE)).toBe(false); + }); + + test("verifyPkce rejects verifiers with characters outside the unreserved set", async () => { + expect(await verifyPkce(`${"a".repeat(42)}!`, CHALLENGE)).toBe(false); + }); + + test("sha256Hex matches a known digest", async () => { + expect(await sha256Hex("abc")).toBe( + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + ); + }); + + test("toBase64Url strips padding and uses the url alphabet", () => { + expect(toBase64Url(new Uint8Array([251, 255, 191]))).toBe("-_-_"); + expect(toBase64Url(new Uint8Array([1]))).toBe("AQ"); + }); + + test("toHex pads single-digit bytes", () => { + expect(toHex(new Uint8Array([0, 15, 255]))).toBe("000fff"); + }); + + test("random helpers produce distinct, well-formed values", () => { + const a = randomBase64Url(32); + const b = randomBase64Url(32); + expect(a).not.toBe(b); + expect(a).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(randomHex(16)).toMatch(/^[a-f0-9]{32}$/); + }); + + test("constantTimeEqual compares by value and rejects length mismatches", () => { + expect(constantTimeEqual("abc", "abc")).toBe(true); + expect(constantTimeEqual("abc", "abd")).toBe(false); + expect(constantTimeEqual("abc", "abcd")).toBe(false); + }); +}); diff --git a/packages/services/src/oauth/__tests__/metadata.test.ts b/packages/services/src/oauth/__tests__/metadata.test.ts new file mode 100644 index 000000000..b7f950b33 --- /dev/null +++ b/packages/services/src/oauth/__tests__/metadata.test.ts @@ -0,0 +1,53 @@ +import { expect } from "@std/expect"; +import { describe, test } from "@std/testing/bdd"; + +import { + authorizationServerMetadata, + mcpResource, + protectedResourceMetadata, + resourceMetadataUrl, +} from "../metadata"; + +const ISSUER = "https://api.example.test"; + +describe("oauth metadata", () => { + test("authorization server document derives every endpoint from the issuer", () => { + const doc = authorizationServerMetadata(ISSUER); + expect(doc.issuer).toBe(ISSUER); + for (const key of [ + "authorization_endpoint", + "token_endpoint", + "registration_endpoint", + "revocation_endpoint", + ] as const) { + expect(doc[key].startsWith(`${ISSUER}/oauth/`)).toBe(true); + } + expect(doc.response_types_supported).toEqual(["code"]); + expect(doc.grant_types_supported).toEqual([ + "authorization_code", + "refresh_token", + ]); + expect(doc.code_challenge_methods_supported).toEqual(["S256"]); + expect(doc.token_endpoint_auth_methods_supported).toEqual(["none"]); + expect(doc.scopes_supported).toEqual(["read", "write"]); + }); + + test("protected resource document names the MCP resource and the issuer", () => { + const doc = protectedResourceMetadata(ISSUER); + expect(doc.resource).toBe(`${ISSUER}/mcp`); + expect(doc.authorization_servers).toEqual([ISSUER]); + expect(doc.bearer_methods_supported).toEqual(["header"]); + expect(doc.scopes_supported).toEqual( + authorizationServerMetadata(ISSUER).scopes_supported, + ); + }); + + test("resource helpers agree with the documents", () => { + expect(mcpResource(ISSUER)).toBe( + protectedResourceMetadata(ISSUER).resource, + ); + expect(resourceMetadataUrl(ISSUER)).toBe( + `${ISSUER}/.well-known/oauth-protected-resource/mcp`, + ); + }); +}); diff --git a/packages/services/src/oauth/__tests__/oauth.test.ts b/packages/services/src/oauth/__tests__/oauth.test.ts new file mode 100644 index 000000000..5b11fdec2 --- /dev/null +++ b/packages/services/src/oauth/__tests__/oauth.test.ts @@ -0,0 +1,1492 @@ +import { and, eq } from "@openstatus/db"; +import { + oauthAuthorizationCode, + oauthClient, + oauthGrant, + oauthSession, + user, + usersToWorkspaces, +} from "@openstatus/db/src/schema"; +import { + addUserToWorkspace, + createUser, +} from "@openstatus/db/src/test/factories"; +import { expect } from "@std/expect"; +import { beforeAll, describe, test } from "@std/testing/bdd"; + +import { + createWorkspaceFixture, + expectAuditRow, + makeApiKeyCtx, + makeUserCtx, + readAuditLog, + withTestTransaction, +} from "../../../test/helpers"; +import type { DB, ServiceContext } from "../../context"; +import { + ForbiddenError, + NotFoundError, + PreconditionFailedError, + ValidationError, +} from "../../errors"; +import { removeMemberInWorkspace } from "../../member/internal"; +import type { Workspace } from "../../types"; +import { deleteAccount } from "../../user/delete"; +import { pkceChallenge } from "../crypto"; +import { OAuthError } from "../errors"; +import { + createSession, + decideSession, + exchangeCode, + getSession, + listGrants, + pruneExpired, + refreshGrant, + registerClient, + revokeGrant, + revokeGrantsForUser, + revokeToken, + verifyAccessToken, +} from "../index"; +import { revokeGrantRows } from "../internal"; + +const VERIFIER = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; +const REDIRECT = "http://localhost:8765/callback"; +const MINUTE = 60_000; + +let team: Workspace; +let ownerId: number; +let other: Workspace; + +beforeAll(async () => { + const fixture = await createWorkspaceFixture("team"); + team = fixture.workspace; + ownerId = fixture.userId; + other = (await createWorkspaceFixture("free")).workspace; +}); + +async function register(tx: DB, name = "Test Client") { + return registerClient({ + input: { client_name: name, redirect_uris: [REDIRECT] }, + db: tx, + }); +} + +async function start( + tx: DB, + clientId: string, + overrides: Record = {}, + now?: Date, +) { + return createSession({ + input: { + response_type: "code", + client_id: clientId, + redirect_uri: REDIRECT, + scope: "read write", + state: "state-123", + code_challenge: await pkceChallenge(VERIFIER), + code_challenge_method: "S256", + ...overrides, + }, + db: tx, + now, + }); +} + +function codeFrom(redirectUrl: string): string { + const code = new URL(redirectUrl).searchParams.get("code"); + if (!code) throw new Error(`no code in ${redirectUrl}`); + return code; +} + +async function consent( + tx: DB, + sessionId: string, + userId: number, + workspaceId: number, + scope?: ("read" | "write")[], + now?: Date, +) { + const { redirectUrl } = await decideSession({ + input: { id: sessionId, approved: true, userId, workspaceId, scope }, + db: tx, + now, + }); + return codeFrom(redirectUrl); +} + +async function mintGrant( + tx: DB, + args: { + clientId: string; + userId: number; + workspaceId: number; + scope?: ("read" | "write")[]; + now?: Date; + }, +) { + const { id } = await start(tx, args.clientId, {}, args.now); + const code = await consent( + tx, + id, + args.userId, + args.workspaceId, + args.scope, + args.now, + ); + return exchangeCode({ + input: { + clientId: args.clientId, + code, + codeVerifier: VERIFIER, + redirectUri: REDIRECT, + }, + db: tx, + now: args.now, + }); +} + +async function grantIdOf(tx: DB, accessToken: string): Promise { + const verified = await verifyAccessToken(accessToken, { db: tx }); + if (!verified) throw new Error("token did not verify"); + return verified.grantId; +} + +describe("registerClient", () => { + test("stores a public client with a hex client_id", async () => { + await withTestTransaction(async (tx) => { + const client = await register(tx, "Claude"); + expect(client.client_id).toMatch(/^[a-f0-9]{32}$/); + expect(client.client_name).toBe("Claude"); + expect(client.token_endpoint_auth_method).toBe("none"); + expect(client.grant_types).toEqual([ + "authorization_code", + "refresh_token", + ]); + expect(client.response_types).toEqual(["code"]); + expect(client.redirect_uris).toEqual([REDIRECT]); + expect(typeof client.client_id_issued_at).toBe("number"); + }); + }); + + test("defaults the name and dedupes redirect URIs", async () => { + await withTestTransaction(async (tx) => { + const client = await registerClient({ + input: { redirect_uris: [REDIRECT, REDIRECT] }, + db: tx, + }); + expect(client.client_name).toBe("MCP Client"); + expect(client.redirect_uris).toEqual([REDIRECT]); + }); + }); + + test("rejects redirect URIs outside the allowlist", async () => { + await withTestTransaction(async (tx) => { + await expect( + registerClient({ + input: { redirect_uris: [REDIRECT, "https://evil.example/cb"] }, + db: tx, + }), + ).rejects.toMatchObject({ oauthCode: "invalid_redirect_uri" }); + }); + }); + + test("rejects confidential client metadata", async () => { + await withTestTransaction(async (tx) => { + await expect( + registerClient({ + input: { + redirect_uris: [REDIRECT], + // Off-enum on purpose: the wire is untyped. + token_endpoint_auth_method: "client_secret_post" as never, + }, + db: tx, + }), + ).rejects.toMatchObject({ oauthCode: "invalid_client_metadata" }); + }); + }); +}); + +describe("createSession", () => { + test("persists the request and defaults scope to write", async () => { + await withTestTransaction(async (tx) => { + const client = await register(tx); + const { id } = await start(tx, client.client_id, { scope: undefined }); + const row = await tx + .select() + .from(oauthSession) + .where(eq(oauthSession.id, id)) + .get(); + expect(row?.scope).toEqual(["write"]); + expect(row?.state).toBe("state-123"); + expect(row?.codeChallengeMethod).toBe("S256"); + expect(row?.decidedAt).toBeNull(); + }); + }); + + test("unknown client and mismatched redirect_uri fail without a redirect", async () => { + await withTestTransaction(async (tx) => { + const client = await register(tx); + const unknown = await start(tx, "does-not-exist").catch((e) => e); + expect(unknown).toBeInstanceOf(OAuthError); + expect(unknown.oauthCode).toBe("invalid_client"); + expect(unknown.redirectUri).toBeUndefined(); + + const mismatch = await start(tx, client.client_id, { + redirect_uri: "http://localhost:8765/other", + }).catch((e) => e); + expect(mismatch.oauthCode).toBe("invalid_redirect_uri"); + expect(mismatch.redirectUri).toBeUndefined(); + }); + }); + + test("later validation failures carry the redirect target and state", async () => { + await withTestTransaction(async (tx) => { + const client = await register(tx); + const cases: [Record, string][] = [ + [{ response_type: "token" }, "unsupported_response_type"], + [{ code_challenge: undefined }, "invalid_request"], + [{ code_challenge_method: "plain" }, "invalid_request"], + [{ scope: "read admin" }, "invalid_scope"], + [ + { + resource: "https://elsewhere/mcp", + expectedResource: "http://localhost:3000/mcp", + }, + "invalid_target", + ], + ]; + for (const [overrides, code] of cases) { + const err = await start(tx, client.client_id, overrides).catch( + (e) => e, + ); + expect(err).toBeInstanceOf(OAuthError); + expect(err.oauthCode).toBe(code); + expect(err.redirectUri).toBe(REDIRECT); + expect(err.state).toBe("state-123"); + } + }); + }); + + test("accepts a matching resource parameter", async () => { + await withTestTransaction(async (tx) => { + const client = await register(tx); + const { id } = await start(tx, client.client_id, { + resource: "http://localhost:3000/mcp", + expectedResource: "http://localhost:3000/mcp", + }); + expect(id).toMatch(/^[A-Za-z0-9_-]{43}$/); + }); + }); +}); + +describe("getSession", () => { + test("returns client name and requested scope while pending", async () => { + await withTestTransaction(async (tx) => { + const client = await register(tx, "Cursor"); + const { id } = await start(tx, client.client_id, { scope: "read" }); + const pending = await getSession({ input: { id }, db: tx }); + expect(pending.clientName).toBe("Cursor"); + expect(pending.scope).toEqual(["read"]); + }); + }); + + test("throws NotFound for unknown ids", async () => { + await withTestTransaction(async (tx) => { + await expect( + getSession({ input: { id: "nope" }, db: tx }), + ).rejects.toBeInstanceOf(NotFoundError); + }); + }); + + test("throws once expired or decided", async () => { + await withTestTransaction(async (tx) => { + const client = await register(tx); + const expired = await start( + tx, + client.client_id, + {}, + new Date(Date.now() - 60 * MINUTE), + ); + await expect( + getSession({ input: { id: expired.id }, db: tx }), + ).rejects.toBeInstanceOf(PreconditionFailedError); + + const decided = await start(tx, client.client_id); + await decideSession({ + input: { id: decided.id, approved: false, userId: ownerId }, + db: tx, + }); + await expect( + getSession({ input: { id: decided.id }, db: tx }), + ).rejects.toBeInstanceOf(PreconditionFailedError); + }); + }); +}); + +describe("decideSession", () => { + test("deny redirects with access_denied and the state", async () => { + await withTestTransaction(async (tx) => { + const client = await register(tx); + const { id } = await start(tx, client.client_id); + const { redirectUrl } = await decideSession({ + input: { id, approved: false, userId: ownerId }, + db: tx, + }); + const url = new URL(redirectUrl); + expect(url.origin + url.pathname).toBe(REDIRECT); + expect(url.searchParams.get("error")).toBe("access_denied"); + expect(url.searchParams.get("state")).toBe("state-123"); + expect(url.searchParams.get("code")).toBeNull(); + }); + }); + + test("an empty state is echoed back, a missing one is omitted", async () => { + await withTestTransaction(async (tx) => { + const client = await register(tx); + const empty = await start(tx, client.client_id, { state: "" }); + const emptyUrl = new URL( + ( + await decideSession({ + input: { id: empty.id, approved: false, userId: ownerId }, + db: tx, + }) + ).redirectUrl, + ); + expect(emptyUrl.searchParams.has("state")).toBe(true); + expect(emptyUrl.searchParams.get("state")).toBe(""); + + const none = await start(tx, client.client_id, { state: undefined }); + const noneUrl = new URL( + ( + await decideSession({ + input: { id: none.id, approved: false, userId: ownerId }, + db: tx, + }) + ).redirectUrl, + ); + expect(noneUrl.searchParams.has("state")).toBe(false); + }); + }); + + test("an oversized state is rejected through the redirect, not a 400", async () => { + await withTestTransaction(async (tx) => { + const client = await register(tx); + const err = await start(tx, client.client_id, { + state: "x".repeat(1025), + }).catch((e) => e); + expect(err).toBeInstanceOf(OAuthError); + expect(err.oauthCode).toBe("invalid_request"); + expect(err.redirectUri).toBe(REDIRECT); + expect(err.state?.length).toBe(1024); + }); + }); + + test("approve mints a single-use code bound to the session", async () => { + await withTestTransaction(async (tx) => { + const client = await register(tx); + const { id } = await start(tx, client.client_id); + const code = await consent(tx, id, ownerId, team.id); + expect(code).toMatch(/^[A-Za-z0-9_-]{43}$/); + const rows = await tx + .select() + .from(oauthAuthorizationCode) + .where(eq(oauthAuthorizationCode.clientId, client.client_id)) + .all(); + expect(rows.length).toBe(1); + expect(rows[0].workspaceId).toBe(team.id); + expect(rows[0].userId).toBe(ownerId); + expect(rows[0].scope).toEqual(["write"]); + expect(rows[0].consumedAt).toBeNull(); + }); + }); + + test("rejects a workspace the user is not a member of", async () => { + await withTestTransaction(async (tx) => { + const client = await register(tx); + const { id } = await start(tx, client.client_id); + await expect( + decideSession({ + input: { id, approved: true, userId: ownerId, workspaceId: other.id }, + db: tx, + }), + ).rejects.toBeInstanceOf(ForbiddenError); + }); + }); + + test("requires a workspace to approve", async () => { + await withTestTransaction(async (tx) => { + const client = await register(tx); + const { id } = await start(tx, client.client_id); + await expect( + decideSession({ + input: { id, approved: true, userId: ownerId }, + db: tx, + }), + ).rejects.toBeInstanceOf(ValidationError); + }); + }); + + test("allows narrowing to read but never widening to write", async () => { + await withTestTransaction(async (tx) => { + const client = await register(tx); + const narrow = await start(tx, client.client_id, { scope: "read write" }); + const code = await consent(tx, narrow.id, ownerId, team.id, ["read"]); + const tokens = await exchangeCode({ + input: { + clientId: client.client_id, + code, + codeVerifier: VERIFIER, + redirectUri: REDIRECT, + }, + db: tx, + }); + expect(tokens.scope).toBe("read"); + const verified = await verifyAccessToken(tokens.access_token, { db: tx }); + expect(verified?.scopes).toEqual(["read"]); + + const widen = await start(tx, client.client_id, { scope: "read" }); + await expect( + decideSession({ + input: { + id: widen.id, + approved: true, + userId: ownerId, + workspaceId: team.id, + scope: ["write"], + }, + db: tx, + }), + ).rejects.toBeInstanceOf(ValidationError); + }); + }); + + test("a session can only be decided once", async () => { + await withTestTransaction(async (tx) => { + const client = await register(tx); + const { id } = await start(tx, client.client_id); + await consent(tx, id, ownerId, team.id); + await expect( + decideSession({ + input: { id, approved: true, userId: ownerId, workspaceId: team.id }, + db: tx, + }), + ).rejects.toBeInstanceOf(PreconditionFailedError); + }); + }); + + test("an expired session cannot be approved", async () => { + await withTestTransaction(async (tx) => { + const client = await register(tx); + const { id } = await start( + tx, + client.client_id, + {}, + new Date(Date.now() - 60 * MINUTE), + ); + await expect( + decideSession({ + input: { id, approved: true, userId: ownerId, workspaceId: team.id }, + db: tx, + }), + ).rejects.toBeInstanceOf(PreconditionFailedError); + }); + }); +}); + +describe("exchangeCode", () => { + test("returns a bearer pair, stores hashes only and emits oauth_grant.create", async () => { + await withTestTransaction(async (tx) => { + const client = await register(tx, "Claude"); + const tokens = await mintGrant(tx, { + clientId: client.client_id, + userId: ownerId, + workspaceId: team.id, + }); + expect(tokens.token_type).toBe("bearer"); + expect(tokens.expires_in).toBe(3600); + expect(tokens.access_token).toMatch(/^os_oat_[A-Za-z0-9_-]{43}$/); + expect(tokens.refresh_token).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(tokens.scope).toBe("write"); + + const grantId = await grantIdOf(tx, tokens.access_token); + const row = await tx + .select() + .from(oauthGrant) + .where(eq(oauthGrant.id, grantId)) + .get(); + expect(row?.accessTokenHash).not.toContain(tokens.access_token); + expect(row?.refreshTokenHash).not.toBe(tokens.refresh_token); + expect(row?.workspaceId).toBe(team.id); + expect(row?.userId).toBe(ownerId); + + await expectAuditRow({ + workspaceId: team.id, + action: "oauth_grant.create", + entityType: "oauth_grant", + entityId: grantId, + actorType: "user", + db: tx, + }); + const [audit] = await readAuditLog({ + workspaceId: team.id, + entityType: "oauth_grant", + entityId: grantId, + db: tx, + }); + expect(audit.metadata).toMatchObject({ + clientName: "Claude", + scope: ["write"], + }); + expect(audit.actorUserId).toBe(ownerId); + expect(JSON.stringify(audit.after)).not.toContain("Hash"); + }); + }); + + test("rejects a wrong PKCE verifier and leaves the code unconsumed", async () => { + await withTestTransaction(async (tx) => { + const client = await register(tx); + const { id } = await start(tx, client.client_id); + const code = await consent(tx, id, ownerId, team.id); + await expect( + exchangeCode({ + input: { + clientId: client.client_id, + code, + codeVerifier: `${VERIFIER.slice(0, -1)}A`, + redirectUri: REDIRECT, + }, + db: tx, + }), + ).rejects.toMatchObject({ oauthCode: "invalid_grant" }); + const row = await tx + .select() + .from(oauthAuthorizationCode) + .where(eq(oauthAuthorizationCode.clientId, client.client_id)) + .get(); + expect(row?.consumedAt).toBeNull(); + }); + }); + + test("rejects a mismatched redirect_uri, client and unknown code", async () => { + await withTestTransaction(async (tx) => { + const client = await register(tx); + const otherClient = await register(tx, "Other"); + const { id } = await start(tx, client.client_id); + const code = await consent(tx, id, ownerId, team.id); + await expect( + exchangeCode({ + input: { + clientId: client.client_id, + code, + codeVerifier: VERIFIER, + redirectUri: "http://localhost:8765/nope", + }, + db: tx, + }), + ).rejects.toMatchObject({ oauthCode: "invalid_grant" }); + await expect( + exchangeCode({ + input: { + clientId: otherClient.client_id, + code, + codeVerifier: VERIFIER, + redirectUri: REDIRECT, + }, + db: tx, + }), + ).rejects.toMatchObject({ oauthCode: "invalid_grant" }); + await expect( + exchangeCode({ + input: { + clientId: client.client_id, + code: "bogus", + codeVerifier: VERIFIER, + redirectUri: REDIRECT, + }, + db: tx, + }), + ).rejects.toMatchObject({ oauthCode: "invalid_grant" }); + await expect( + exchangeCode({ + input: { + clientId: "missing", + code, + codeVerifier: VERIFIER, + redirectUri: REDIRECT, + }, + db: tx, + }), + ).rejects.toMatchObject({ oauthCode: "invalid_client" }); + }); + }); + + test("rejects an expired code", async () => { + await withTestTransaction(async (tx) => { + const client = await register(tx); + const { id } = await start(tx, client.client_id); + const code = await consent(tx, id, ownerId, team.id); + await expect( + exchangeCode({ + input: { + clientId: client.client_id, + code, + codeVerifier: VERIFIER, + redirectUri: REDIRECT, + }, + db: tx, + now: new Date(Date.now() + 20 * MINUTE), + }), + ).rejects.toMatchObject({ oauthCode: "invalid_grant" }); + }); + }); + + test("replaying a consumed code revokes the grant it produced", async () => { + await withTestTransaction(async (tx) => { + const client = await register(tx); + const { id } = await start(tx, client.client_id); + const code = await consent(tx, id, ownerId, team.id); + const input = { + clientId: client.client_id, + code, + codeVerifier: VERIFIER, + redirectUri: REDIRECT, + }; + const tokens = await exchangeCode({ input, db: tx }); + const grantId = await grantIdOf(tx, tokens.access_token); + + await expect(exchangeCode({ input, db: tx })).rejects.toMatchObject({ + oauthCode: "invalid_grant", + }); + expect( + await verifyAccessToken(tokens.access_token, { db: tx }), + ).toBeNull(); + await expectAuditRow({ + workspaceId: team.id, + action: "oauth_grant.delete", + entityType: "oauth_grant", + entityId: grantId, + db: tx, + }); + const rows = await readAuditLog({ + workspaceId: team.id, + entityType: "oauth_grant", + entityId: grantId, + db: tx, + }); + expect( + rows.find((r) => r.action === "oauth_grant.delete")?.metadata, + ).toMatchObject({ reason: "code_reuse" }); + }); + }); + + test("re-consent revokes the user's previous grant for the same client, even in another workspace", async () => { + await withTestTransaction(async (tx) => { + const client = await register(tx); + const memberId = (await createUser({}, tx as never)).id; + await addUserToWorkspace(memberId, team.id, "member", tx as never); + await addUserToWorkspace(memberId, other.id, "member", tx as never); + + const first = await mintGrant(tx, { + clientId: client.client_id, + userId: memberId, + workspaceId: team.id, + }); + const firstId = await grantIdOf(tx, first.access_token); + const second = await mintGrant(tx, { + clientId: client.client_id, + userId: memberId, + workspaceId: other.id, + }); + + expect( + await verifyAccessToken(first.access_token, { db: tx }), + ).toBeNull(); + expect( + (await verifyAccessToken(second.access_token, { db: tx }))?.workspaceId, + ).toBe(other.id); + const rows = await readAuditLog({ + workspaceId: team.id, + entityType: "oauth_grant", + entityId: firstId, + db: tx, + }); + expect( + rows.find((r) => r.action === "oauth_grant.delete")?.metadata, + ).toMatchObject({ reason: "re_consent" }); + }); + }); + + test("a code minted before the member was removed no longer exchanges", async () => { + await withTestTransaction(async (tx) => { + const client = await register(tx); + const memberId = (await createUser({}, tx as never)).id; + await addUserToWorkspace(memberId, team.id, "member", tx as never); + const { id } = await start(tx, client.client_id); + const code = await consent(tx, id, memberId, team.id); + + await removeMemberInWorkspace({ + tx, + ctx: { ...makeUserCtx(team, { userId: ownerId }), db: tx }, + userId: memberId, + }); + + await expect( + exchangeCode({ + input: { + clientId: client.client_id, + code, + codeVerifier: VERIFIER, + redirectUri: REDIRECT, + }, + db: tx, + }), + ).rejects.toMatchObject({ oauthCode: "invalid_grant" }); + const grants = await tx + .select() + .from(oauthGrant) + .where(eq(oauthGrant.clientId, client.client_id)) + .all(); + expect(grants.length).toBe(0); + }); + }); + + test("a code minted before the account was deleted no longer exchanges", async () => { + await withTestTransaction(async (tx) => { + const client = await register(tx); + const { id } = await start(tx, client.client_id); + const code = await consent(tx, id, ownerId, team.id); + await tx + .update(user) + .set({ deletedAt: new Date() }) + .where(eq(user.id, ownerId)); + await expect( + exchangeCode({ + input: { + clientId: client.client_id, + code, + codeVerifier: VERIFIER, + redirectUri: REDIRECT, + }, + db: tx, + }), + ).rejects.toMatchObject({ oauthCode: "invalid_grant" }); + }); + }); + + test("grants for different clients coexist", async () => { + await withTestTransaction(async (tx) => { + const a = await register(tx, "A"); + const b = await register(tx, "B"); + const ta = await mintGrant(tx, { + clientId: a.client_id, + userId: ownerId, + workspaceId: team.id, + }); + const tb = await mintGrant(tx, { + clientId: b.client_id, + userId: ownerId, + workspaceId: team.id, + }); + expect( + await verifyAccessToken(ta.access_token, { db: tx }), + ).not.toBeNull(); + expect( + await verifyAccessToken(tb.access_token, { db: tx }), + ).not.toBeNull(); + }); + }); +}); + +describe("verifyAccessToken", () => { + test("resolves workspace, user and scopes and bumps last_used_at", async () => { + await withTestTransaction(async (tx) => { + const client = await register(tx); + const tokens = await mintGrant(tx, { + clientId: client.client_id, + userId: ownerId, + workspaceId: team.id, + }); + const verified = await verifyAccessToken(tokens.access_token, { db: tx }); + expect(verified).toMatchObject({ + workspaceId: team.id, + userId: ownerId, + scopes: ["write"], + }); + const row = await tx + .select() + .from(oauthGrant) + .where(eq(oauthGrant.id, verified?.grantId ?? -1)) + .get(); + expect(row?.lastUsedAt).not.toBeNull(); + }); + }); + + test("returns null for foreign strings, unknown, expired and revoked tokens", async () => { + await withTestTransaction(async (tx) => { + expect( + await verifyAccessToken("os_0123456789abcdef", { db: tx }), + ).toBeNull(); + expect(await verifyAccessToken("os_oat_unknown", { db: tx })).toBeNull(); + + const client = await register(tx); + const tokens = await mintGrant(tx, { + clientId: client.client_id, + userId: ownerId, + workspaceId: team.id, + }); + expect( + await verifyAccessToken(tokens.access_token, { + db: tx, + now: new Date(Date.now() + 2 * 60 * MINUTE), + }), + ).toBeNull(); + + const grantId = await grantIdOf(tx, tokens.access_token); + await revokeGrant({ + ctx: { ...makeUserCtx(team, { userId: ownerId }), db: tx }, + input: { grantId }, + }); + expect( + await verifyAccessToken(tokens.access_token, { db: tx }), + ).toBeNull(); + }); + }); +}); + +describe("refreshGrant", () => { + test("rotates both tokens in place and invalidates the old access token", async () => { + await withTestTransaction(async (tx) => { + const client = await register(tx); + const first = await mintGrant(tx, { + clientId: client.client_id, + userId: ownerId, + workspaceId: team.id, + }); + const grantId = await grantIdOf(tx, first.access_token); + const second = await refreshGrant({ + input: { + clientId: client.client_id, + refreshToken: first.refresh_token, + }, + db: tx, + }); + expect(second.access_token).not.toBe(first.access_token); + expect(second.refresh_token).not.toBe(first.refresh_token); + expect(second.scope).toBe("write"); + expect( + await verifyAccessToken(first.access_token, { db: tx }), + ).toBeNull(); + expect( + (await verifyAccessToken(second.access_token, { db: tx }))?.grantId, + ).toBe(grantId); + const count = await tx + .select() + .from(oauthGrant) + .where(eq(oauthGrant.clientId, client.client_id)) + .all(); + expect(count.length).toBe(1); + }); + }); + + test("extends the refresh expiry on rotation", async () => { + await withTestTransaction(async (tx) => { + const client = await register(tx); + const t0 = new Date(); + const first = await mintGrant(tx, { + clientId: client.client_id, + userId: ownerId, + workspaceId: team.id, + now: t0, + }); + const later = new Date(t0.getTime() + 10 * 24 * 60 * MINUTE); + await refreshGrant({ + input: { + clientId: client.client_id, + refreshToken: first.refresh_token, + }, + db: tx, + now: later, + }); + const row = await tx + .select() + .from(oauthGrant) + .where(eq(oauthGrant.clientId, client.client_id)) + .get(); + expect(row?.refreshTokenExpiresAt.getTime() ?? 0).toBeGreaterThan( + later.getTime() + 89 * 24 * 60 * MINUTE, + ); + }); + }); + + test("the rotated-out token is accepted inside the grace window and rotates again", async () => { + await withTestTransaction(async (tx) => { + const client = await register(tx); + const t0 = new Date(); + const r0 = await mintGrant(tx, { + clientId: client.client_id, + userId: ownerId, + workspaceId: team.id, + now: t0, + }); + const r1 = await refreshGrant({ + input: { clientId: client.client_id, refreshToken: r0.refresh_token }, + db: tx, + now: t0, + }); + const r2 = await refreshGrant({ + input: { clientId: client.client_id, refreshToken: r0.refresh_token }, + db: tx, + now: new Date(t0.getTime() + 10_000), + }); + expect(r2.refresh_token).not.toBe(r1.refresh_token); + expect( + await verifyAccessToken(r2.access_token, { db: tx }), + ).not.toBeNull(); + // r1 is now the rotated-out token: still inside its own window. + const r3 = await refreshGrant({ + input: { clientId: client.client_id, refreshToken: r1.refresh_token }, + db: tx, + now: new Date(t0.getTime() + 20_000), + }); + expect( + await verifyAccessToken(r3.access_token, { db: tx }), + ).not.toBeNull(); + }); + }); + + test("reuse after the grace window revokes the whole grant", async () => { + await withTestTransaction(async (tx) => { + const client = await register(tx); + const t0 = new Date(); + const r0 = await mintGrant(tx, { + clientId: client.client_id, + userId: ownerId, + workspaceId: team.id, + now: t0, + }); + const grantId = await grantIdOf(tx, r0.access_token); + const r1 = await refreshGrant({ + input: { clientId: client.client_id, refreshToken: r0.refresh_token }, + db: tx, + now: t0, + }); + await expect( + refreshGrant({ + input: { clientId: client.client_id, refreshToken: r0.refresh_token }, + db: tx, + now: new Date(t0.getTime() + 31_000), + }), + ).rejects.toMatchObject({ oauthCode: "invalid_grant" }); + expect(await verifyAccessToken(r1.access_token, { db: tx })).toBeNull(); + await expect( + refreshGrant({ + input: { clientId: client.client_id, refreshToken: r1.refresh_token }, + db: tx, + }), + ).rejects.toMatchObject({ oauthCode: "invalid_grant" }); + const rows = await readAuditLog({ + workspaceId: team.id, + entityType: "oauth_grant", + entityId: grantId, + db: tx, + }); + expect( + rows.find((r) => r.action === "oauth_grant.delete")?.metadata, + ).toMatchObject({ reason: "refresh_reuse" }); + }); + }); + + test("rejects unknown tokens, another client's token and revoked grants", async () => { + await withTestTransaction(async (tx) => { + const client = await register(tx); + const otherClient = await register(tx, "Other"); + const tokens = await mintGrant(tx, { + clientId: client.client_id, + userId: ownerId, + workspaceId: team.id, + }); + await expect( + refreshGrant({ + input: { clientId: client.client_id, refreshToken: "nope" }, + db: tx, + }), + ).rejects.toMatchObject({ oauthCode: "invalid_grant" }); + await expect( + refreshGrant({ + input: { + clientId: otherClient.client_id, + refreshToken: tokens.refresh_token, + }, + db: tx, + }), + ).rejects.toMatchObject({ oauthCode: "invalid_grant" }); + const grantId = await grantIdOf(tx, tokens.access_token); + await revokeGrant({ + ctx: { ...makeUserCtx(team, { userId: ownerId }), db: tx }, + input: { grantId }, + }); + await expect( + refreshGrant({ + input: { + clientId: client.client_id, + refreshToken: tokens.refresh_token, + }, + db: tx, + }), + ).rejects.toMatchObject({ oauthCode: "invalid_grant" }); + }); + }); + + test("rejects an expired refresh token", async () => { + await withTestTransaction(async (tx) => { + const client = await register(tx); + const tokens = await mintGrant(tx, { + clientId: client.client_id, + userId: ownerId, + workspaceId: team.id, + }); + await expect( + refreshGrant({ + input: { + clientId: client.client_id, + refreshToken: tokens.refresh_token, + }, + db: tx, + now: new Date(Date.now() + 91 * 24 * 60 * MINUTE), + }), + ).rejects.toMatchObject({ oauthCode: "invalid_grant" }); + expect( + await verifyAccessToken(tokens.access_token, { db: tx }), + ).toBeNull(); + }); + }); +}); + +describe("listGrants / revokeGrant", () => { + test("lists live grants with client and user, hiding revoked ones", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...makeUserCtx(team, { userId: ownerId }), db: tx }; + const client = await register(tx, "Claude Desktop"); + const tokens = await mintGrant(tx, { + clientId: client.client_id, + userId: ownerId, + workspaceId: team.id, + scope: ["read"], + }); + const grantId = await grantIdOf(tx, tokens.access_token); + + const before = await listGrants({ ctx }); + const found = before.find((g) => g.id === grantId); + expect(found).toMatchObject({ + clientName: "Claude Desktop", + scope: ["read"], + userId: ownerId, + }); + expect(found?.user?.id).toBe(ownerId); + expect(Object.keys(found ?? {})).not.toContain("accessTokenHash"); + + await revokeGrant({ ctx, input: { grantId } }); + const after = await listGrants({ ctx }); + expect(after.find((g) => g.id === grantId)).toBeUndefined(); + await expectAuditRow({ + workspaceId: team.id, + action: "oauth_grant.delete", + entityType: "oauth_grant", + entityId: grantId, + actorType: "user", + db: tx, + }); + }); + }); + + test("does not list another workspace's grants", async () => { + await withTestTransaction(async (tx) => { + const client = await register(tx); + const tokens = await mintGrant(tx, { + clientId: client.client_id, + userId: ownerId, + workspaceId: team.id, + }); + const grantId = await grantIdOf(tx, tokens.access_token); + const otherCtx = { ...makeUserCtx(other, { userId: ownerId }), db: tx }; + expect( + (await listGrants({ ctx: otherCtx })).find((g) => g.id === grantId), + ).toBeUndefined(); + await expect( + revokeGrant({ ctx: otherCtx, input: { grantId } }), + ).rejects.toBeInstanceOf(NotFoundError); + }); + }); + + test("members revoke their own grants but not others'; admins and owners revoke any", async () => { + await withTestTransaction(async (tx) => { + const client = await register(tx); + const memberId = (await createUser({}, tx as never)).id; + const adminId = (await createUser({}, tx as never)).id; + await addUserToWorkspace(memberId, team.id, "member", tx as never); + await addUserToWorkspace(adminId, team.id, "member", tx as never); + await tx + .update(usersToWorkspaces) + .set({ role: "admin" }) + .where( + and( + eq(usersToWorkspaces.userId, adminId), + eq(usersToWorkspaces.workspaceId, team.id), + ), + ); + + const own = await mintGrant(tx, { + clientId: client.client_id, + userId: memberId, + workspaceId: team.id, + }); + const ownersGrant = await mintGrant(tx, { + clientId: (await register(tx, "B")).client_id, + userId: ownerId, + workspaceId: team.id, + }); + const ownId = await grantIdOf(tx, own.access_token); + const ownersId = await grantIdOf(tx, ownersGrant.access_token); + + const memberCtx = { ...makeUserCtx(team, { userId: memberId }), db: tx }; + await expect( + revokeGrant({ ctx: memberCtx, input: { grantId: ownersId } }), + ).rejects.toBeInstanceOf(ForbiddenError); + await revokeGrant({ ctx: memberCtx, input: { grantId: ownId } }); + expect(await verifyAccessToken(own.access_token, { db: tx })).toBeNull(); + + const adminCtx = { ...makeUserCtx(team, { userId: adminId }), db: tx }; + await revokeGrant({ ctx: adminCtx, input: { grantId: ownersId } }); + expect( + await verifyAccessToken(ownersGrant.access_token, { db: tx }), + ).toBeNull(); + }); + }); + + test("revoking twice reports not found", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...makeUserCtx(team, { userId: ownerId }), db: tx }; + const client = await register(tx); + const tokens = await mintGrant(tx, { + clientId: client.client_id, + userId: ownerId, + workspaceId: team.id, + }); + const grantId = await grantIdOf(tx, tokens.access_token); + await revokeGrant({ ctx, input: { grantId } }); + await expect( + revokeGrant({ ctx, input: { grantId } }), + ).rejects.toBeInstanceOf(NotFoundError); + }); + }); + + test("rejects read-only actor", async () => { + await withTestTransaction(async (tx) => { + const readOnlyCtx: ServiceContext = { + ...makeApiKeyCtx(team, { + keyId: "k-read", + userId: ownerId, + scopes: ["read"], + }), + db: tx, + }; + await expect( + revokeGrant({ ctx: readOnlyCtx, input: { grantId: 1 } }), + ).rejects.toBeInstanceOf(ForbiddenError); + }); + }); +}); + +describe("revokeToken (RFC 7009)", () => { + test("either token revokes the grant; unknown tokens are a no-op", async () => { + await withTestTransaction(async (tx) => { + const client = await register(tx); + const a = await mintGrant(tx, { + clientId: client.client_id, + userId: ownerId, + workspaceId: team.id, + }); + await revokeToken({ + input: { clientId: client.client_id, token: a.access_token }, + db: tx, + }); + expect(await verifyAccessToken(a.access_token, { db: tx })).toBeNull(); + + const b = await mintGrant(tx, { + clientId: client.client_id, + userId: ownerId, + workspaceId: team.id, + }); + await revokeToken({ + input: { clientId: client.client_id, token: b.refresh_token }, + db: tx, + }); + expect(await verifyAccessToken(b.access_token, { db: tx })).toBeNull(); + + await revokeToken({ + input: { clientId: client.client_id, token: "unknown" }, + db: tx, + }); + }); + }); + + test("another client cannot revoke the grant", async () => { + await withTestTransaction(async (tx) => { + const client = await register(tx); + const otherClient = await register(tx, "Other"); + const a = await mintGrant(tx, { + clientId: client.client_id, + userId: ownerId, + workspaceId: team.id, + }); + await revokeToken({ + input: { clientId: otherClient.client_id, token: a.access_token }, + db: tx, + }); + expect( + await verifyAccessToken(a.access_token, { db: tx }), + ).not.toBeNull(); + }); + }); +}); + +describe("revokeGrantRows", () => { + test("an already revoked grant is skipped and not audited twice", async () => { + await withTestTransaction(async (tx) => { + const client = await register(tx); + const tokens = await mintGrant(tx, { + clientId: client.client_id, + userId: ownerId, + workspaceId: team.id, + }); + const grantId = await grantIdOf(tx, tokens.access_token); + const ctx = { ...makeUserCtx(team, { userId: ownerId }), db: tx }; + const stale = await tx + .select() + .from(oauthGrant) + .where(eq(oauthGrant.id, grantId)) + .get(); + if (!stale) throw new Error("grant missing"); + expect(await revokeGrantRows(tx, ctx, [stale], "manual")).toBe(1); + // Same pre-revocation snapshot presented again, as a racing caller would. + expect(await revokeGrantRows(tx, ctx, [stale], "manual")).toBe(0); + const rows = await readAuditLog({ + workspaceId: team.id, + entityType: "oauth_grant", + entityId: grantId, + db: tx, + }); + expect(rows.filter((r) => r.action === "oauth_grant.delete").length).toBe( + 1, + ); + }); + }); +}); + +describe("lifecycle hooks", () => { + test("removing a member revokes their grants in that workspace only", async () => { + await withTestTransaction(async (tx) => { + const client = await register(tx); + const memberId = (await createUser({}, tx as never)).id; + await addUserToWorkspace(memberId, team.id, "member", tx as never); + await addUserToWorkspace(memberId, other.id, "member", tx as never); + const inTeam = await mintGrant(tx, { + clientId: client.client_id, + userId: memberId, + workspaceId: team.id, + }); + const inOther = await mintGrant(tx, { + clientId: (await register(tx, "B")).client_id, + userId: memberId, + workspaceId: other.id, + }); + const teamGrantId = await grantIdOf(tx, inTeam.access_token); + + const ctx = { ...makeUserCtx(team, { userId: ownerId }), db: tx }; + await removeMemberInWorkspace({ tx, ctx, userId: memberId }); + + expect( + await verifyAccessToken(inTeam.access_token, { db: tx }), + ).toBeNull(); + expect( + await verifyAccessToken(inOther.access_token, { db: tx }), + ).not.toBeNull(); + const rows = await readAuditLog({ + workspaceId: team.id, + entityType: "oauth_grant", + entityId: teamGrantId, + db: tx, + }); + const del = rows.find((r) => r.action === "oauth_grant.delete"); + expect(del?.metadata).toMatchObject({ reason: "member_removed" }); + expect(del?.actorUserId).toBe(ownerId); + }); + }); + + test("revokeGrantsForUser without a workspace revokes everywhere", async () => { + await withTestTransaction(async (tx) => { + const memberId = (await createUser({}, tx as never)).id; + await addUserToWorkspace(memberId, team.id, "member", tx as never); + await addUserToWorkspace(memberId, other.id, "member", tx as never); + const a = await mintGrant(tx, { + clientId: (await register(tx, "A")).client_id, + userId: memberId, + workspaceId: team.id, + }); + const b = await mintGrant(tx, { + clientId: (await register(tx, "B")).client_id, + userId: memberId, + workspaceId: other.id, + }); + const ctx = { ...makeUserCtx(team, { userId: memberId }), db: tx }; + const revoked = await revokeGrantsForUser({ tx, ctx, userId: memberId }); + expect(revoked).toBe(2); + expect(await verifyAccessToken(a.access_token, { db: tx })).toBeNull(); + expect(await verifyAccessToken(b.access_token, { db: tx })).toBeNull(); + const otherGrants = await tx + .select({ id: oauthGrant.id }) + .from(oauthGrant) + .where(eq(oauthGrant.workspaceId, other.id)) + .all(); + const otherGrantId = otherGrants[otherGrants.length - 1]?.id ?? -1; + await expectAuditRow({ + workspaceId: other.id, + action: "oauth_grant.delete", + entityType: "oauth_grant", + entityId: otherGrantId, + db: tx, + }); + }); + }); + + test("account deletion revokes every grant of the user", async () => { + await withTestTransaction(async (tx) => { + const memberId = (await createUser({}, tx as never)).id; + await addUserToWorkspace(memberId, team.id, "member", tx as never); + await addUserToWorkspace(memberId, other.id, "member", tx as never); + const a = await mintGrant(tx, { + clientId: (await register(tx, "A")).client_id, + userId: memberId, + workspaceId: team.id, + }); + const b = await mintGrant(tx, { + clientId: (await register(tx, "B")).client_id, + userId: memberId, + workspaceId: other.id, + }); + const grantId = await grantIdOf(tx, a.access_token); + + await deleteAccount({ + ctx: { ...makeUserCtx(team, { userId: memberId }), db: tx }, + }); + + expect(await verifyAccessToken(a.access_token, { db: tx })).toBeNull(); + expect(await verifyAccessToken(b.access_token, { db: tx })).toBeNull(); + const rows = await readAuditLog({ + workspaceId: team.id, + entityType: "oauth_grant", + entityId: grantId, + db: tx, + }); + expect( + rows.find((r) => r.action === "oauth_grant.delete")?.metadata, + ).toMatchObject({ reason: "account_deleted" }); + }); + }); +}); + +describe("pruneExpired", () => { + test("drops expired sessions and codes, expires stale grants and orphan clients", async () => { + await withTestTransaction(async (tx) => { + const now = new Date(); + const past = new Date(now.getTime() - 60 * MINUTE); + const live = await register(tx, "Live"); + const orphan = await register(tx, "Orphan"); + const fresh = await register(tx, "Fresh"); + await tx + .update(oauthClient) + .set({ createdAt: new Date(now.getTime() - 8 * 24 * 60 * MINUTE) }) + .where(eq(oauthClient.clientId, orphan.client_id)); + await tx + .update(oauthClient) + .set({ createdAt: new Date(now.getTime() - 8 * 24 * 60 * MINUTE) }) + .where(eq(oauthClient.clientId, live.client_id)); + + // expired session + expired code on the orphan, live session on fresh + const expiredSession = await start(tx, orphan.client_id, {}, past); + const liveSession = await start(tx, fresh.client_id); + const codeSession = await start(tx, live.client_id, {}, past); + await decideSession({ + input: { + id: codeSession.id, + approved: true, + userId: ownerId, + workspaceId: team.id, + }, + db: tx, + now: past, + }); + + // grant whose refresh token expired keeps the client alive + const tokens = await mintGrant(tx, { + clientId: live.client_id, + userId: ownerId, + workspaceId: team.id, + now: new Date(now.getTime() - 100 * 24 * 60 * MINUTE), + }); + + const result = await pruneExpired({ ctx: { db: tx }, now }); + expect(result.sessions).toBeGreaterThanOrEqual(1); + expect(result.codes).toBeGreaterThanOrEqual(1); + expect(result.grants).toBeGreaterThanOrEqual(1); + expect(result.clients).toBeGreaterThanOrEqual(1); + + expect( + await tx + .select() + .from(oauthSession) + .where(eq(oauthSession.id, expiredSession.id)) + .get(), + ).toBeUndefined(); + expect( + await tx + .select() + .from(oauthSession) + .where(eq(oauthSession.id, liveSession.id)) + .get(), + ).toBeDefined(); + expect( + await tx + .select() + .from(oauthClient) + .where(eq(oauthClient.clientId, orphan.client_id)) + .get(), + ).toBeUndefined(); + expect( + await tx + .select() + .from(oauthClient) + .where(eq(oauthClient.clientId, live.client_id)) + .get(), + ).toBeDefined(); + expect( + await tx + .select() + .from(oauthClient) + .where(eq(oauthClient.clientId, fresh.client_id)) + .get(), + ).toBeDefined(); + expect( + await verifyAccessToken(tokens.access_token, { db: tx, now }), + ).toBeNull(); + const grant = await tx + .select() + .from(oauthGrant) + .where(eq(oauthGrant.clientId, live.client_id)) + .get(); + expect(grant?.revokedAt).not.toBeNull(); + }); + }); +}); diff --git a/packages/services/src/oauth/__tests__/redirect-allowlist.test.ts b/packages/services/src/oauth/__tests__/redirect-allowlist.test.ts new file mode 100644 index 000000000..07b79b43d --- /dev/null +++ b/packages/services/src/oauth/__tests__/redirect-allowlist.test.ts @@ -0,0 +1,211 @@ +import { expect } from "@std/expect"; +import { describe, test } from "@std/testing/bdd"; + +import { + isAllowedRedirectUri, + matchesRegisteredRedirectUri, +} from "../redirect-allowlist"; + +describe("isAllowedRedirectUri", () => { + test("allows loopback on http and https", () => { + expect(isAllowedRedirectUri("http://localhost:3000/callback")).toBe(true); + expect(isAllowedRedirectUri("http://127.0.0.1:53422/cb")).toBe(true); + expect(isAllowedRedirectUri("https://localhost/cb")).toBe(true); + expect(isAllowedRedirectUri("http://[::1]:8080/cb")).toBe(true); + }); + + test("allows https on allowlisted hosts and their subdomains", () => { + expect( + isAllowedRedirectUri("https://claude.ai/api/mcp/auth_callback"), + ).toBe(true); + expect(isAllowedRedirectUri("https://app.openstatus.dev/cb")).toBe(true); + expect( + isAllowedRedirectUri( + "https://chatgpt.com/connector_platform_oauth_redirect", + ), + ).toBe(true); + expect(isAllowedRedirectUri("https://cursor.com/api/auth/callback")).toBe( + true, + ); + }); + + test("rejects http on allowlisted hosts", () => { + expect(isAllowedRedirectUri("http://claude.ai/cb")).toBe(false); + }); + + test("rejects lookalike hosts", () => { + expect(isAllowedRedirectUri("https://claude.ai.evil.com/cb")).toBe(false); + expect(isAllowedRedirectUri("https://evilclaude.ai/cb")).toBe(false); + expect(isAllowedRedirectUri("https://openstatus.dev.attacker.io/cb")).toBe( + false, + ); + }); + + test("rejects unknown hosts", () => { + expect(isAllowedRedirectUri("https://example.com/cb")).toBe(false); + expect(isAllowedRedirectUri("https://localhost.example.com/cb")).toBe( + false, + ); + }); + + test("allows supported app schemes regardless of host", () => { + expect( + isAllowedRedirectUri( + "cursor://anysphere.cursor-retrieval/oauth/callback", + ), + ).toBe(true); + expect(isAllowedRedirectUri("vscode://ms-vscode.mcp/authorize")).toBe(true); + expect( + isAllowedRedirectUri("vscode-insiders://ms-vscode.mcp/authorize"), + ).toBe(true); + }); + + test("rejects other custom schemes", () => { + expect(isAllowedRedirectUri("myapp://callback")).toBe(false); + expect(isAllowedRedirectUri("javascript:alert(1)")).toBe(false); + }); + + test("rejects fragments and unparsable input", () => { + expect(isAllowedRedirectUri("https://claude.ai/cb#frag")).toBe(false); + expect(isAllowedRedirectUri("https://claude.ai/cb#")).toBe(false); + expect(isAllowedRedirectUri("https://user:pw@claude.ai/cb")).toBe(false); + expect(isAllowedRedirectUri("https://user@app.openstatus.dev/cb")).toBe( + false, + ); + expect(isAllowedRedirectUri("http://user@localhost:3000/cb")).toBe(false); + }); + + test("rejects empty userinfo that URL parsing drops", () => { + expect(isAllowedRedirectUri("https://@claude.ai/cb")).toBe(false); + expect(isAllowedRedirectUri("https://:@claude.ai/cb")).toBe(false); + expect(isAllowedRedirectUri("http://@localhost:3000/cb")).toBe(false); + expect(isAllowedRedirectUri("cursor://@callback")).toBe(false); + expect(isAllowedRedirectUri("https:\n//@claude.ai/cb")).toBe(false); + expect(isAllowedRedirectUri("not a url")).toBe(false); + expect(isAllowedRedirectUri("")).toBe(false); + }); +}); + +describe("matchesRegisteredRedirectUri", () => { + const registered = [ + "http://localhost/callback", + "http://127.0.0.1/callback", + "https://claude.ai/api/mcp/auth_callback", + ]; + + test("ignores the port on loopback (RFC 8252 §7.3)", () => { + expect( + matchesRegisteredRedirectUri( + registered, + "http://localhost:53495/callback", + ), + ).toBe(true); + expect( + matchesRegisteredRedirectUri( + registered, + "http://127.0.0.1:8080/callback", + ), + ).toBe(true); + expect( + matchesRegisteredRedirectUri( + ["http://localhost:3000/cb"], + "http://localhost/cb", + ), + ).toBe(true); + }); + + test("still requires scheme, host, path and query to match on loopback", () => { + expect( + matchesRegisteredRedirectUri(registered, "https://localhost:1/callback"), + ).toBe(false); + expect( + matchesRegisteredRedirectUri(registered, "http://[::1]:1/callback"), + ).toBe(false); + expect( + matchesRegisteredRedirectUri(registered, "http://localhost:1/other"), + ).toBe(false); + expect( + matchesRegisteredRedirectUri( + registered, + "http://localhost:1/callback?x=1", + ), + ).toBe(false); + }); + + test("rejects userinfo even when the registered entry carries it", () => { + const legacy = ["https://user:pw@claude.ai/cb", "http://user@localhost/cb"]; + expect( + matchesRegisteredRedirectUri(legacy, "https://user:pw@claude.ai/cb"), + ).toBe(false); + expect( + matchesRegisteredRedirectUri(legacy, "http://user@localhost:1/cb"), + ).toBe(false); + expect( + matchesRegisteredRedirectUri( + ["https://@claude.ai/cb"], + "https://@claude.ai/cb", + ), + ).toBe(false); + }); + + test("a legacy loopback entry with userinfo never widens the match", () => { + expect( + matchesRegisteredRedirectUri( + ["http://user:pw@localhost/cb"], + "http://localhost:1/cb", + ), + ).toBe(false); + expect( + matchesRegisteredRedirectUri( + ["http://@localhost/cb"], + "http://localhost:1/cb", + ), + ).toBe(false); + expect( + matchesRegisteredRedirectUri( + ["http://localhost/cb#x"], + "http://localhost:1/cb", + ), + ).toBe(false); + }); + + test("rejects fragments and userinfo on loopback", () => { + expect( + matchesRegisteredRedirectUri( + registered, + "http://localhost:1/callback#frag", + ), + ).toBe(false); + expect( + matchesRegisteredRedirectUri(registered, "http://localhost:1/callback#"), + ).toBe(false); + expect( + matchesRegisteredRedirectUri( + registered, + "http://user:pass@localhost:1/callback", + ), + ).toBe(false); + expect( + matchesRegisteredRedirectUri( + registered, + "http://user@127.0.0.1:1/callback", + ), + ).toBe(false); + }); + + test("requires an exact match for non-loopback hosts", () => { + expect( + matchesRegisteredRedirectUri( + registered, + "https://claude.ai/api/mcp/auth_callback", + ), + ).toBe(true); + expect( + matchesRegisteredRedirectUri( + registered, + "https://claude.ai:8443/api/mcp/auth_callback", + ), + ).toBe(false); + expect(matchesRegisteredRedirectUri(registered, "not a url")).toBe(false); + }); +}); diff --git a/packages/services/src/oauth/__tests__/schemas.test.ts b/packages/services/src/oauth/__tests__/schemas.test.ts new file mode 100644 index 000000000..e1e5971fd --- /dev/null +++ b/packages/services/src/oauth/__tests__/schemas.test.ts @@ -0,0 +1,87 @@ +import { expect } from "@std/expect"; +import { describe, test } from "@std/testing/bdd"; + +import { + RegisterClientInput, + formatScope, + normalizeScopes, + parseScopeParam, +} from "../schemas"; + +describe("parseScopeParam", () => { + test("defaults to write when absent or blank", () => { + expect(parseScopeParam(undefined)).toEqual({ + scopes: ["write"], + invalid: [], + }); + expect(parseScopeParam("")).toEqual({ scopes: ["write"], invalid: [] }); + expect(parseScopeParam(" ")).toEqual({ scopes: ["write"], invalid: [] }); + }); + + test("collapses read+write to write", () => { + expect(parseScopeParam("read write").scopes).toEqual(["write"]); + expect(parseScopeParam("write").scopes).toEqual(["write"]); + }); + + test("keeps read alone", () => { + expect(parseScopeParam("read").scopes).toEqual(["read"]); + }); + + test("reports unknown scopes without partially accepting", () => { + expect(parseScopeParam("read admin")).toEqual({ + scopes: [], + invalid: ["admin"], + }); + expect(parseScopeParam("*").invalid).toEqual(["*"]); + }); +}); + +describe("normalizeScopes / formatScope", () => { + test("write implies read", () => { + expect(normalizeScopes(["read", "write"])).toEqual(["write"]); + expect(formatScope(["write"])).toBe("write"); + expect(formatScope(["read"])).toBe("read"); + }); + + test("folds '*' into write defensively", () => { + expect(normalizeScopes(["*"])).toEqual(["write"]); + }); +}); + +describe("RegisterClientInput", () => { + test("accepts a minimal public client", () => { + const parsed = RegisterClientInput.safeParse({ + redirect_uris: ["http://localhost:1234/cb"], + }); + expect(parsed.success).toBe(true); + }); + + test("rejects confidential auth methods", () => { + const parsed = RegisterClientInput.safeParse({ + redirect_uris: ["http://localhost:1234/cb"], + token_endpoint_auth_method: "client_secret_basic", + }); + expect(parsed.success).toBe(false); + }); + + test("rejects unsupported grant and response types", () => { + expect( + RegisterClientInput.safeParse({ + redirect_uris: ["http://localhost:1234/cb"], + grant_types: ["client_credentials"], + }).success, + ).toBe(false); + expect( + RegisterClientInput.safeParse({ + redirect_uris: ["http://localhost:1234/cb"], + response_types: ["token"], + }).success, + ).toBe(false); + }); + + test("rejects empty redirect_uris", () => { + expect(RegisterClientInput.safeParse({ redirect_uris: [] }).success).toBe( + false, + ); + }); +}); diff --git a/packages/services/src/oauth/cimd.ts b/packages/services/src/oauth/cimd.ts new file mode 100644 index 000000000..c402fed4d --- /dev/null +++ b/packages/services/src/oauth/cimd.ts @@ -0,0 +1,229 @@ +import { eq } from "@openstatus/db"; +import { + type OAuthClient, + oauthClient, + selectOAuthClientSchema, +} from "@openstatus/db/src/schema"; +import { assertSafeUrlSync } from "@openstatus/utils"; +import { z } from "zod"; + +import type { DB } from "../context"; +import { InternalServiceError } from "../errors"; +import { OAuthError } from "./errors"; + +/** + * Client ID Metadata Documents: a client identifies itself with an HTTPS URL + * that serves its own metadata. Domain ownership is the trust anchor, so no + * registration and no allowlist entry are needed. + */ + +export const CIMD_FETCH_TIMEOUT_MS = 5_000; +export const CIMD_MAX_BYTES = 64 * 1024; + +// Carrier-grade NAT; the shared helper does not cover it. +const CGNAT_V4 = /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./; + +/** + * HTTPS, non-root path, public hostname. Layers CIMD rules on the repo's + * SSRF blocklist. Hostname checks cannot see what DNS resolves to, so a + * rebound name still reaches `fetch`. That is accepted because the fetch is + * HTTPS with certificate verification: an internal host cannot present a + * certificate for the attacker's name, so its response is never read. + * The residual is a timing oracle on whether an internal port answers. + */ +export function isUrlClientId(clientId: string): boolean { + if (clientId.includes("#")) return false; + let url: URL; + try { + url = new URL(clientId); + } catch { + return false; + } + if (url.protocol !== "https:") return false; + if (url.pathname === "/" || url.username || url.password) return false; + + try { + assertSafeUrlSync(clientId); + } catch { + return false; + } + + // Trailing dot is the same name to DNS; strip it before suffix checks. + const host = url.hostname.toLowerCase().replace(/\.$/, ""); + if (host.startsWith("[")) return false; + if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return !CGNAT_V4.test(host); + if ( + host === "localhost" || + host.endsWith(".localhost") || + host.endsWith(".local") || + host.endsWith(".internal") || + host.endsWith(".arpa") || + !host.includes(".") + ) { + return false; + } + return true; +} + +const redirectUriSchema = z.string().refine((value) => { + if (value.includes("#")) return false; + try { + const url = new URL(value); + if (url.username || url.password) return false; + const host = url.hostname.toLowerCase(); + const loopback = + host === "localhost" || host === "127.0.0.1" || host === "[::1]"; + return url.protocol === "https:" || (loopback && url.protocol === "http:"); + } catch { + return false; + } +}, "redirect_uris must be https or loopback, without fragment or credentials"); + +export const ClientMetadataDocument = z.object({ + client_id: z.string().url(), + client_name: z.string().trim().min(1).max(120).optional(), + redirect_uris: z.array(redirectUriSchema).min(1).max(20), + token_endpoint_auth_method: z.literal("none").optional(), +}); +export type ClientMetadataDocument = z.infer; + +/** Validates the body and pins `client_id` to the URL it was fetched from. */ +export function parseClientMetadataDocument( + clientId: string, + body: unknown, +): ClientMetadataDocument { + const parsed = ClientMetadataDocument.safeParse(body); + if (!parsed.success) { + throw new OAuthError( + "invalid_client", + `Client metadata document is invalid: ${parsed.error.issues + .map((i) => `${i.path.join(".") || "document"} ${i.message}`) + .join("; ")}`, + ); + } + if (parsed.data.client_id !== clientId) { + throw new OAuthError( + "invalid_client", + "Client metadata document client_id does not match its URL", + ); + } + return parsed.data; +} + +export type ClientMetadataFetcher = ( + clientId: string, +) => Promise; + +/** Reads at most `limit` bytes; an oversized or chunked body is cut off, not buffered. */ +async function readBounded(res: Response, limit: number): Promise { + const reader = res.body?.getReader(); + if (!reader) return ""; + const chunks: Uint8Array[] = []; + let received = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + received += value.byteLength; + if (received > limit) { + await reader.cancel(); + throw new OAuthError( + "invalid_client", + "Client metadata document is too large", + ); + } + chunks.push(value); + } + const bytes = new Uint8Array(received); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return new TextDecoder().decode(bytes); +} + +/** Default fetcher: no redirects, bounded time and size, JSON only. */ +export async function fetchClientMetadataDocument( + clientId: string, +): Promise { + if (!isUrlClientId(clientId)) { + throw new OAuthError( + "invalid_client", + "client_id is not a valid metadata URL", + ); + } + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), CIMD_FETCH_TIMEOUT_MS); + try { + const res = await fetch(clientId, { + headers: { Accept: "application/json" }, + redirect: "error", + signal: controller.signal, + }); + if (!res.ok) { + throw new OAuthError( + "invalid_client", + `Client metadata document responded with HTTP ${res.status}`, + ); + } + const text = await readBounded(res, CIMD_MAX_BYTES); + let body: unknown; + try { + body = JSON.parse(text); + } catch { + throw new OAuthError( + "invalid_client", + "Client metadata document is not JSON", + ); + } + return parseClientMetadataDocument(clientId, body); + } catch (err) { + if (err instanceof OAuthError) throw err; + // The raw error distinguishes refused / bad certificate / timeout, which + // would let an unauthenticated caller probe internal ports. Log it only. + console.warn( + `[oauth/cimd] fetch failed for ${clientId}: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + throw new OAuthError( + "invalid_client", + "Client metadata document could not be fetched", + ); + } finally { + clearTimeout(timer); + } +} + +/** + * Fetch the document and upsert the client row keyed by its URL. Runs once per + * authorize request, so no separate cache; a row an operator revoked stays + * revoked no matter what the document says. + */ +export async function resolveUrlClient( + db: DB, + clientId: string, + fetcher: ClientMetadataFetcher = fetchClientMetadataDocument, +): Promise { + const existing = await db + .select({ revokedAt: oauthClient.revokedAt }) + .from(oauthClient) + .where(eq(oauthClient.clientId, clientId)) + .get(); + if (existing?.revokedAt) { + throw new OAuthError("invalid_client", "Unknown or revoked client"); + } + + const doc = await fetcher(clientId); + const values = { + name: doc.client_name ?? new URL(clientId).hostname, + redirectUris: Array.from(new Set(doc.redirect_uris)), + }; + const [row] = await db + .insert(oauthClient) + .values({ clientId, ...values }) + .onConflictDoUpdate({ target: oauthClient.clientId, set: values }) + .returning(); + if (!row) throw new InternalServiceError("Failed to store URL client"); + return selectOAuthClientSchema.parse(row); +} diff --git a/packages/services/src/oauth/client.ts b/packages/services/src/oauth/client.ts new file mode 100644 index 000000000..0a845547f --- /dev/null +++ b/packages/services/src/oauth/client.ts @@ -0,0 +1,67 @@ +import { db as defaultDb } from "@openstatus/db"; +import { oauthClient } from "@openstatus/db/src/schema"; + +import type { DB } from "../context"; +import { InternalServiceError } from "../errors"; +import { GRANT_TYPES, RESPONSE_TYPES } from "./constants"; +import { randomHex } from "./crypto"; +import { OAuthError } from "./errors"; +import { isAllowedRedirectUri } from "./redirect-allowlist"; +import { RegisterClientInput } from "./schemas"; + +export type RegisteredClient = { + client_id: string; + client_id_issued_at: number; + client_name: string; + redirect_uris: string[]; + token_endpoint_auth_method: "none"; + grant_types: string[]; + response_types: string[]; +}; + +/** RFC 7591 dynamic registration. Public clients only; every redirect URI must pass the allowlist. */ +export async function registerClient(args: { + input: RegisterClientInput; + db?: DB; +}): Promise { + const parsed = RegisterClientInput.safeParse(args.input); + if (!parsed.success) { + throw new OAuthError( + "invalid_client_metadata", + parsed.error.issues.map((i) => i.message).join("; "), + ); + } + const input = parsed.data; + const db = args.db ?? defaultDb; + + const redirectUris = Array.from(new Set(input.redirect_uris)); + const rejected = redirectUris.filter((uri) => !isAllowedRedirectUri(uri)); + if (rejected.length > 0) { + throw new OAuthError( + "invalid_redirect_uri", + `redirect_uris must target an allowlisted host, a loopback address or a supported app scheme. Rejected: ${rejected.join(", ")}`, + ); + } + + const [row] = await db + .insert(oauthClient) + .values({ + clientId: randomHex(16), + name: input.client_name ?? "MCP Client", + redirectUris, + }) + .returning(); + if (!row) throw new InternalServiceError("Failed to register client"); + + return { + client_id: row.clientId, + client_id_issued_at: Math.floor( + (row.createdAt ?? new Date()).getTime() / 1000, + ), + client_name: row.name, + redirect_uris: row.redirectUris, + token_endpoint_auth_method: "none", + grant_types: [...GRANT_TYPES], + response_types: [...RESPONSE_TYPES], + }; +} diff --git a/packages/services/src/oauth/constants.ts b/packages/services/src/oauth/constants.ts new file mode 100644 index 000000000..303c0b783 --- /dev/null +++ b/packages/services/src/oauth/constants.ts @@ -0,0 +1,16 @@ +import type { SettableScope } from "@openstatus/db/src/schema/api-keys/constants"; + +export const ACCESS_TOKEN_PREFIX = "os_oat_"; +export const ACCESS_TOKEN_TTL_SECONDS = 60 * 60; +export const REFRESH_TOKEN_TTL_MS = 90 * 24 * 60 * 60 * 1000; +export const REFRESH_GRACE_MS = 30 * 1000; +export const SESSION_TTL_MS = 30 * 60 * 1000; +export const CODE_TTL_MS = 10 * 60 * 1000; +export const CLIENT_PRUNE_AFTER_MS = 7 * 24 * 60 * 60 * 1000; + +export const DEFAULT_SCOPE: SettableScope[] = ["write"]; + +export const GRANT_TYPES = ["authorization_code", "refresh_token"] as const; +export const RESPONSE_TYPES = ["code"] as const; +export const CODE_CHALLENGE_METHODS = ["S256"] as const; +export const TOKEN_ENDPOINT_AUTH_METHODS = ["none"] as const; diff --git a/packages/services/src/oauth/crypto.ts b/packages/services/src/oauth/crypto.ts new file mode 100644 index 000000000..e098e0cf5 --- /dev/null +++ b/packages/services/src/oauth/crypto.ts @@ -0,0 +1,61 @@ +// Web Crypto only: this package runs on Deno and stays Edge-safe. + +const encoder = new TextEncoder(); + +function randomBytes(length: number): Uint8Array { + const bytes = new Uint8Array(length); + crypto.getRandomValues(bytes); + return bytes; +} + +export function toBase64Url(bytes: Uint8Array): string { + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + +export function toHex(bytes: Uint8Array): string { + return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); +} + +export function randomBase64Url(length = 32): string { + return toBase64Url(randomBytes(length)); +} + +export function randomHex(length = 16): string { + return toHex(randomBytes(length)); +} + +async function sha256(input: string): Promise { + const digest = await crypto.subtle.digest("SHA-256", encoder.encode(input)); + return new Uint8Array(digest); +} + +export async function sha256Hex(input: string): Promise { + return toHex(await sha256(input)); +} + +/** RFC 7636 S256: BASE64URL(SHA256(ASCII(code_verifier))). */ +export async function pkceChallenge(verifier: string): Promise { + return toBase64Url(await sha256(verifier)); +} + +export function constantTimeEqual(a: string, b: string): boolean { + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i); + return diff === 0; +} + +const VERIFIER_RE = /^[A-Za-z0-9\-._~]{43,128}$/; + +export async function verifyPkce( + verifier: string, + challenge: string, +): Promise { + if (!VERIFIER_RE.test(verifier)) return false; + return constantTimeEqual(await pkceChallenge(verifier), challenge); +} diff --git a/packages/services/src/oauth/decide.ts b/packages/services/src/oauth/decide.ts new file mode 100644 index 000000000..8aa098ced --- /dev/null +++ b/packages/services/src/oauth/decide.ts @@ -0,0 +1,101 @@ +import { and, eq, isNull } from "@openstatus/db"; +import { + oauthAuthorizationCode, + oauthSession, +} from "@openstatus/db/src/schema"; + +import type { DB } from "../context"; +import { + ForbiddenError, + PreconditionFailedError, + ValidationError, +} from "../errors"; +import { getMembership } from "../member/membership"; +import { CODE_TTL_MS } from "./constants"; +import { randomBase64Url, sha256Hex } from "./crypto"; +import { runTx } from "./internal"; +import { DecideSessionInput, normalizeScopes } from "./schemas"; +import { loadPendingSession } from "./session"; + +/** `state` is opaque and echoed whenever the client sent one, even empty. */ +function buildRedirect( + redirectUri: string, + params: Record, + state: string | null, +): string { + const url = new URL(redirectUri); + for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v); + if (state !== null) url.searchParams.set("state", state); + return url.toString(); +} + +/** + * Consent decision. Approve checks workspace membership, allows narrowing the + * requested scope, mints a ten-minute single-use code and returns the client + * redirect; deny returns the `access_denied` redirect. Either way the session + * is spent. + */ +export async function decideSession(args: { + input: DecideSessionInput; + db?: DB; + now?: Date; +}): Promise<{ redirectUrl: string }> { + const input = DecideSessionInput.parse(args.input); + const now = args.now ?? new Date(); + + return runTx(args.db, async (tx) => { + const session = await loadPendingSession(tx, input.id, now); + + // The atomic update is the race guard; `loadPendingSession` only + // produces the friendlier error for the common case. + const [decided] = await tx + .update(oauthSession) + .set({ decidedAt: now }) + .where(and(eq(oauthSession.id, input.id), isNull(oauthSession.decidedAt))) + .returning({ id: oauthSession.id }); + if (!decided) { + throw new PreconditionFailedError( + "This authorization request was already answered", + ); + } + + if (!input.approved) { + return { + redirectUrl: buildRedirect( + session.redirectUri, + { error: "access_denied" }, + session.state, + ), + }; + } + + if (input.workspaceId === undefined) { + throw new ValidationError("workspaceId is required to approve"); + } + if (!(await getMembership(tx, input.userId, input.workspaceId))) { + throw new ForbiddenError("You are not a member of that workspace"); + } + + const granted = normalizeScopes(input.scope ?? session.scope); + const requestedWrite = session.scope.includes("write"); + if (granted.includes("write") && !requestedWrite) { + throw new ValidationError("Granted scope exceeds the requested scope"); + } + + const code = randomBase64Url(32); + await tx.insert(oauthAuthorizationCode).values({ + hash: await sha256Hex(code), + clientId: session.clientId, + userId: input.userId, + workspaceId: input.workspaceId, + scope: granted, + redirectUri: session.redirectUri, + codeChallenge: session.codeChallenge, + expiresAt: new Date(now.getTime() + CODE_TTL_MS), + }); + + return { + redirectUrl: buildRedirect(session.redirectUri, { code }, session.state), + }; + }); +} diff --git a/packages/services/src/oauth/errors.ts b/packages/services/src/oauth/errors.ts new file mode 100644 index 000000000..d82938f7b --- /dev/null +++ b/packages/services/src/oauth/errors.ts @@ -0,0 +1,39 @@ +import { ServiceError } from "../errors"; + +export type OAuthErrorCode = + | "invalid_request" + | "invalid_client" + | "invalid_grant" + | "invalid_scope" + | "invalid_target" + | "invalid_redirect_uri" + | "invalid_client_metadata" + | "unauthorized_client" + | "unsupported_grant_type" + | "unsupported_response_type" + | "access_denied"; + +/** + * RFC 6749 error. When `redirectUri` is set the authorize request had a + * valid client and redirect target, so the error travels back to the + * client instead of rendering as a 400. + */ +export class OAuthError extends ServiceError { + public readonly oauthCode: OAuthErrorCode; + public readonly redirectUri?: string; + public readonly state?: string; + + constructor( + oauthCode: OAuthErrorCode, + message: string, + redirect?: { redirectUri: string; state?: string | null }, + ) { + super( + oauthCode === "invalid_client" ? "UNAUTHORIZED" : "VALIDATION", + message, + ); + this.oauthCode = oauthCode; + this.redirectUri = redirect?.redirectUri; + this.state = redirect?.state ?? undefined; + } +} diff --git a/packages/services/src/oauth/exchange.ts b/packages/services/src/oauth/exchange.ts new file mode 100644 index 000000000..5c5dd129f --- /dev/null +++ b/packages/services/src/oauth/exchange.ts @@ -0,0 +1,150 @@ +import { and, db as defaultDb, eq, isNull } from "@openstatus/db"; +import { + oauthAuthorizationCode, + oauthGrant, + user, +} from "@openstatus/db/src/schema"; + +import { emitAudit } from "../audit"; +import { requireScope } from "../auth"; +import { type DB, type ServiceContext, withTransaction } from "../context"; +import { InternalServiceError } from "../errors"; +import { getMembership } from "../member/membership"; +import { sha256Hex, verifyPkce } from "./crypto"; +import { OAuthError } from "./errors"; +import { + getLiveClient, + grantSnapshot, + loadWorkspace, + mintTokens, + revokeGrantRows, + toTokenResponse, +} from "./internal"; +import { ExchangeCodeInput, type TokenResponse } from "./schemas"; + +const invalidGrant = (message: string) => + new OAuthError("invalid_grant", message); + +/** + * `grant_type=authorization_code`. Consumes the code once, revokes the user's + * other live grants for this client (one install, one token store) and mints + * the grant. A replayed code revokes the grant it produced. + */ +export async function exchangeCode(args: { + input: ExchangeCodeInput; + db?: DB; + now?: Date; +}): Promise { + const input = ExchangeCodeInput.parse(args.input); + const db = args.db ?? defaultDb; + const now = args.now ?? new Date(); + + const client = await getLiveClient(db, input.clientId); + const hash = await sha256Hex(input.code); + const code = await db + .select() + .from(oauthAuthorizationCode) + .where(eq(oauthAuthorizationCode.hash, hash)) + .get(); + if (!code || code.clientId !== client.clientId) { + throw invalidGrant("Invalid authorization code"); + } + + const workspace = await loadWorkspace(db, code.workspaceId); + const ctx: ServiceContext = { + workspace, + actor: { type: "user", userId: code.userId }, + db, + }; + requireScope(ctx, "write"); + + if (code.consumedAt) { + if (code.grantId !== null) { + const grantId = code.grantId; + await withTransaction(ctx, async (tx) => { + const grant = await tx + .select() + .from(oauthGrant) + .where(eq(oauthGrant.id, grantId)) + .get(); + if (grant) await revokeGrantRows(tx, ctx, [grant], "code_reuse", now); + }); + } + throw invalidGrant("Authorization code already used"); + } + if (code.expiresAt < now) throw invalidGrant("Authorization code expired"); + if (code.redirectUri !== input.redirectUri) { + throw invalidGrant("redirect_uri does not match the authorization request"); + } + if (!(await verifyPkce(input.codeVerifier, code.codeChallenge))) { + throw invalidGrant("PKCE verification failed"); + } + + const tokens = await mintTokens(now); + + return withTransaction(ctx, async (tx) => { + // Consent can be minutes old: the member may have been removed or the + // account deleted in between, and pending codes are not invalidated then. + const membership = await getMembership(tx, code.userId, code.workspaceId); + const owner = await tx + .select({ deletedAt: user.deletedAt }) + .from(user) + .where(eq(user.id, code.userId)) + .get(); + if (!membership || !owner || owner.deletedAt) { + throw invalidGrant("The consenting user no longer has access"); + } + + const others = await tx + .select() + .from(oauthGrant) + .where( + and( + eq(oauthGrant.clientId, client.clientId), + eq(oauthGrant.userId, code.userId), + isNull(oauthGrant.revokedAt), + ), + ) + .all(); + await revokeGrantRows(tx, ctx, others, "re_consent", now); + + const [grant] = await tx + .insert(oauthGrant) + .values({ + clientId: client.clientId, + userId: code.userId, + workspaceId: code.workspaceId, + scope: code.scope, + accessTokenHash: tokens.accessTokenHash, + accessTokenExpiresAt: tokens.accessTokenExpiresAt, + refreshTokenHash: tokens.refreshTokenHash, + refreshTokenExpiresAt: tokens.refreshTokenExpiresAt, + }) + .returning(); + if (!grant) throw new InternalServiceError("Failed to create OAuth grant"); + + // Single conditional write: a concurrent exchange that consumed the code + // first makes this match nothing, and the throw rolls the grant back. + const [consumed] = await tx + .update(oauthAuthorizationCode) + .set({ consumedAt: now, grantId: grant.id }) + .where( + and( + eq(oauthAuthorizationCode.id, code.id), + isNull(oauthAuthorizationCode.consumedAt), + ), + ) + .returning({ id: oauthAuthorizationCode.id }); + if (!consumed) throw invalidGrant("Authorization code already used"); + + await emitAudit(tx, ctx, { + action: "oauth_grant.create", + entityType: "oauth_grant", + entityId: grant.id, + after: grantSnapshot(grant), + metadata: { clientName: client.name, scope: grant.scope }, + }); + + return toTokenResponse(grant, tokens); + }); +} diff --git a/packages/services/src/oauth/index.ts b/packages/services/src/oauth/index.ts new file mode 100644 index 000000000..cd55eacdb --- /dev/null +++ b/packages/services/src/oauth/index.ts @@ -0,0 +1,36 @@ +export { registerClient, type RegisteredClient } from "./client"; +export { createSession, getSession, type PendingSession } from "./session"; +export { decideSession } from "./decide"; +export { exchangeCode } from "./exchange"; +export { refreshGrant } from "./refresh"; +export { + isAccessToken, + verifyAccessToken, + type VerifiedAccessToken, +} from "./verify"; +export { type ConnectedApp, listGrants } from "./list"; +export { revokeGrant, revokeGrantsForUser, revokeToken } from "./revoke"; +export { type PruneExpiredResult, pruneExpired } from "./prune"; +export { OAuthError, type OAuthErrorCode } from "./errors"; +export { isAllowedRedirectUri } from "./redirect-allowlist"; +export { pkceChallenge } from "./crypto"; +export { + type ClientMetadataDocument, + type ClientMetadataFetcher, + isUrlClientId, + parseClientMetadataDocument, +} from "./cimd"; +export { + authorizationServerMetadata, + mcpResource, + protectedResourceMetadata, + resourceMetadataUrl, +} from "./metadata"; +export { GRANT_TYPES } from "./constants"; +export { + GetSessionInput, + RevokeGrantInput, + type TokenResponse, + formatScope, + parseScopeParam, +} from "./schemas"; diff --git a/packages/services/src/oauth/internal.ts b/packages/services/src/oauth/internal.ts new file mode 100644 index 000000000..2ed2a9321 --- /dev/null +++ b/packages/services/src/oauth/internal.ts @@ -0,0 +1,203 @@ +import { and, db as defaultDb, eq, isNull } from "@openstatus/db"; +import { + type OAuthClient, + type OAuthGrant, + type Workspace, + oauthClient, + oauthGrant, + selectOAuthClientSchema, + selectWorkspaceSchema, + workspace, +} from "@openstatus/db/src/schema"; +import type { Scope } from "@openstatus/db/src/schema/api-keys/constants"; + +import { emitAudit } from "../audit"; +import { requireScope } from "../auth"; +import { + type DB, + type ServiceContext, + isTx, + withTransaction, +} from "../context"; +import { withBusyRetry } from "../retry"; +import { + ACCESS_TOKEN_PREFIX, + ACCESS_TOKEN_TTL_SECONDS, + REFRESH_TOKEN_TTL_MS, +} from "./constants"; +import { randomBase64Url, sha256Hex } from "./crypto"; +import { OAuthError } from "./errors"; +import { type TokenResponse, formatScope } from "./schemas"; + +export type GrantRevokeReason = + | "expired" + | "manual" + | "re_consent" + | "code_reuse" + | "refresh_reuse" + | "member_removed" + | "account_deleted" + | "token_revoked"; + +export async function getLiveClient( + db: DB, + clientId: string, +): Promise { + const row = await db + .select() + .from(oauthClient) + .where(eq(oauthClient.clientId, clientId)) + .get(); + if (!row || row.revokedAt) { + throw new OAuthError("invalid_client", "Unknown or revoked client"); + } + return selectOAuthClientSchema.parse(row); +} + +export async function loadWorkspace( + db: DB, + workspaceId: number, +): Promise { + const row = await db + .select() + .from(workspace) + .where(eq(workspace.id, workspaceId)) + .get(); + if (!row) throw new OAuthError("invalid_grant", "Workspace no longer exists"); + return selectWorkspaceSchema.parse(row); +} + +/** Pre-workspace verbs have no `ServiceContext`; this is `withTransaction` for them. */ +export async function runTx( + db: DB | undefined, + fn: (tx: DB) => Promise, +): Promise { + const conn = db ?? defaultDb; + if (isTx(conn)) return fn(conn); + return withBusyRetry(() => (conn as typeof defaultDb).transaction(fn)); +} + +/** Audit snapshot without token hashes. */ +export function grantSnapshot( + grant: OAuthGrant | typeof oauthGrant.$inferSelect, +) { + return { + id: grant.id, + clientId: grant.clientId, + workspaceId: grant.workspaceId, + userId: grant.userId, + scope: grant.scope, + createdAt: grant.createdAt, + lastUsedAt: grant.lastUsedAt, + revokedAt: grant.revokedAt, + }; +} + +export type MintedTokens = { + accessToken: string; + refreshToken: string; + accessTokenHash: string; + refreshTokenHash: string; + accessTokenExpiresAt: Date; + refreshTokenExpiresAt: Date; +}; + +export async function mintTokens(now: Date): Promise { + const accessToken = `${ACCESS_TOKEN_PREFIX}${randomBase64Url(32)}`; + const refreshToken = randomBase64Url(32); + return { + accessToken, + refreshToken, + accessTokenHash: await sha256Hex(accessToken), + refreshTokenHash: await sha256Hex(refreshToken), + accessTokenExpiresAt: new Date( + now.getTime() + ACCESS_TOKEN_TTL_SECONDS * 1000, + ), + refreshTokenExpiresAt: new Date(now.getTime() + REFRESH_TOKEN_TTL_MS), + }; +} + +export function toTokenResponse( + grant: { scope: readonly Scope[] }, + tokens: Pick, +): TokenResponse { + return { + access_token: tokens.accessToken, + token_type: "bearer", + expires_in: ACCESS_TOKEN_TTL_SECONDS, + refresh_token: tokens.refreshToken, + scope: formatScope(grant.scope), + }; +} + +/** + * Stamp `revoked_at` and emit `oauth_grant.delete` for each live row. A grant + * that belongs to another workspace than `ctx.workspace` (re-consent after a + * workspace switch, account deletion) is audited under its own workspace. + */ +export async function revokeGrantRows( + tx: DB, + ctx: ServiceContext, + grants: ReadonlyArray, + reason: GrantRevokeReason, + now: Date = new Date(), +): Promise { + const workspaces = new Map([ + [ctx.workspace.id, ctx.workspace], + ]); + let revoked = 0; + for (const grant of grants) { + if (grant.revokedAt) continue; + // Only the live-to-revoked transition is audited; a racing revoke + // matches nothing here. + const [row] = await tx + .update(oauthGrant) + .set({ revokedAt: now }) + .where(and(eq(oauthGrant.id, grant.id), isNull(oauthGrant.revokedAt))) + .returning(); + if (!row) continue; + revoked += 1; + + let ws = workspaces.get(grant.workspaceId); + if (!ws) { + ws = await loadWorkspace(tx, grant.workspaceId); + workspaces.set(grant.workspaceId, ws); + } + await emitAudit( + tx, + { ...ctx, workspace: ws, db: tx }, + { + action: "oauth_grant.delete", + entityType: "oauth_grant", + entityId: grant.id, + before: grantSnapshot(grant), + metadata: { reason }, + }, + ); + } + return revoked; +} + +/** + * Revoke outside a workspace-scoped verb: builds the consenting user's context + * from the grant so the audit row still attributes to them. + */ +// Delegates the audit row to `revokeGrantRows`. +// oxlint-disable-next-line openstatus/services-mutation-guards +export async function revokeGrantAsOwner( + db: DB, + grant: typeof oauthGrant.$inferSelect, + reason: GrantRevokeReason, + now: Date, +): Promise { + const workspace = await loadWorkspace(db, grant.workspaceId); + const ctx: ServiceContext = { + workspace, + actor: { type: "user", userId: grant.userId }, + db, + }; + requireScope(ctx, "write"); + await withTransaction(ctx, (tx) => + revokeGrantRows(tx, ctx, [grant], reason, now), + ); +} diff --git a/packages/services/src/oauth/list.ts b/packages/services/src/oauth/list.ts new file mode 100644 index 000000000..5ba762552 --- /dev/null +++ b/packages/services/src/oauth/list.ts @@ -0,0 +1,62 @@ +import { and, eq, gt, isNull } from "@openstatus/db"; +import { oauthClient, oauthGrant, user } from "@openstatus/db/src/schema"; +import type { Scope } from "@openstatus/db/src/schema/api-keys/constants"; + +import { type ServiceContext, getReadDb } from "../context"; + +export type ConnectedApp = { + id: number; + clientId: string; + clientName: string; + scope: Scope[]; + userId: number; + user: { + id: number; + email: string | null; + firstName: string | null; + lastName: string | null; + } | null; + createdAt: Date | null; + lastUsedAt: Date | null; +}; + +/** Live grants for the workspace, joined with client and consenting user. Hashes never leave the row. */ +export async function listGrants(args: { + ctx: ServiceContext; + now?: Date; +}): Promise { + const { ctx } = args; + const db = getReadDb(ctx); + const now = args.now ?? new Date(); + + const rows = await db + .select({ + id: oauthGrant.id, + clientId: oauthGrant.clientId, + clientName: oauthClient.name, + scope: oauthGrant.scope, + userId: oauthGrant.userId, + createdAt: oauthGrant.createdAt, + lastUsedAt: oauthGrant.lastUsedAt, + user: { + id: user.id, + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + }, + }) + .from(oauthGrant) + .innerJoin(oauthClient, eq(oauthClient.clientId, oauthGrant.clientId)) + .leftJoin(user, eq(user.id, oauthGrant.userId)) + .where( + and( + eq(oauthGrant.workspaceId, ctx.workspace.id), + isNull(oauthGrant.revokedAt), + gt(oauthGrant.refreshTokenExpiresAt, now), + ), + ) + .orderBy(oauthGrant.createdAt) + .all(); + + return rows.map((row) => ({ ...row, user: row.user?.id ? row.user : null })); +} diff --git a/packages/services/src/oauth/metadata.ts b/packages/services/src/oauth/metadata.ts new file mode 100644 index 000000000..49a5dd664 --- /dev/null +++ b/packages/services/src/oauth/metadata.ts @@ -0,0 +1,50 @@ +import { apiKeySettableScopes } from "@openstatus/db/src/schema/api-keys/constants"; + +import { + CODE_CHALLENGE_METHODS, + GRANT_TYPES, + RESPONSE_TYPES, + TOKEN_ENDPOINT_AUTH_METHODS, +} from "./constants"; + +export const MCP_RESOURCE_PATH = "/mcp"; +export const PROTECTED_RESOURCE_METADATA_PATH = + "/.well-known/oauth-protected-resource/mcp"; + +export function mcpResource(issuer: string): string { + return `${issuer}${MCP_RESOURCE_PATH}`; +} + +export function resourceMetadataUrl(issuer: string): string { + return `${issuer}${PROTECTED_RESOURCE_METADATA_PATH}`; +} + +/** RFC 8414 authorization server metadata. */ +export function authorizationServerMetadata(issuer: string) { + return { + issuer, + authorization_endpoint: `${issuer}/oauth/authorize`, + token_endpoint: `${issuer}/oauth/token`, + registration_endpoint: `${issuer}/oauth/register`, + revocation_endpoint: `${issuer}/oauth/revoke`, + response_types_supported: [...RESPONSE_TYPES], + grant_types_supported: [...GRANT_TYPES], + code_challenge_methods_supported: [...CODE_CHALLENGE_METHODS], + token_endpoint_auth_methods_supported: [...TOKEN_ENDPOINT_AUTH_METHODS], + revocation_endpoint_auth_methods_supported: [ + ...TOKEN_ENDPOINT_AUTH_METHODS, + ], + scopes_supported: [...apiKeySettableScopes], + client_id_metadata_document_supported: true, + }; +} + +/** RFC 9728 protected resource metadata for the `/mcp` resource. */ +export function protectedResourceMetadata(issuer: string) { + return { + resource: mcpResource(issuer), + authorization_servers: [issuer], + scopes_supported: [...apiKeySettableScopes], + bearer_methods_supported: ["header"], + }; +} diff --git a/packages/services/src/oauth/prune.ts b/packages/services/src/oauth/prune.ts new file mode 100644 index 000000000..763837956 --- /dev/null +++ b/packages/services/src/oauth/prune.ts @@ -0,0 +1,115 @@ +import { + and, + db as defaultDb, + eq, + gt, + isNull, + lt, + notExists, +} from "@openstatus/db"; +import { + oauthAuthorizationCode, + oauthClient, + oauthGrant, + oauthSession, +} from "@openstatus/db/src/schema"; + +import type { DB } from "../context"; +import { CLIENT_PRUNE_AFTER_MS } from "./constants"; +import { loadWorkspace, revokeGrantRows, runTx } from "./internal"; + +export type PruneExpiredResult = { + sessions: number; + codes: number; + grants: number; + clients: number; +}; + +/** + * Daily cleanup, one transaction. Clients that never completed a consent go + * after seven days; clients with a grant row are kept forever because + * Claude.ai caches the `client_id` and never re-registers. + */ +export async function pruneExpired( + args: { ctx?: { db?: DB }; now?: Date } = {}, +): Promise { + const now = args.now ?? new Date(); + const clientCutoff = new Date(now.getTime() - CLIENT_PRUNE_AFTER_MS); + + return runTx(args.ctx?.db ?? defaultDb, async (tx) => { + const sessions = await tx + .delete(oauthSession) + .where(lt(oauthSession.expiresAt, now)) + .run(); + const codes = await tx + .delete(oauthAuthorizationCode) + .where(lt(oauthAuthorizationCode.expiresAt, now)) + .run(); + + // Refresh expiry is a revocation like any other and is audited as one, + // under the system actor. + const expired = await tx + .select() + .from(oauthGrant) + .where( + and( + isNull(oauthGrant.revokedAt), + lt(oauthGrant.refreshTokenExpiresAt, now), + ), + ) + .all(); + let grants = 0; + if (expired.length > 0) { + const workspace = await loadWorkspace(tx, expired[0].workspaceId); + grants = await revokeGrantRows( + tx, + { workspace, actor: { type: "system", job: "oauth-prune" }, db: tx }, + expired, + "expired", + now, + ); + } + + // A client is dead weight only when nothing can still turn into a grant: + // no grant ever, no live session, no unconsumed code. + const clients = await tx + .delete(oauthClient) + .where( + and( + lt(oauthClient.createdAt, clientCutoff), + notExists( + tx + .select({ id: oauthGrant.id }) + .from(oauthGrant) + .where(eq(oauthGrant.clientId, oauthClient.clientId)), + ), + notExists( + tx + .select({ id: oauthSession.id }) + .from(oauthSession) + .where(eq(oauthSession.clientId, oauthClient.clientId)), + ), + notExists( + tx + .select({ id: oauthAuthorizationCode.id }) + .from(oauthAuthorizationCode) + .where( + and( + eq(oauthAuthorizationCode.clientId, oauthClient.clientId), + isNull(oauthAuthorizationCode.consumedAt), + gt(oauthAuthorizationCode.expiresAt, now), + ), + ), + ), + ), + ) + .run(); + + return { + sessions: sessions.rowsAffected, + codes: codes.rowsAffected, + grants, + clients: clients.rowsAffected, + }; + }); +} diff --git a/packages/services/src/oauth/redirect-allowlist.ts b/packages/services/src/oauth/redirect-allowlist.ts new file mode 100644 index 000000000..e00feaecd --- /dev/null +++ b/packages/services/src/oauth/redirect-allowlist.ts @@ -0,0 +1,126 @@ +/** + * `/oauth/register` is unauthenticated, so an arbitrary redirect target would + * let anyone register a client and use it in a confused-deputy attack against + * an openstatus user. Every entry below needs a one-line justification. + * Third parties do not need an entry: a URL client id (see `cimd.ts`) proves + * domain ownership instead. + * + * - `openstatus.dev` (+ subdomains): first-party clients. + * - `claude.ai` (+ subdomains): Claude web connectors call back on + * `https://claude.ai/api/mcp/auth_callback`. + * - `chatgpt.com` (+ subdomains): ChatGPT connectors. + * - `cursor.com` (+ subdomains): Cursor's hosted callback. + * + * Loopback hosts are allowed on http or https (RFC 8252 native clients such + * as Claude Code): a victim's loopback address cannot exfiltrate a code. + * + * Custom schemes are matched on scheme only; the host part is app-defined: + * - `cursor://`: Cursor desktop. + * - `vscode://`, `vscode-insiders://`: VS Code MCP client. + */ +export const ALLOWED_REDIRECT_HOSTS = [ + "openstatus.dev", + "claude.ai", + "chatgpt.com", + "cursor.com", +] as const; + +export const ALLOWED_REDIRECT_SCHEMES = [ + "cursor:", + "vscode:", + "vscode-insiders:", +] as const; + +function isLoopbackHost(hostname: string): boolean { + return ( + hostname === "localhost" || + hostname === "127.0.0.1" || + hostname === "[::1]" || + hostname === "::1" + ); +} + +function isAllowlistedHost(hostname: string): boolean { + return ALLOWED_REDIRECT_HOSTS.some( + (allowed) => hostname === allowed || hostname.endsWith(`.${allowed}`), + ); +} + +/** + * `URL` drops an empty userinfo (`https://@host`), so `username`/`password` + * alone miss it. Inspect the raw authority, normalised the way `URL` does. + */ +function hasUserinfo(uri: string): boolean { + const raw = uri.trim().replace(/[\t\n\r]/g, ""); + const authority = /^[^:/?#]+:\/\/([^/?#]*)/.exec(raw)?.[1] ?? ""; + return authority.includes("@"); +} + +function hasFragmentOrUserinfo(uri: string): boolean { + // `URL.hash` is empty for a bare trailing `#`, so check the raw string. + return uri.includes("#") || hasUserinfo(uri); +} + +export function isAllowedRedirectUri(redirectUri: string): boolean { + // RFC 6749 §3.1.2 forbids fragments; credentials have no legitimate use. + if (hasFragmentOrUserinfo(redirectUri)) return false; + let url: URL; + try { + url = new URL(redirectUri); + } catch { + return false; + } + if (url.username || url.password) return false; + const protocol = url.protocol.toLowerCase(); + if ((ALLOWED_REDIRECT_SCHEMES as readonly string[]).includes(protocol)) { + return true; + } + + const hostname = url.hostname.toLowerCase(); + if (isLoopbackHost(hostname)) { + return protocol === "http:" || protocol === "https:"; + } + return protocol === "https:" && isAllowlistedHost(hostname); +} + +/** + * RFC 8252 §7.3: native clients bind an ephemeral port, so a loopback + * redirect matches its registered entry on everything but the port. + * Any other URI must match a registered entry exactly. Fragments (RFC 6749 + * §3.1.2) and userinfo never match on either side: registration rejects them + * today, and entries written before that guard must not widen the match. + */ +export function matchesRegisteredRedirectUri( + registered: readonly string[], + requested: string, +): boolean { + if (hasFragmentOrUserinfo(requested)) return false; + let url: URL; + try { + url = new URL(requested); + } catch { + return false; + } + if (url.username || url.password) return false; + if (registered.includes(requested)) return true; + if (!isLoopbackHost(url.hostname.toLowerCase())) return false; + const protocol = url.protocol.toLowerCase(); + if (protocol !== "http:" && protocol !== "https:") return false; + return registered.some((entry) => { + if (hasFragmentOrUserinfo(entry)) return false; + let candidate: URL; + try { + candidate = new URL(entry); + } catch { + return false; + } + return ( + !candidate.username && + !candidate.password && + candidate.protocol.toLowerCase() === protocol && + candidate.hostname.toLowerCase() === url.hostname.toLowerCase() && + candidate.pathname === url.pathname && + candidate.search === url.search + ); + }); +} diff --git a/packages/services/src/oauth/refresh.ts b/packages/services/src/oauth/refresh.ts new file mode 100644 index 000000000..b34d00406 --- /dev/null +++ b/packages/services/src/oauth/refresh.ts @@ -0,0 +1,117 @@ +import { and, db as defaultDb, eq, isNull, or } from "@openstatus/db"; +import { oauthGrant } from "@openstatus/db/src/schema"; + +import type { DB } from "../context"; +import { REFRESH_GRACE_MS } from "./constants"; +import { sha256Hex } from "./crypto"; +import { OAuthError } from "./errors"; +import { + getLiveClient, + mintTokens, + revokeGrantAsOwner, + toTokenResponse, +} from "./internal"; +import { RefreshGrantInput, type TokenResponse } from "./schemas"; + +type GrantRow = typeof oauthGrant.$inferSelect; + +const invalidGrant = (message: string) => + new OAuthError("invalid_grant", message); + +/** + * Rotate in place. The predicate pins the hash being replaced and the live + * state, so a concurrent refresh or revoke makes this match nothing. + */ +async function rotate( + db: DB, + grant: GrantRow, + expectedCurrentHash: string, + now: Date, +): Promise { + const tokens = await mintTokens(now); + const [updated] = await db + .update(oauthGrant) + .set({ + accessTokenHash: tokens.accessTokenHash, + accessTokenExpiresAt: tokens.accessTokenExpiresAt, + refreshTokenHash: tokens.refreshTokenHash, + refreshTokenExpiresAt: tokens.refreshTokenExpiresAt, + previousRefreshTokenHash: expectedCurrentHash, + rotatedAt: now, + lastUsedAt: now, + }) + .where( + and( + eq(oauthGrant.id, grant.id), + eq(oauthGrant.refreshTokenHash, expectedCurrentHash), + isNull(oauthGrant.revokedAt), + ), + ) + .returning(); + return updated ? toTokenResponse(updated, tokens) : null; +} + +function insideGrace(grant: GrantRow, now: Date): boolean { + return ( + grant.rotatedAt !== null && + now.getTime() - grant.rotatedAt.getTime() <= REFRESH_GRACE_MS + ); +} + +/** + * `grant_type=refresh_token`. The hash rotated out most recently stays valid + * for a short grace window and rotates again; presenting it after the window + * is treated as theft and revokes the grant. + */ +export async function refreshGrant(args: { + input: RefreshGrantInput; + db?: DB; + now?: Date; +}): Promise { + const input = RefreshGrantInput.parse(args.input); + const db = args.db ?? defaultDb; + const now = args.now ?? new Date(); + + const client = await getLiveClient(db, input.clientId); + const hash = await sha256Hex(input.refreshToken); + + const load = () => + db + .select() + .from(oauthGrant) + .where( + or( + eq(oauthGrant.refreshTokenHash, hash), + eq(oauthGrant.previousRefreshTokenHash, hash), + ), + ) + .get(); + + let grant = await load(); + if (!grant || grant.clientId !== client.clientId || grant.revokedAt) { + throw invalidGrant("Invalid refresh token"); + } + + if (grant.refreshTokenHash === hash) { + if (grant.refreshTokenExpiresAt < now) { + await revokeGrantAsOwner(db, grant, "refresh_reuse", now); + throw invalidGrant("Refresh token expired"); + } + const rotated = await rotate(db, grant, hash, now); + if (rotated) return rotated; + // Lost a race: either a concurrent refresh moved this hash to + // `previous`, or a revoke landed. Re-read and fall through. + grant = await load(); + if (!grant || grant.revokedAt || grant.previousRefreshTokenHash !== hash) { + throw invalidGrant("Invalid refresh token"); + } + } + + if (!insideGrace(grant, now)) { + await revokeGrantAsOwner(db, grant, "refresh_reuse", now); + throw invalidGrant("Refresh token reused; grant revoked"); + } + const rotated = await rotate(db, grant, grant.refreshTokenHash, now); + if (!rotated) throw invalidGrant("Invalid refresh token"); + return rotated; +} diff --git a/packages/services/src/oauth/revoke.ts b/packages/services/src/oauth/revoke.ts new file mode 100644 index 000000000..384f7e818 --- /dev/null +++ b/packages/services/src/oauth/revoke.ts @@ -0,0 +1,125 @@ +import { and, db as defaultDb, eq, isNull, or } from "@openstatus/db"; +import { oauthGrant } from "@openstatus/db/src/schema"; + +import { requireScope } from "../auth"; +import { + type DB, + type ServiceContext, + tryGetActorUserId, + withTransaction, +} from "../context"; +import { ForbiddenError, NotFoundError, UnauthorizedError } from "../errors"; +import { getMembership } from "../member/membership"; +import { sha256Hex } from "./crypto"; +import { getLiveClient, revokeGrantAsOwner, revokeGrantRows } from "./internal"; +import { RevokeGrantInput, RevokeTokenInput } from "./schemas"; + +/** + * Connected apps → Revoke. Any member revokes their own grants; owners and + * admins revoke any grant in the workspace. + */ +// Delegates the audit row to `revokeGrantRows`. +// oxlint-disable-next-line openstatus/services-mutation-guards +export async function revokeGrant(args: { + ctx: ServiceContext; + input: RevokeGrantInput; +}): Promise { + const { ctx } = args; + requireScope(ctx, "write"); + const input = RevokeGrantInput.parse(args.input); + + const actorUserId = tryGetActorUserId(ctx.actor); + if (actorUserId == null) { + throw new UnauthorizedError( + "Grants can only be revoked by a known user actor.", + ); + } + + await withTransaction(ctx, async (tx) => { + const grant = await tx + .select() + .from(oauthGrant) + .where( + and( + eq(oauthGrant.id, input.grantId), + eq(oauthGrant.workspaceId, ctx.workspace.id), + isNull(oauthGrant.revokedAt), + ), + ) + .get(); + if (!grant) throw new NotFoundError("oauth_grant", input.grantId); + + if (grant.userId !== actorUserId) { + const membership = await getMembership(tx, actorUserId, ctx.workspace.id); + if (membership?.role !== "owner" && membership?.role !== "admin") { + throw new ForbiddenError( + "Only owners and admins can revoke other members' grants", + ); + } + } + + await revokeGrantRows(tx, ctx, [grant], "manual"); + }); +} + +/** + * Internal: revoke every live grant of a user, in one workspace (member + * removal) or everywhere (account deletion). Runs inside the caller's tx. + */ +export async function revokeGrantsForUser(args: { + tx: DB; + ctx: ServiceContext; + userId: number; + workspaceId?: number; + reason?: "member_removed" | "account_deleted"; +}): Promise { + const { tx, ctx, userId } = args; + const clauses = [eq(oauthGrant.userId, userId), isNull(oauthGrant.revokedAt)]; + if (args.workspaceId !== undefined) { + clauses.push(eq(oauthGrant.workspaceId, args.workspaceId)); + } + const grants = await tx + .select() + .from(oauthGrant) + .where(and(...clauses)) + .all(); + if (grants.length === 0) return 0; + return revokeGrantRows( + tx, + ctx, + grants, + args.reason ?? + (args.workspaceId !== undefined ? "member_removed" : "account_deleted"), + ); +} + +/** RFC 7009: either token of a grant revokes the whole grant. Unknown tokens are a silent no-op. */ +export async function revokeToken(args: { + input: RevokeTokenInput; + db?: DB; + now?: Date; +}): Promise { + const input = RevokeTokenInput.parse(args.input); + const db = args.db ?? defaultDb; + const now = args.now ?? new Date(); + + const client = await getLiveClient(db, input.clientId); + const hash = await sha256Hex(input.token); + const grant = await db + .select() + .from(oauthGrant) + .where( + and( + eq(oauthGrant.clientId, client.clientId), + isNull(oauthGrant.revokedAt), + or( + eq(oauthGrant.accessTokenHash, hash), + eq(oauthGrant.refreshTokenHash, hash), + eq(oauthGrant.previousRefreshTokenHash, hash), + ), + ), + ) + .get(); + if (!grant) return; + await revokeGrantAsOwner(db, grant, "token_revoked", now); +} diff --git a/packages/services/src/oauth/schemas.ts b/packages/services/src/oauth/schemas.ts new file mode 100644 index 000000000..f5b73d6d3 --- /dev/null +++ b/packages/services/src/oauth/schemas.ts @@ -0,0 +1,119 @@ +import { + type Scope, + type SettableScope, + apiKeySettableScopes, +} from "@openstatus/db/src/schema/api-keys/constants"; +import { oauthScopesSchema } from "@openstatus/db/src/schema/oauth/validation"; +import { z } from "zod"; + +import { + DEFAULT_SCOPE, + GRANT_TYPES, + RESPONSE_TYPES, + TOKEN_ENDPOINT_AUTH_METHODS, +} from "./constants"; + +/** + * Space-delimited RFC 6749 scope string to the stored array. Read+write + * collapses to `["write"]` because `matchesScope` treats write as implying + * read; an absent parameter means write. + */ +export function parseScopeParam(scope: string | undefined | null): { + scopes: SettableScope[]; + invalid: string[]; +} { + const parts = (scope ?? "").trim().split(/\s+/).filter(Boolean); + if (parts.length === 0) return { scopes: [...DEFAULT_SCOPE], invalid: [] }; + const invalid = parts.filter( + (p) => !(apiKeySettableScopes as readonly string[]).includes(p), + ); + if (invalid.length > 0) return { scopes: [], invalid }; + return { scopes: parts.includes("write") ? ["write"] : ["read"], invalid }; +} + +// Grants never store '*'; it is folded into write defensively. +export function normalizeScopes(scopes: readonly Scope[]): SettableScope[] { + return scopes.includes("write") || scopes.includes("*") + ? ["write"] + : ["read"]; +} + +export function formatScope(scopes: readonly Scope[]): string { + return normalizeScopes(scopes).join(" "); +} + +export const RegisterClientInput = z.object({ + client_name: z.string().trim().min(1).max(120).optional(), + redirect_uris: z.array(z.string().min(1)).min(1).max(10), + token_endpoint_auth_method: z + .enum(TOKEN_ENDPOINT_AUTH_METHODS, { + message: + "Only public clients (token_endpoint_auth_method 'none') are supported", + }) + .optional(), + grant_types: z.array(z.enum(GRANT_TYPES)).optional(), + response_types: z.array(z.enum(RESPONSE_TYPES)).optional(), +}); +export type RegisterClientInput = z.input; + +// Loose on purpose: `createSession` decides per field whether the failure +// is a 400 or an error redirect, so zod must not reject early. +export const CreateSessionInput = z.object({ + response_type: z.string().optional(), + client_id: z.string().optional(), + redirect_uri: z.string().optional(), + scope: z.string().optional(), + state: z.string().optional(), + resource: z.string().optional(), + code_challenge: z.string().optional(), + code_challenge_method: z.string().optional(), + /** `/mcp`; a `resource` parameter must equal it when present. */ + expectedResource: z.string().optional(), +}); +export type CreateSessionInput = z.input; + +export const MAX_STATE_LENGTH = 1024; + +export const GetSessionInput = z.object({ id: z.string().min(1) }); +export type GetSessionInput = z.infer; + +export const DecideSessionInput = z.object({ + id: z.string().min(1), + approved: z.boolean(), + userId: z.number().int(), + workspaceId: z.number().int().optional(), + scope: oauthScopesSchema.optional(), +}); +export type DecideSessionInput = z.infer; + +export const ExchangeCodeInput = z.object({ + clientId: z.string().min(1), + code: z.string().min(1), + codeVerifier: z.string().min(1), + redirectUri: z.string().min(1), +}); +export type ExchangeCodeInput = z.infer; + +export const RefreshGrantInput = z.object({ + clientId: z.string().min(1), + refreshToken: z.string().min(1), +}); +export type RefreshGrantInput = z.infer; + +export const RevokeTokenInput = z.object({ + clientId: z.string().min(1), + token: z.string().min(1), +}); +export type RevokeTokenInput = z.infer; + +export const RevokeGrantInput = z.object({ grantId: z.number().int() }); +export type RevokeGrantInput = z.infer; + +export const TokenResponse = z.object({ + access_token: z.string(), + token_type: z.literal("bearer"), + expires_in: z.number().int(), + refresh_token: z.string(), + scope: z.string(), +}); +export type TokenResponse = z.infer; diff --git a/packages/services/src/oauth/session.ts b/packages/services/src/oauth/session.ts new file mode 100644 index 000000000..6e9726b83 --- /dev/null +++ b/packages/services/src/oauth/session.ts @@ -0,0 +1,196 @@ +import { db as defaultDb, eq } from "@openstatus/db"; +import { + type OAuthSession, + oauthClient, + oauthSession, + selectOAuthSessionSchema, +} from "@openstatus/db/src/schema"; +import type { SettableScope } from "@openstatus/db/src/schema/api-keys/constants"; + +import type { DB } from "../context"; +import { + InternalServiceError, + NotFoundError, + PreconditionFailedError, +} from "../errors"; +import { + type ClientMetadataFetcher, + isUrlClientId, + resolveUrlClient, +} from "./cimd"; +import { SESSION_TTL_MS } from "./constants"; +import { randomBase64Url } from "./crypto"; +import { OAuthError } from "./errors"; +import { getLiveClient } from "./internal"; +import { matchesRegisteredRedirectUri } from "./redirect-allowlist"; +import { + CreateSessionInput, + GetSessionInput, + MAX_STATE_LENGTH, + parseScopeParam, +} from "./schemas"; + +/** + * Validate an authorize request and persist it as a pending session. Client + * and redirect URI failures throw without a redirect target; everything after + * that carries the redirect so the route can send the error back per RFC 6749. + */ +export async function createSession(args: { + input: CreateSessionInput; + db?: DB; + now?: Date; + /** Overrides the network fetch for URL client ids; tests inject a stub. */ + fetchClientMetadata?: ClientMetadataFetcher; +}): Promise<{ id: string }> { + const input = CreateSessionInput.parse(args.input); + const db = args.db ?? defaultDb; + const now = args.now ?? new Date(); + + if (!input.client_id) { + throw new OAuthError("invalid_request", "client_id is required"); + } + const client = isUrlClientId(input.client_id) + ? await resolveUrlClient(db, input.client_id, args.fetchClientMetadata) + : await getLiveClient(db, input.client_id); + + if (!input.redirect_uri) { + throw new OAuthError("invalid_request", "redirect_uri is required"); + } + if (!matchesRegisteredRedirectUri(client.redirectUris, input.redirect_uri)) { + throw new OAuthError( + "invalid_redirect_uri", + "redirect_uri does not match a registered redirect URI", + ); + } + // From here on the redirect target is trusted; the state itself is checked + // last so an oversized one still travels back (truncated) to the client. + const redirect = { + redirectUri: input.redirect_uri, + state: input.state?.slice(0, MAX_STATE_LENGTH), + }; + + if (input.response_type !== "code") { + throw new OAuthError( + "unsupported_response_type", + "response_type must be 'code'", + redirect, + ); + } + if (!input.code_challenge) { + throw new OAuthError( + "invalid_request", + "code_challenge is required (PKCE)", + redirect, + ); + } + if (input.code_challenge_method !== "S256") { + throw new OAuthError( + "invalid_request", + "code_challenge_method must be 'S256'", + redirect, + ); + } + const { scopes, invalid } = parseScopeParam(input.scope); + if (invalid.length > 0) { + throw new OAuthError( + "invalid_scope", + `Unknown scope: ${invalid.join(", ")}`, + redirect, + ); + } + if ( + input.resource !== undefined && + input.expectedResource !== undefined && + input.resource !== input.expectedResource + ) { + throw new OAuthError( + "invalid_target", + `resource must be ${input.expectedResource}`, + redirect, + ); + } + if (input.state !== undefined && input.state.length > MAX_STATE_LENGTH) { + throw new OAuthError( + "invalid_request", + `state must be at most ${MAX_STATE_LENGTH} characters`, + redirect, + ); + } + + const id = randomBase64Url(32); + const [row] = await db + .insert(oauthSession) + .values({ + id, + clientId: client.clientId, + redirectUri: input.redirect_uri, + scope: scopes, + state: input.state ?? null, + resource: input.resource ?? null, + codeChallenge: input.code_challenge, + codeChallengeMethod: "S256", + expiresAt: new Date(now.getTime() + SESSION_TTL_MS), + }) + .returning({ id: oauthSession.id }); + if (!row) throw new InternalServiceError("Failed to create OAuth session"); + return { id: row.id }; +} + +/** The one definition of "still answerable": exists, undecided, unexpired. */ +export async function loadPendingSession( + db: DB, + id: string, + now: Date, +): Promise { + const raw = await db + .select() + .from(oauthSession) + .where(eq(oauthSession.id, id)) + .get(); + if (!raw) throw new NotFoundError("oauth_session"); + const session = selectOAuthSessionSchema.parse(raw); + if (session.decidedAt) { + throw new PreconditionFailedError( + "This authorization request was already answered", + ); + } + if (session.expiresAt < now) { + throw new PreconditionFailedError("This authorization request expired"); + } + return session; +} + +export type PendingSession = { + id: string; + clientId: string; + clientName: string; + scope: SettableScope[]; + expiresAt: Date; +}; + +/** Consent-page read. Throws once the session expired or was decided. */ +export async function getSession(args: { + input: GetSessionInput; + db?: DB; + now?: Date; +}): Promise { + const input = GetSessionInput.parse(args.input); + const db = args.db ?? defaultDb; + const now = args.now ?? new Date(); + + const session = await loadPendingSession(db, input.id, now); + const client = await db + .select({ clientId: oauthClient.clientId, name: oauthClient.name }) + .from(oauthClient) + .where(eq(oauthClient.clientId, session.clientId)) + .get(); + if (!client) throw new NotFoundError("oauth_client", session.clientId); + + return { + id: session.id, + clientId: client.clientId, + clientName: client.name, + scope: session.scope, + expiresAt: session.expiresAt, + }; +} diff --git a/packages/services/src/oauth/verify.ts b/packages/services/src/oauth/verify.ts new file mode 100644 index 000000000..32db99128 --- /dev/null +++ b/packages/services/src/oauth/verify.ts @@ -0,0 +1,56 @@ +import { db as defaultDb, eq } from "@openstatus/db"; +import { oauthGrant } from "@openstatus/db/src/schema"; +import type { Scope } from "@openstatus/db/src/schema/api-keys/constants"; +import { shouldUpdateLastUsed } from "@openstatus/db/src/utils/api-key"; + +import type { DB } from "../context"; +import { ACCESS_TOKEN_PREFIX } from "./constants"; +import { sha256Hex } from "./crypto"; + +export type VerifiedAccessToken = { + grantId: number; + workspaceId: number; + userId: number; + scopes: Scope[]; +}; + +export function isAccessToken(token: string): boolean { + return token.startsWith(ACCESS_TOKEN_PREFIX); +} + +/** + * Resolve a bearer access token to its grant. Runs before the workspace is + * known, so no `ServiceContext`. `last_used_at` is bumped best-effort with the + * same debounce as API keys. + */ +export async function verifyAccessToken( + token: string, + opts: { db?: DB; now?: Date } = {}, +): Promise { + if (!isAccessToken(token)) return null; + const db = opts.db ?? defaultDb; + const now = opts.now ?? new Date(); + + const grant = await db + .select() + .from(oauthGrant) + .where(eq(oauthGrant.accessTokenHash, await sha256Hex(token))) + .get(); + if (!grant || grant.revokedAt) return null; + if (grant.accessTokenExpiresAt < now) return null; + + if (shouldUpdateLastUsed(grant.lastUsedAt, undefined, now)) { + await db + .update(oauthGrant) + .set({ lastUsedAt: now }) + .where(eq(oauthGrant.id, grant.id)) + .catch(() => {}); + } + + return { + grantId: grant.id, + workspaceId: grant.workspaceId, + userId: grant.userId, + scopes: grant.scope, + }; +} diff --git a/packages/services/src/user/delete.ts b/packages/services/src/user/delete.ts index e5ee86dcc..5f955c1c3 100644 --- a/packages/services/src/user/delete.ts +++ b/packages/services/src/user/delete.ts @@ -24,6 +24,7 @@ import { } from "../errors"; import { deleteMonitors } from "../monitor/delete"; import { deleteNotification } from "../notification/delete"; +import { revokeGrantsForUser } from "../oauth/revoke"; import { deletePage } from "../page/delete"; import { DeleteAccountInput } from "./schemas"; @@ -37,7 +38,9 @@ import { DeleteAccountInput } from "./schemas"; * notifications so we don't keep running probes / sending alerts for * an unreachable owner. The workspace row and the owner membership * are intentionally left in place; reclaiming those is out of scope. - * 3. Removes their membership from every workspace they don't own. + * 3. Revokes their OAuth grants everywhere (soft delete means the FK + * cascade never fires) and removes their membership from every + * workspace they don't own. * 4. Deletes their sessions and OAuth accounts. * 5. Blanks out PII on the user row and stamps `deletedAt`. * @@ -162,6 +165,8 @@ export async function deleteAccount(args: { } } + await revokeGrantsForUser({ tx, ctx, userId, reason: "account_deleted" }); + await tx .delete(usersToWorkspaces) .where( diff --git a/packages/utils/src/ssrf.ts b/packages/utils/src/ssrf.ts index ae84e4eff..34ca534ac 100644 --- a/packages/utils/src/ssrf.ts +++ b/packages/utils/src/ssrf.ts @@ -83,7 +83,9 @@ function isBlockedHost(hostname: string): boolean { /** * Validates that a URL is safe to fetch (not targeting internal/private infrastructure). - * Checks protocol, hostname, and resolved IP address. + * Checks protocol and hostname/literal IP only; DNS is never resolved, so a + * public name that resolves to a private address still passes. Same checks as + * `assertSafeUrlSync`, kept async for existing callers. * Throws an error if the URL is not safe. */ export async function assertSafeUrl(urlString: string): Promise {