diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d3fd5f5632b..c3a6b290f8b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,11 +30,7 @@ jobs: run: bun install --frozen --ignore-scripts - name: Sherif - # Deliberate version split: web's RemoteTerminal viewer uses the - # stable @xterm/xterm@5.x track; desktop uses the beta 6.x track - # for kittyKeyboard / scrollbar options that aren't on stable yet. - # See the comment in apps/web/.../RemoteTerminal/RemoteTerminal.tsx. - run: bunx sherif -i @xterm/addon-fit -i @xterm/xterm + run: bunx sherif lint: name: Lint diff --git a/apps/desktop/src/main/host-service/index.ts b/apps/desktop/src/main/host-service/index.ts index ec4302982c9..403623e41fe 100644 --- a/apps/desktop/src/main/host-service/index.ts +++ b/apps/desktop/src/main/host-service/index.ts @@ -97,7 +97,6 @@ async function main(): Promise { `http://localhost:${env.DESKTOP_VITE_PORT}`, `http://127.0.0.1:${env.DESKTOP_VITE_PORT}`, ], - hostServiceSecret: env.HOST_SERVICE_SECRET, }, providers: { auth: authProvider, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalHeaderExtras/TerminalHeaderExtras.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalHeaderExtras/TerminalHeaderExtras.tsx deleted file mode 100644 index c4321175052..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalHeaderExtras/TerminalHeaderExtras.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import type { RendererContext } from "@superset/panes"; -import type { - PaneViewerData, - TerminalPaneData, -} from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types"; -import { TerminalRemoteControlButton } from "../TerminalRemoteControlButton"; - -interface TerminalHeaderExtrasProps { - context: RendererContext; - workspaceId: string; -} - -export function TerminalHeaderExtras({ - context, - workspaceId, -}: TerminalHeaderExtrasProps) { - if (context.pane.kind !== "terminal") return null; - - const data = context.pane.data as TerminalPaneData; - - return ( -
- -
- ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalHeaderExtras/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalHeaderExtras/index.ts deleted file mode 100644 index 7f654de91f5..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalHeaderExtras/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { TerminalHeaderExtras } from "./TerminalHeaderExtras"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalRemoteControlButton/TerminalRemoteControlButton.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalRemoteControlButton/TerminalRemoteControlButton.tsx deleted file mode 100644 index 1ac334fe95a..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalRemoteControlButton/TerminalRemoteControlButton.tsx +++ /dev/null @@ -1,259 +0,0 @@ -import { FEATURE_FLAGS } from "@superset/shared/constants"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@superset/ui/dropdown-menu"; -import { toast } from "@superset/ui/sonner"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; -import { cn } from "@superset/ui/utils"; -import { ExternalLink, Radio } from "lucide-react"; -import { useFeatureFlagEnabled } from "posthog-js/react"; -import { useCallback, useEffect, useState } from "react"; -import { apiTrpcClient } from "renderer/lib/api-trpc-client"; - -interface TerminalRemoteControlButtonProps { - workspaceId: string; - terminalId: string; -} - -interface ActiveSession { - sessionId: string; - // `webUrl` is only available right after `create` — the cloud only - // stores `token_hash`, so we cannot reconstruct the share URL when - // hydrating from `listForWorkspace`. `null` means "session is live but - // the original link isn't recoverable; user needs to stop + re-share". - webUrl: string | null; - expiresAt: string; -} - -type Phase = "inactive" | "loading" | "creating" | "active" | "revoking"; -const HYDRATE_REFRESH_MS = 30_000; - -export function TerminalRemoteControlButton({ - workspaceId, - terminalId, -}: TerminalRemoteControlButtonProps) { - // Hooks must run unconditionally — gate at render time instead of - // short-circuiting before `useState` etc. - const hasAccess = useFeatureFlagEnabled( - FEATURE_FLAGS.WEB_REMOTE_CONTROL_ACCESS, - ); - const [phase, setPhase] = useState("loading"); - const [active, setActive] = useState(null); - - const hydrate = useCallback( - async (signal?: AbortSignal): Promise => { - try { - const rows = await apiTrpcClient.remoteControl.listForWorkspace.query({ - workspaceId, - }); - if (signal?.aborted) return; - const now = Date.now(); - const live = rows.find( - (r) => - r.terminalId === terminalId && - r.status === "active" && - new Date(r.expiresAt).getTime() > now, - ); - if (live) { - setActive((prev) => ({ - sessionId: live.sessionId, - // Preserve a previously-captured webUrl if we still have - // it for the same session (e.g., we minted it ourselves - // in this component lifetime). - webUrl: - prev && prev.sessionId === live.sessionId ? prev.webUrl : null, - expiresAt: live.expiresAt, - })); - setPhase((prev) => - prev === "creating" || prev === "revoking" ? prev : "active", - ); - } else { - setActive(null); - setPhase((prev) => - prev === "creating" || prev === "revoking" ? prev : "inactive", - ); - } - } catch { - // Silent — background refresh; the user still has the optimistic - // state from the last successful action. - if (!signal?.aborted) { - setPhase((prev) => (prev === "loading" ? "inactive" : prev)); - } - } - }, - [workspaceId, terminalId], - ); - - useEffect(() => { - // Don't run the cloud hydrate poll when the user isn't in the cohort - // — that just wastes a tRPC call every 30s for a button that won't - // render anyway. - if (!hasAccess) return; - const ac = new AbortController(); - void hydrate(ac.signal); - const timer = setInterval( - () => void hydrate(ac.signal), - HYDRATE_REFRESH_MS, - ); - return () => { - ac.abort(); - clearInterval(timer); - }; - }, [hydrate, hasAccess]); - - if (!hasAccess) return null; - - async function copyLink(url: string) { - try { - await navigator.clipboard.writeText(url); - toast.success("Remote control link copied", { - description: "Anyone with this link can control your terminal.", - }); - } catch { - toast.error("Failed to copy link to clipboard"); - } - } - - async function startShare() { - setPhase("creating"); - try { - const result = await apiTrpcClient.remoteControl.create.mutate({ - workspaceId, - terminalId, - mode: "full", - }); - setActive({ - sessionId: result.sessionId, - webUrl: result.webUrl, - expiresAt: result.expiresAt, - }); - setPhase("active"); - void copyLink(result.webUrl); - } catch (err) { - setPhase("inactive"); - toast.error( - `Failed to start remote control: ${err instanceof Error ? err.message : String(err)}`, - ); - } - } - - async function stopShare() { - if (!active) return; - setPhase("revoking"); - try { - await apiTrpcClient.remoteControl.revoke.mutate({ - sessionId: active.sessionId, - }); - setActive(null); - setPhase("inactive"); - toast.success("Remote control stopped"); - } catch (err) { - setPhase("active"); - toast.error( - `Failed to stop remote control: ${err instanceof Error ? err.message : String(err)}`, - ); - } - } - - if (phase === "loading") { - // Render the button but suppress the live badge until hydration - // completes — otherwise the badge flashes "off" on every remount even - // when a session is in fact still live. - return ( - - ); - } - - if (phase === "inactive" || phase === "creating") { - return ( - - - - - - {phase === "creating" ? "Starting…" : "Share remote control"} - - - ); - } - - const canCopy = Boolean(active?.webUrl); - - return ( - - - - - - { - // `window.open(url, "_blank")` is the convention used elsewhere - // in the renderer (e.g. DashboardSidebarHelpMenu) — Electron's - // main process intercepts and routes to the system browser - // so the share opens outside the Superset app. - if (active?.webUrl) window.open(active.webUrl, "_blank"); - }} - disabled={!canCopy} - > - - Open in browser - - { - if (active?.webUrl) void copyLink(active.webUrl); - }} - disabled={!canCopy} - > - {canCopy ? "Copy link" : "Link only available right after sharing"} - - void stopShare()} - disabled={phase === "revoking"} - className="text-destructive focus:text-destructive" - > - {phase === "revoking" ? "Stopping…" : "Stop sharing"} - - - - ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalRemoteControlButton/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalRemoteControlButton/index.ts deleted file mode 100644 index 193983d3115..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalRemoteControlButton/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { TerminalRemoteControlButton } from "./TerminalRemoteControlButton"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx index 5b5fcd59f21..5695ff12f9a 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx @@ -55,7 +55,6 @@ import { DiffPaneHeaderExtras } from "./components/DiffPane/components/DiffPaneH import { FilePane } from "./components/FilePane"; import { FilePaneHeaderExtras } from "./components/FilePane/components/FilePaneHeaderExtras"; import { TerminalPane } from "./components/TerminalPane"; -import { TerminalHeaderExtras } from "./components/TerminalPane/components/TerminalHeaderExtras"; import { TerminalPaneIcon } from "./components/TerminalPane/components/TerminalPaneIcon"; import { TerminalSessionDropdown } from "./components/TerminalPane/components/TerminalSessionDropdown"; @@ -348,9 +347,6 @@ export function usePaneRegistry({ /> ), - renderHeaderExtras: (ctx: RendererContext) => ( - - ), renderPane: (ctx: RendererContext) => ( { const redacted = typeof message === "string" @@ -271,35 +271,8 @@ app.get("/hosts/:hostId/_whoowns", async (c) => { }); // ── Host proxy (auth required) ────────────────────────────────────── -// -// Remote-control viewer WebSockets (`/hosts/:hostId/remote-control/*`) -// authenticate via a per-session HMAC `remoteControlToken` query param -// that is verified by the host-service, not by us. Skip the user-JWT -// gate for those paths only — the HMAC is the credential the cloud -// hands to viewers, who may not have a Superset user JWT in the URL. -// -// We must still run the tunnel-presence + maybeReplay logic that -// `authMiddleware` does, otherwise viewer links break in multi-region -// Fly deployments whenever the load balancer lands a request on a -// relay instance that doesn't own the destination tunnel. -app.use("/hosts/:hostId/*", async (c, next) => { - const path = new URL(c.req.url).pathname; - const hostId = c.req.param("hostId") ?? ""; - if (!hostId) return c.json({ error: "Missing hostId" }, 400); - const prefix = `/hosts/${hostId}`; - const rest = path.slice(prefix.length); - if (rest.startsWith("/remote-control/")) { - if (!tunnelManager.hasTunnel(hostId)) { - const replay = await maybeReplay(hostId); - if (replay) return c.body(null, 200, replay.header); - return c.json({ error: "Host not connected" }, 503); - } - c.set("hostId", hostId); - return next(); - } - return authMiddleware(c, next); -}); +app.use("/hosts/:hostId/*", authMiddleware); app.all("/hosts/:hostId/trpc/*", async (c) => { const hostId = c.get("hostId"); diff --git a/apps/web/src/app/(public)/agents/remote-control/[sessionId]/components/RemoteTerminal/RemoteTerminal.tsx b/apps/web/src/app/(public)/agents/remote-control/[sessionId]/components/RemoteTerminal/RemoteTerminal.tsx deleted file mode 100644 index 74f9d909318..00000000000 --- a/apps/web/src/app/(public)/agents/remote-control/[sessionId]/components/RemoteTerminal/RemoteTerminal.tsx +++ /dev/null @@ -1,504 +0,0 @@ -"use client"; - -import { - REMOTE_CONTROL_TOKEN_PARAM, - type RemoteControlClientMessage, - type RemoteControlMode, - type RemoteControlServerMessage, - type RemoteControlStatus, -} from "@superset/shared/remote-control-protocol"; -import { FitAddon } from "@xterm/addon-fit"; -import type { ITheme } from "@xterm/xterm"; -import { Terminal } from "@xterm/xterm"; -import "@xterm/xterm/css/xterm.css"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { MobileTerminalInput } from "../../../../../../../components/MobileTerminalInput"; -import { trpcClient } from "../../../../../../../trpc/client"; - -// Mirrors apps/desktop/src/shared/themes/built-in/ember.ts (id "dark") -// so the browser viewer renders the same palette as the desktop default -// terminal theme. Keep in sync if the desktop default changes. -const DESKTOP_DARK_TERMINAL_THEME: ITheme = { - background: "#151110", - foreground: "#eae8e6", - cursor: "#e07850", - cursorAccent: "#151110", - selectionBackground: "rgba(224, 120, 80, 0.25)", - black: "#151110", - red: "#dc6b6b", - green: "#7ec699", - yellow: "#e5c07b", - blue: "#61afef", - magenta: "#c678dd", - cyan: "#56b6c2", - white: "#eae8e6", - brightBlack: "#5c5856", - brightRed: "#e88888", - brightGreen: "#98d1a8", - brightYellow: "#ecd08f", - brightBlue: "#7ec0f5", - brightMagenta: "#d494e6", - brightCyan: "#73c7d3", - brightWhite: "#ffffff", -}; - -// Mirrors apps/desktop/src/renderer/lib/terminal/appearance/index.ts -// `DEFAULT_TERMINAL_FONT_FAMILIES` so we fall through the same Nerd Font -// stack as the desktop and only land on `monospace` when nothing else -// is installed. -const DESKTOP_TERMINAL_FONT_FAMILY = - '"JetBrains Mono", "JetBrainsMono Nerd Font", "MesloLGM Nerd Font", "MesloLGM NF", "MesloLGS NF", "MesloLGS Nerd Font", "Hack Nerd Font", "FiraCode Nerd Font", "CaskaydiaCove Nerd Font", "Menlo", "Monaco", "Courier New", monospace'; -const DESKTOP_TERMINAL_FONT_SIZE = 14; -const DESKTOP_TERMINAL_SCROLLBACK = 5000; - -interface RemoteTerminalProps { - sessionId: string; - token: string; -} - -type ConnectionState = - | "loading" - | "connecting" - | "open" - | "revoked" - | "expired" - | "exited" - | "error"; - -interface SessionMeta { - // `null` for revoked/expired sessions — cloud refuses to hand out - // a WS endpoint for non-active rows as defense-in-depth. - wsUrl: string | null; - mode: RemoteControlMode; - status: RemoteControlStatus; - terminalId: string; -} - -function bytesToBase64(bytes: Uint8Array): string { - let bin = ""; - for (const byte of bytes) { - bin += String.fromCharCode(byte); - } - return btoa(bin); -} - -function base64ToBytes(s: string): Uint8Array { - const bin = atob(s); - const bytes = new Uint8Array(bin.length); - for (let i = 0; i < bin.length; i += 1) bytes[i] = bin.charCodeAt(i); - return bytes; -} - -export function RemoteTerminal({ sessionId, token }: RemoteTerminalProps) { - const containerRef = useRef(null); - const termRef = useRef(null); - const fitRef = useRef(null); - const wsRef = useRef(null); - const pingTimerRef = useRef | null>(null); - - const [state, setState] = useState("loading"); - const [meta, setMeta] = useState(null); - const [title, setTitle] = useState(null); - const [errorMsg, setErrorMsg] = useState(null); - const [viewerCount, setViewerCount] = useState(null); - - const sendClientMessage = useCallback((msg: RemoteControlClientMessage) => { - const ws = wsRef.current; - if (!ws || ws.readyState !== WebSocket.OPEN) return; - ws.send(JSON.stringify(msg)); - }, []); - - useEffect(() => { - let cancelled = false; - (async () => { - try { - // `.mutate()` rather than `.query()` so the bearer token rides - // in the POST body. A query would put it in the URL. - const result = await trpcClient.remoteControl.get.mutate({ - sessionId, - token, - }); - if (cancelled) return; - if (result.status !== "active") { - setMeta({ - wsUrl: result.wsUrl, - mode: result.mode, - status: result.status, - terminalId: result.terminalId, - }); - setState( - result.status === "expired" - ? "expired" - : result.status === "revoked" - ? "revoked" - : "error", - ); - return; - } - setMeta({ - wsUrl: result.wsUrl, - mode: result.mode, - status: result.status, - terminalId: result.terminalId, - }); - setState("connecting"); - } catch (err) { - setErrorMsg(err instanceof Error ? err.message : String(err)); - setState("error"); - } - })(); - return () => { - cancelled = true; - }; - }, [sessionId, token]); - - useEffect(() => { - // Only run when we have a usable, active session. We deliberately do - // NOT depend on `state` — `setState("open")` would otherwise re-run - // this effect, triggering the cleanup which sends `stop` and closes - // the WS the moment it finishes connecting. - if (!meta || meta.status !== "active" || !meta.wsUrl) return; - if (!containerRef.current) return; - - // `vtExtensions` (kittyKeyboard) and `scrollbar` are only present on - // the desktop's xterm beta build; the stable web release omits them. - // Everything else here mirrors createTerminal in - // apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts. - const term = new Terminal({ - cursorBlink: true, - cursorStyle: "block", - cursorInactiveStyle: "outline", - fontFamily: DESKTOP_TERMINAL_FONT_FAMILY, - fontSize: DESKTOP_TERMINAL_FONT_SIZE, - scrollback: DESKTOP_TERMINAL_SCROLLBACK, - theme: DESKTOP_DARK_TERMINAL_THEME, - allowProposedApi: true, - macOptionIsMeta: false, - }); - const fit = new FitAddon(); - term.loadAddon(fit); - term.open(containerRef.current); - try { - fit.fit(); - } catch { - // container may not yet be sized - } - termRef.current = term; - fitRef.current = fit; - if (window.matchMedia("(pointer: coarse)").matches) { - const xtermInput = term.textarea; - if (xtermInput) { - xtermInput.readOnly = true; - xtermInput.inputMode = "none"; - xtermInput.tabIndex = -1; - } - } - - const url = `${meta.wsUrl}?${REMOTE_CONTROL_TOKEN_PARAM}=${encodeURIComponent(token)}`; - const ws = new WebSocket(url); - wsRef.current = ws; - - ws.onopen = () => { - setState("open"); - pingTimerRef.current = setInterval(() => { - sendClientMessage({ type: "ping" }); - }, 25_000); - }; - - ws.onmessage = (event) => { - let msg: RemoteControlServerMessage; - try { - msg = JSON.parse(String(event.data)) as RemoteControlServerMessage; - } catch { - return; - } - switch (msg.type) { - case "hello": - setTitle(msg.title); - try { - term.resize(msg.cols, msg.rows); - } catch { - // best-effort - } - return; - case "snapshot": - case "data": - term.write(base64ToBytes(msg.data)); - return; - case "title": - setTitle(msg.title); - return; - case "exit": - term.write( - `\r\n\x1b[33m[terminal exited code=${msg.exitCode} signal=${msg.signal}]\x1b[0m\r\n`, - ); - setState("exited"); - return; - case "revoked": - setState("revoked"); - return; - case "presence": - setViewerCount(msg.viewerCount); - return; - case "error": - setErrorMsg(`${msg.code}: ${msg.message}`); - return; - case "pong": - return; - } - }; - - ws.onclose = () => { - if (pingTimerRef.current) { - clearInterval(pingTimerRef.current); - pingTimerRef.current = null; - } - setState((prev) => - prev === "open" || prev === "connecting" ? "error" : prev, - ); - }; - - ws.onerror = () => { - setErrorMsg("WebSocket connection failed"); - }; - - // Show a one-time hint in `command` mode the first time the user - // types — otherwise keystrokes are silently dropped, with no local - // echo or feedback to explain why nothing happens. - let readOnlyHintShown = false; - const dataDispose = term.onData((data) => { - if (meta.mode !== "full") { - if (!readOnlyHintShown) { - readOnlyHintShown = true; - term.write( - "\r\n\x1b[90m[view-only — host shared this terminal in command mode]\x1b[0m\r\n", - ); - } - return; - } - const bytes = new TextEncoder().encode(data); - sendClientMessage({ type: "input", data: bytesToBase64(bytes) }); - }); - - // ResizeObserver can fire ~60Hz during a window-drag. The host - // enforces REMOTE_CONTROL_RESIZE_RATE_PER_SEC = 10, so an - // unthrottled broadcast trips the "rate-limited" error during - // normal use. We `fit()` the local terminal every event so the - // viewer feels responsive, but trailing-debounce the host - // broadcast at 200ms (5 Hz, well under the 10/s cap, and only - // fires once after the user stops dragging). - let pendingResize: { cols: number; rows: number } | null = null; - let resizeTimer: ReturnType | null = null; - const flushResize = () => { - resizeTimer = null; - if (!pendingResize) return; - const { cols, rows } = pendingResize; - pendingResize = null; - sendClientMessage({ type: "resize", cols, rows }); - }; - const onResize = () => { - if (!fitRef.current || !termRef.current) return; - try { - fitRef.current.fit(); - } catch { - return; - } - if (meta.mode !== "full") return; - pendingResize = { - cols: termRef.current.cols, - rows: termRef.current.rows, - }; - if (resizeTimer !== null) clearTimeout(resizeTimer); - resizeTimer = setTimeout(flushResize, 200); - }; - const ro = new ResizeObserver(onResize); - ro.observe(containerRef.current); - - return () => { - ro.disconnect(); - if (resizeTimer !== null) { - clearTimeout(resizeTimer); - resizeTimer = null; - } - pendingResize = null; - dataDispose.dispose(); - try { - sendClientMessage({ type: "stop" }); - } catch { - // best-effort - } - if (pingTimerRef.current) { - clearInterval(pingTimerRef.current); - pingTimerRef.current = null; - } - try { - ws.close(); - } catch { - // best-effort - } - term.dispose(); - termRef.current = null; - fitRef.current = null; - wsRef.current = null; - }; - }, [meta, token, sendClientMessage]); - - const sendInputBytes = useCallback( - (bytes: Uint8Array) => { - sendClientMessage({ type: "input", data: bytesToBase64(bytes) }); - }, - [sendClientMessage], - ); - - const sendInputText = useCallback( - (sequence: string) => { - sendInputBytes(new TextEncoder().encode(sequence)); - }, - [sendInputBytes], - ); - - const onCopyLink = useCallback(async () => { - try { - await navigator.clipboard.writeText(window.location.href); - } catch { - // best-effort - } - }, []); - - const onStop = useCallback(async () => { - try { - // `revoke` is a `protectedProcedure` (org member + host member) — - // anonymous viewers (the common case for shared links) wouldn't be - // able to call it. `revokeWithToken` is gated by the same HMAC the - // viewer used to attach, so anyone who can see the terminal can - // also stop it. - await trpcClient.remoteControl.revokeWithToken.mutate({ - sessionId, - token, - }); - } catch (err) { - setErrorMsg(err instanceof Error ? err.message : String(err)); - } - }, [sessionId, token]); - - const isFull = meta?.mode === "full" && state === "open"; - - return ( -
-
-
- - ← Back - - - {title ?? meta?.terminalId ?? "Remote terminal"} - - - {state} - - {meta && ( - - mode: {meta.mode} - - )} - {viewerCount !== null && ( - - viewers: {viewerCount} - - )} -
-
- - -
-
- {errorMsg && ( -
- {errorMsg} -
- )} - {state === "revoked" && ( -
- This session was revoked. -
- )} - {state === "expired" && ( -
- This session has expired. Ask the host to share a new link. -
- )} -
-
-
- {isFull && } -
- ); -} diff --git a/apps/web/src/app/(public)/agents/remote-control/[sessionId]/components/RemoteTerminal/index.ts b/apps/web/src/app/(public)/agents/remote-control/[sessionId]/components/RemoteTerminal/index.ts deleted file mode 100644 index 51dc50fc790..00000000000 --- a/apps/web/src/app/(public)/agents/remote-control/[sessionId]/components/RemoteTerminal/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { RemoteTerminal } from "./RemoteTerminal"; diff --git a/apps/web/src/app/(public)/agents/remote-control/[sessionId]/components/RemoteTerminalLoader/RemoteTerminalLoader.tsx b/apps/web/src/app/(public)/agents/remote-control/[sessionId]/components/RemoteTerminalLoader/RemoteTerminalLoader.tsx deleted file mode 100644 index bacc23dea82..00000000000 --- a/apps/web/src/app/(public)/agents/remote-control/[sessionId]/components/RemoteTerminalLoader/RemoteTerminalLoader.tsx +++ /dev/null @@ -1,65 +0,0 @@ -"use client"; - -import { REMOTE_CONTROL_TOKEN_PARAM } from "@superset/shared/remote-control-protocol"; -import { useEffect, useState } from "react"; -import { RemoteTerminal } from "../RemoteTerminal"; - -interface RemoteTerminalLoaderProps { - sessionId: string; -} - -function readTokenFromHash(): string | null { - if (typeof window === "undefined") return null; - const hash = window.location.hash.replace(/^#/, ""); - if (!hash) return null; - const params = new URLSearchParams(hash); - return params.get(REMOTE_CONTROL_TOKEN_PARAM); -} - -// Client-side wrapper that pulls the bearer token out of `location.hash`. -// The fragment never travels to the server, so the page itself can't see -// it on first render — we reach the fallback markup, then swap to the -// terminal component once the token is available. -export function RemoteTerminalLoader({ sessionId }: RemoteTerminalLoaderProps) { - const [token, setToken] = useState(null); - const [resolved, setResolved] = useState(false); - - useEffect(() => { - setToken(readTokenFromHash()); - setResolved(true); - const onHashChange = () => setToken(readTokenFromHash()); - window.addEventListener("hashchange", onHashChange); - return () => window.removeEventListener("hashchange", onHashChange); - }, []); - - if (!resolved) { - return ( -
- ); - } - - if (!token) { - return ( -
-
-

Remote control unavailable

-

- This link is missing its access token. Open the share link from the - original message to view the terminal. -

-
-
- ); - } - - return ; -} diff --git a/apps/web/src/app/(public)/agents/remote-control/[sessionId]/components/RemoteTerminalLoader/index.ts b/apps/web/src/app/(public)/agents/remote-control/[sessionId]/components/RemoteTerminalLoader/index.ts deleted file mode 100644 index 3909736c287..00000000000 --- a/apps/web/src/app/(public)/agents/remote-control/[sessionId]/components/RemoteTerminalLoader/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { RemoteTerminalLoader } from "./RemoteTerminalLoader"; diff --git a/apps/web/src/app/(public)/agents/remote-control/[sessionId]/page.tsx b/apps/web/src/app/(public)/agents/remote-control/[sessionId]/page.tsx deleted file mode 100644 index 0a71d84ef1c..00000000000 --- a/apps/web/src/app/(public)/agents/remote-control/[sessionId]/page.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { use } from "react"; -import { RemoteTerminalLoader } from "./components/RemoteTerminalLoader"; - -interface PageProps { - params: Promise<{ sessionId: string }>; -} - -export default function RemoteControlPage({ params }: PageProps) { - const { sessionId } = use(params); - // The bearer token is in `location.hash`, not the query string — keeping - // it out of server access logs, browser history's query, and `Referer` - // headers. `RemoteTerminalLoader` reads it client-side after mount. - return ; -} diff --git a/apps/web/src/proxy.ts b/apps/web/src/proxy.ts index e45df4bce16..d59910a7bf3 100644 --- a/apps/web/src/proxy.ts +++ b/apps/web/src/proxy.ts @@ -9,11 +9,6 @@ const publicRoutes = [ "/api/auth/desktop", "/accept-invitation", "/cli/auth/code", - // Anonymous remote-control viewers: the per-session HMAC in the URL - // fragment is the credential, not a Superset user session. The page - // itself lives outside `(agents)` so it doesn't hit the agents-only - // feature-flag gate either. - "/agents/remote-control/", ]; function isPublicRoute(pathname: string): boolean { diff --git a/packages/db/drizzle/0057_drop_remote_control_sessions.sql b/packages/db/drizzle/0057_drop_remote_control_sessions.sql new file mode 100644 index 00000000000..921f89de188 --- /dev/null +++ b/packages/db/drizzle/0057_drop_remote_control_sessions.sql @@ -0,0 +1,3 @@ +DROP TABLE "v2_remote_control_sessions" CASCADE;--> statement-breakpoint +DROP TYPE "public"."remote_control_session_mode";--> statement-breakpoint +DROP TYPE "public"."remote_control_session_status"; \ No newline at end of file diff --git a/packages/db/drizzle/meta/0057_snapshot.json b/packages/db/drizzle/meta/0057_snapshot.json new file mode 100644 index 00000000000..9a844ea412d --- /dev/null +++ b/packages/db/drizzle/meta/0057_snapshot.json @@ -0,0 +1,6500 @@ +{ + "id": "6b9a3714-55d5-472d-9b8c-bcef57a6c385", + "prevId": "ada52a0b-8b55-463d-b8d9-6f7ddf4f2f97", + "version": "7", + "dialect": "postgresql", + "tables": { + "auth.accounts": { + "name": "accounts", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "accounts_user_id_idx": { + "name": "accounts_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.apikeys": { + "name": "apikeys", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 86400000 + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_request": { + "name": "last_request", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE\n\t\t\t\tWHEN metadata IS NULL OR metadata = '' THEN NULL\n\t\t\t\tWHEN NOT (metadata IS JSON OBJECT) THEN NULL\n\t\t\t\tWHEN (metadata::jsonb->>'organizationId') ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'\n\t\t\t\t\tTHEN (metadata::jsonb->>'organizationId')::uuid\n\t\t\t\tELSE NULL\n\t\t\tEND", + "type": "stored" + } + } + }, + "indexes": { + "apikeys_configId_idx": { + "name": "apikeys_configId_idx", + "columns": [ + { + "expression": "config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "apikeys_referenceId_idx": { + "name": "apikeys_referenceId_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "apikeys_key_idx": { + "name": "apikeys_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "apikeys_organization_id_idx": { + "name": "apikeys_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "apikeys_metadata_trgm_idx": { + "name": "apikeys_metadata_trgm_idx", + "columns": [ + { + "expression": "\"metadata\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.device_codes": { + "name": "device_codes", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "device_code": { + "name": "device_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_code": { + "name": "user_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_polled_at": { + "name": "last_polled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "polling_interval": { + "name": "polling_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.invitations": { + "name": "invitations", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "inviter_id": { + "name": "inviter_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "invitations_organization_id_idx": { + "name": "invitations_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitations_email_idx": { + "name": "invitations_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitations_organization_id_organizations_id_fk": { + "name": "invitations_organization_id_organizations_id_fk", + "tableFrom": "invitations", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitations_inviter_id_users_id_fk": { + "name": "invitations_inviter_id_users_id_fk", + "tableFrom": "invitations", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitations_team_id_teams_id_fk": { + "name": "invitations_team_id_teams_id_fk", + "tableFrom": "invitations", + "tableTo": "teams", + "schemaTo": "auth", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.jwkss": { + "name": "jwkss", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.members": { + "name": "members", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "members_organization_id_idx": { + "name": "members_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "members_user_id_idx": { + "name": "members_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "members_organization_id_organizations_id_fk": { + "name": "members_organization_id_organizations_id_fk", + "tableFrom": "members", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "members_user_id_users_id_fk": { + "name": "members_user_id_users_id_fk", + "tableFrom": "members", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.oauth_access_tokens": { + "name": "oauth_access_tokens", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_id": { + "name": "refresh_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_access_tokens_client_id_oauth_clients_client_id_fk": { + "name": "oauth_access_tokens_client_id_oauth_clients_client_id_fk", + "tableFrom": "oauth_access_tokens", + "tableTo": "oauth_clients", + "schemaTo": "auth", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_tokens_session_id_sessions_id_fk": { + "name": "oauth_access_tokens_session_id_sessions_id_fk", + "tableFrom": "oauth_access_tokens", + "tableTo": "sessions", + "schemaTo": "auth", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_access_tokens_user_id_users_id_fk": { + "name": "oauth_access_tokens_user_id_users_id_fk", + "tableFrom": "oauth_access_tokens", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_tokens_refresh_id_oauth_refresh_tokens_id_fk": { + "name": "oauth_access_tokens_refresh_id_oauth_refresh_tokens_id_fk", + "tableFrom": "oauth_access_tokens", + "tableTo": "oauth_refresh_tokens", + "schemaTo": "auth", + "columnsFrom": [ + "refresh_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_tokens_token_unique": { + "name": "oauth_access_tokens_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.oauth_clients": { + "name": "oauth_clients", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "skip_consent": { + "name": "skip_consent", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enable_end_session": { + "name": "enable_end_session", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contacts": { + "name": "contacts", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "tos": { + "name": "tos", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_id": { + "name": "software_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_version": { + "name": "software_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_statement": { + "name": "software_statement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "post_logout_redirect_uris": { + "name": "post_logout_redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_types": { + "name": "grant_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "response_types": { + "name": "response_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "require_pkce": { + "name": "require_pkce", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_clients_user_id_users_id_fk": { + "name": "oauth_clients_user_id_users_id_fk", + "tableFrom": "oauth_clients", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_clients_client_id_unique": { + "name": "oauth_clients_client_id_unique", + "nullsNotDistinct": false, + "columns": [ + "client_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.oauth_consents": { + "name": "oauth_consents", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_consents_client_id_oauth_clients_client_id_fk": { + "name": "oauth_consents_client_id_oauth_clients_client_id_fk", + "tableFrom": "oauth_consents", + "tableTo": "oauth_clients", + "schemaTo": "auth", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consents_user_id_users_id_fk": { + "name": "oauth_consents_user_id_users_id_fk", + "tableFrom": "oauth_consents", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.oauth_refresh_tokens": { + "name": "oauth_refresh_tokens", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked": { + "name": "revoked", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "auth_time": { + "name": "auth_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_refresh_tokens_client_id_oauth_clients_client_id_fk": { + "name": "oauth_refresh_tokens_client_id_oauth_clients_client_id_fk", + "tableFrom": "oauth_refresh_tokens", + "tableTo": "oauth_clients", + "schemaTo": "auth", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_tokens_session_id_sessions_id_fk": { + "name": "oauth_refresh_tokens_session_id_sessions_id_fk", + "tableFrom": "oauth_refresh_tokens", + "tableTo": "sessions", + "schemaTo": "auth", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_refresh_tokens_user_id_users_id_fk": { + "name": "oauth_refresh_tokens_user_id_users_id_fk", + "tableFrom": "oauth_refresh_tokens", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.organizations": { + "name": "organizations", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_domains": { + "name": "allowed_domains", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + } + }, + "indexes": { + "organizations_slug_idx": { + "name": "organizations_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "organizations_allowed_domains_idx": { + "name": "organizations_allowed_domains_idx", + "columns": [ + { + "expression": "allowed_domains", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.sessions": { + "name": "sessions", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "active_team_id": { + "name": "active_team_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.team_members": { + "name": "team_members", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "team_members_team_id_idx": { + "name": "team_members_team_id_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_members_user_id_idx": { + "name": "team_members_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_members_organization_id_idx": { + "name": "team_members_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_members_team_user_unique": { + "name": "team_members_team_user_unique", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_members_team_id_teams_id_fk": { + "name": "team_members_team_id_teams_id_fk", + "tableFrom": "team_members", + "tableTo": "teams", + "schemaTo": "auth", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_members_user_id_users_id_fk": { + "name": "team_members_user_id_users_id_fk", + "tableFrom": "team_members", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_members_organization_id_organizations_id_fk": { + "name": "team_members_organization_id_organizations_id_fk", + "tableFrom": "team_members", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.teams": { + "name": "teams", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "teams_organization_id_idx": { + "name": "teams_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "teams_org_slug_unique": { + "name": "teams_org_slug_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "teams_organization_id_organizations_id_fk": { + "name": "teams_organization_id_organizations_id_fk", + "tableFrom": "teams", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.users": { + "name": "users", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_ids": { + "name": "organization_ids", + "type": "uuid[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "onboarded_at": { + "name": "onboarded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_organization_ids_idx": { + "name": "users_organization_ids_idx", + "columns": [ + { + "expression": "organization_ids", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.verifications": { + "name": "verifications", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verifications_identifier_idx": { + "name": "verifications_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_installations": { + "name": "github_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "connected_by_user_id": { + "name": "connected_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_login": { + "name": "account_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_type": { + "name": "account_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "suspended": { + "name": "suspended", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_installations_installation_id_idx": { + "name": "github_installations_installation_id_idx", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_installations_organization_id_organizations_id_fk": { + "name": "github_installations_organization_id_organizations_id_fk", + "tableFrom": "github_installations", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_installations_connected_by_user_id_users_id_fk": { + "name": "github_installations_connected_by_user_id_users_id_fk", + "tableFrom": "github_installations", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "connected_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "github_installations_installation_id_unique": { + "name": "github_installations_installation_id_unique", + "nullsNotDistinct": false, + "columns": [ + "installation_id" + ] + }, + "github_installations_org_unique": { + "name": "github_installations_org_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_pull_requests": { + "name": "github_pull_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "node_id": { + "name": "node_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "head_branch": { + "name": "head_branch", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "head_sha": { + "name": "head_sha", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "base_branch": { + "name": "base_branch", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_login": { + "name": "author_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_avatar_url": { + "name": "author_avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_draft": { + "name": "is_draft", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "additions": { + "name": "additions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "deletions": { + "name": "deletions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "changed_files": { + "name": "changed_files", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "review_decision": { + "name": "review_decision", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checks_status": { + "name": "checks_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "checks": { + "name": "checks", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "merged_at": { + "name": "merged_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_pull_requests_repository_id_idx": { + "name": "github_pull_requests_repository_id_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_pull_requests_state_idx": { + "name": "github_pull_requests_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_pull_requests_head_branch_idx": { + "name": "github_pull_requests_head_branch_idx", + "columns": [ + { + "expression": "head_branch", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_pull_requests_org_id_idx": { + "name": "github_pull_requests_org_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_pull_requests_repository_id_github_repositories_id_fk": { + "name": "github_pull_requests_repository_id_github_repositories_id_fk", + "tableFrom": "github_pull_requests", + "tableTo": "github_repositories", + "columnsFrom": [ + "repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_pull_requests_organization_id_organizations_id_fk": { + "name": "github_pull_requests_organization_id_organizations_id_fk", + "tableFrom": "github_pull_requests", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "github_pull_requests_repo_pr_unique": { + "name": "github_pull_requests_repo_pr_unique", + "nullsNotDistinct": false, + "columns": [ + "repository_id", + "pr_number" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_repositories": { + "name": "github_repositories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "installation_id": { + "name": "installation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "repo_id": { + "name": "repo_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "full_name": { + "name": "full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'main'" + }, + "is_private": { + "name": "is_private", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_repositories_installation_id_idx": { + "name": "github_repositories_installation_id_idx", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_repositories_full_name_idx": { + "name": "github_repositories_full_name_idx", + "columns": [ + { + "expression": "full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_repositories_org_id_idx": { + "name": "github_repositories_org_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_repositories_installation_id_github_installations_id_fk": { + "name": "github_repositories_installation_id_github_installations_id_fk", + "tableFrom": "github_repositories", + "tableTo": "github_installations", + "columnsFrom": [ + "installation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_repositories_organization_id_organizations_id_fk": { + "name": "github_repositories_organization_id_organizations_id_fk", + "tableFrom": "github_repositories", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "github_repositories_repo_id_unique": { + "name": "github_repositories_repo_id_unique", + "nullsNotDistinct": false, + "columns": [ + "repo_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "ingest.webhook_events": { + "name": "webhook_events", + "schema": "ingest", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "integration_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "retry_count": { + "name": "retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "received_at": { + "name": "received_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "webhook_events_provider_status_idx": { + "name": "webhook_events_provider_status_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_events_provider_event_id_idx": { + "name": "webhook_events_provider_event_id_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_events_received_at_idx": { + "name": "webhook_events_received_at_idx", + "columns": [ + { + "expression": "received_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_commands": { + "name": "agent_commands", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_device_id": { + "name": "target_device_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_device_type": { + "name": "target_device_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool": { + "name": "tool", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "parent_command_id": { + "name": "parent_command_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "command_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "executed_at": { + "name": "executed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "timeout_at": { + "name": "timeout_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "agent_commands_user_status_idx": { + "name": "agent_commands_user_status_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_commands_target_device_status_idx": { + "name": "agent_commands_target_device_status_idx", + "columns": [ + { + "expression": "target_device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_commands_org_created_idx": { + "name": "agent_commands_org_created_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_commands_user_id_users_id_fk": { + "name": "agent_commands_user_id_users_id_fk", + "tableFrom": "agent_commands", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_commands_organization_id_organizations_id_fk": { + "name": "agent_commands_organization_id_organizations_id_fk", + "tableFrom": "agent_commands", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.automation_prompt_versions": { + "name": "automation_prompt_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "automation_id": { + "name": "automation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_user_id": { + "name": "author_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "window_bucket": { + "name": "window_bucket", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "automation_prompt_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "restored_from_version_id": { + "name": "restored_from_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "automation_prompt_versions_bucket_uniq": { + "name": "automation_prompt_versions_bucket_uniq", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "author_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "window_bucket", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"automation_prompt_versions\".\"source\" <> 'restore'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "automation_prompt_versions_automation_idx": { + "name": "automation_prompt_versions_automation_idx", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "automation_prompt_versions_automation_id_automations_id_fk": { + "name": "automation_prompt_versions_automation_id_automations_id_fk", + "tableFrom": "automation_prompt_versions", + "tableTo": "automations", + "columnsFrom": [ + "automation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "automation_prompt_versions_author_user_id_users_id_fk": { + "name": "automation_prompt_versions_author_user_id_users_id_fk", + "tableFrom": "automation_prompt_versions", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "author_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "automation_prompt_versions_restored_from_version_id_fk": { + "name": "automation_prompt_versions_restored_from_version_id_fk", + "tableFrom": "automation_prompt_versions", + "tableTo": "automation_prompt_versions", + "columnsFrom": [ + "restored_from_version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.automation_runs": { + "name": "automation_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "automation_id": { + "name": "automation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scheduled_for": { + "name": "scheduled_for", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "v2_workspace_id": { + "name": "v2_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "session_kind": { + "name": "session_kind", + "type": "automation_session_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "chat_session_id": { + "name": "chat_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "terminal_session_id": { + "name": "terminal_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "automation_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dispatched_at": { + "name": "dispatched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "automation_runs_dedup_idx": { + "name": "automation_runs_dedup_idx", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scheduled_for", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "automation_runs_history_idx": { + "name": "automation_runs_history_idx", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "automation_runs_status_idx": { + "name": "automation_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "automation_runs_workspace_idx": { + "name": "automation_runs_workspace_idx", + "columns": [ + { + "expression": "v2_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "automation_runs_automation_id_automations_id_fk": { + "name": "automation_runs_automation_id_automations_id_fk", + "tableFrom": "automation_runs", + "tableTo": "automations", + "columnsFrom": [ + "automation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "automation_runs_organization_id_organizations_id_fk": { + "name": "automation_runs_organization_id_organizations_id_fk", + "tableFrom": "automation_runs", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "automation_runs_chat_session_id_chat_sessions_id_fk": { + "name": "automation_runs_chat_session_id_chat_sessions_id_fk", + "tableFrom": "automation_runs", + "tableTo": "chat_sessions", + "columnsFrom": [ + "chat_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.automations": { + "name": "automations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_host_id": { + "name": "target_host_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "v2_project_id": { + "name": "v2_project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "v2_workspace_id": { + "name": "v2_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "rrule": { + "name": "rrule", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dtstart": { + "name": "dtstart", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "mcp_scope": { + "name": "mcp_scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "automations_dispatcher_idx": { + "name": "automations_dispatcher_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "automations_owner_idx": { + "name": "automations_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "automations_organization_idx": { + "name": "automations_organization_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "automations_organization_id_organizations_id_fk": { + "name": "automations_organization_id_organizations_id_fk", + "tableFrom": "automations", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "automations_owner_user_id_users_id_fk": { + "name": "automations_owner_user_id_users_id_fk", + "tableFrom": "automations", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "automations_v2_project_id_v2_projects_id_fk": { + "name": "automations_v2_project_id_v2_projects_id_fk", + "tableFrom": "automations", + "tableTo": "v2_projects", + "columnsFrom": [ + "v2_project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_attachments": { + "name": "chat_attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_session_id": { + "name": "chat_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "blob_pathname": { + "name": "blob_pathname", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "media_type": { + "name": "media_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_attachments_session_idx": { + "name": "chat_attachments_session_idx", + "columns": [ + { + "expression": "chat_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_attachments_created_by_idx": { + "name": "chat_attachments_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_attachments_chat_session_id_chat_sessions_id_fk": { + "name": "chat_attachments_chat_session_id_chat_sessions_id_fk", + "tableFrom": "chat_attachments", + "tableTo": "chat_sessions", + "columnsFrom": [ + "chat_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_attachments_created_by_users_id_fk": { + "name": "chat_attachments_created_by_users_id_fk", + "tableFrom": "chat_attachments", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_attachments_organization_id_organizations_id_fk": { + "name": "chat_attachments_organization_id_organizations_id_fk", + "tableFrom": "chat_attachments", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_sessions": { + "name": "chat_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "v2_workspace_id": { + "name": "v2_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_active_at": { + "name": "last_active_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_sessions_org_idx": { + "name": "chat_sessions_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_sessions_created_by_idx": { + "name": "chat_sessions_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_sessions_last_active_idx": { + "name": "chat_sessions_last_active_idx", + "columns": [ + { + "expression": "last_active_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_sessions_organization_id_organizations_id_fk": { + "name": "chat_sessions_organization_id_organizations_id_fk", + "tableFrom": "chat_sessions", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_sessions_created_by_users_id_fk": { + "name": "chat_sessions_created_by_users_id_fk", + "tableFrom": "chat_sessions", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_sessions_workspace_id_workspaces_id_fk": { + "name": "chat_sessions_workspace_id_workspaces_id_fk", + "tableFrom": "chat_sessions", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "chat_sessions_v2_workspace_id_v2_workspaces_id_fk": { + "name": "chat_sessions_v2_workspace_id_v2_workspaces_id_fk", + "tableFrom": "chat_sessions", + "tableTo": "v2_workspaces", + "columnsFrom": [ + "v2_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_presence": { + "name": "device_presence", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_name": { + "name": "device_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_type": { + "name": "device_type", + "type": "device_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "device_presence_user_org_idx": { + "name": "device_presence_user_org_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_presence_user_device_idx": { + "name": "device_presence_user_device_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_presence_last_seen_idx": { + "name": "device_presence_last_seen_idx", + "columns": [ + { + "expression": "last_seen_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "device_presence_user_id_users_id_fk": { + "name": "device_presence_user_id_users_id_fk", + "tableFrom": "device_presence", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "device_presence_organization_id_organizations_id_fk": { + "name": "device_presence_organization_id_organizations_id_fk", + "tableFrom": "device_presence", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration_connections": { + "name": "integration_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "connected_by_user_id": { + "name": "connected_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "integration_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_expires_at": { + "name": "token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "disconnected_at": { + "name": "disconnected_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "disconnect_reason": { + "name": "disconnect_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_org_id": { + "name": "external_org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_org_name": { + "name": "external_org_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "integration_connections_slack_external_org_active_unique": { + "name": "integration_connections_slack_external_org_active_unique", + "columns": [ + { + "expression": "external_org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"integration_connections\".\"provider\" = 'slack' AND \"integration_connections\".\"disconnected_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "integration_connections_org_idx": { + "name": "integration_connections_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "integration_connections_organization_id_organizations_id_fk": { + "name": "integration_connections_organization_id_organizations_id_fk", + "tableFrom": "integration_connections", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integration_connections_connected_by_user_id_users_id_fk": { + "name": "integration_connections_connected_by_user_id_users_id_fk", + "tableFrom": "integration_connections", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "connected_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "integration_connections_unique": { + "name": "integration_connections_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "provider" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github_repository_id": { + "name": "github_repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "repo_owner": { + "name": "repo_owner", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_name": { + "name": "repo_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_url": { + "name": "repo_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'main'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "projects_organization_id_idx": { + "name": "projects_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_organization_id_organizations_id_fk": { + "name": "projects_organization_id_organizations_id_fk", + "tableFrom": "projects", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "projects_github_repository_id_github_repositories_id_fk": { + "name": "projects_github_repository_id_github_repositories_id_fk", + "tableFrom": "projects", + "tableTo": "github_repositories", + "columnsFrom": [ + "github_repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "projects_org_slug_unique": { + "name": "projects_org_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandbox_images": { + "name": "sandbox_images", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "setup_commands": { + "name": "setup_commands", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "base_image": { + "name": "base_image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "system_packages": { + "name": "system_packages", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sandbox_images_organization_id_idx": { + "name": "sandbox_images_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sandbox_images_organization_id_organizations_id_fk": { + "name": "sandbox_images_organization_id_organizations_id_fk", + "tableFrom": "sandbox_images", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sandbox_images_project_id_projects_id_fk": { + "name": "sandbox_images_project_id_projects_id_fk", + "tableFrom": "sandbox_images", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sandbox_images_project_unique": { + "name": "sandbox_images_project_unique", + "nullsNotDistinct": false, + "columns": [ + "project_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.secrets": { + "name": "secrets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sensitive": { + "name": "sensitive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "secrets_project_id_idx": { + "name": "secrets_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "secrets_organization_id_idx": { + "name": "secrets_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "secrets_organization_id_organizations_id_fk": { + "name": "secrets_organization_id_organizations_id_fk", + "tableFrom": "secrets", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "secrets_project_id_projects_id_fk": { + "name": "secrets_project_id_projects_id_fk", + "tableFrom": "secrets", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "secrets_created_by_user_id_users_id_fk": { + "name": "secrets_created_by_user_id_users_id_fk", + "tableFrom": "secrets", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "secrets_project_key_unique": { + "name": "secrets_project_key_unique", + "nullsNotDistinct": false, + "columns": [ + "project_id", + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.submitted_prompts": { + "name": "submitted_prompts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "prompt_text": { + "name": "prompt_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "submitter_name": { + "name": "submitter_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "submitted_prompts_user_id_idx": { + "name": "submitted_prompts_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "submitted_prompts_organization_id_idx": { + "name": "submitted_prompts_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "submitted_prompts_created_at_idx": { + "name": "submitted_prompts_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "submitted_prompts_user_id_users_id_fk": { + "name": "submitted_prompts_user_id_users_id_fk", + "tableFrom": "submitted_prompts", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "submitted_prompts_organization_id_organizations_id_fk": { + "name": "submitted_prompts_organization_id_organizations_id_fk", + "tableFrom": "submitted_prompts", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscriptions": { + "name": "subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'incomplete'" + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "cancel_at": { + "name": "cancel_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "subscriptions_reference_id_idx": { + "name": "subscriptions_reference_id_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "subscriptions_stripe_customer_id_idx": { + "name": "subscriptions_stripe_customer_id_idx", + "columns": [ + { + "expression": "stripe_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "subscriptions_status_idx": { + "name": "subscriptions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "subscriptions_reference_id_organizations_id_fk": { + "name": "subscriptions_reference_id_organizations_id_fk", + "tableFrom": "subscriptions", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "reference_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_statuses": { + "name": "task_statuses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "progress_percent": { + "name": "progress_percent", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "external_provider": { + "name": "external_provider", + "type": "integration_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_statuses_organization_id_idx": { + "name": "task_statuses_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_statuses_type_idx": { + "name": "task_statuses_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_statuses_organization_id_organizations_id_fk": { + "name": "task_statuses_organization_id_organizations_id_fk", + "tableFrom": "task_statuses", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "task_statuses_org_external_unique": { + "name": "task_statuses_org_external_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "external_provider", + "external_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tasks": { + "name": "tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_id": { + "name": "status_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "task_priority", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "assignee_id": { + "name": "assignee_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "creator_id": { + "name": "creator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "estimate": { + "name": "estimate", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "due_date": { + "name": "due_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "labels": { + "name": "labels", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_provider": { + "name": "external_provider", + "type": "integration_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_key": { + "name": "external_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_url": { + "name": "external_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "sync_error": { + "name": "sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignee_external_id": { + "name": "assignee_external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignee_display_name": { + "name": "assignee_display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignee_avatar_url": { + "name": "assignee_avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tasks_slug_idx": { + "name": "tasks_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_organization_id_idx": { + "name": "tasks_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_assignee_id_idx": { + "name": "tasks_assignee_id_idx", + "columns": [ + { + "expression": "assignee_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_creator_id_idx": { + "name": "tasks_creator_id_idx", + "columns": [ + { + "expression": "creator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_status_id_idx": { + "name": "tasks_status_id_idx", + "columns": [ + { + "expression": "status_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_created_at_idx": { + "name": "tasks_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_external_provider_idx": { + "name": "tasks_external_provider_idx", + "columns": [ + { + "expression": "external_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_assignee_external_id_idx": { + "name": "tasks_assignee_external_id_idx", + "columns": [ + { + "expression": "assignee_external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tasks_status_id_task_statuses_id_fk": { + "name": "tasks_status_id_task_statuses_id_fk", + "tableFrom": "tasks", + "tableTo": "task_statuses", + "columnsFrom": [ + "status_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "tasks_organization_id_organizations_id_fk": { + "name": "tasks_organization_id_organizations_id_fk", + "tableFrom": "tasks", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_assignee_id_users_id_fk": { + "name": "tasks_assignee_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tasks_creator_id_users_id_fk": { + "name": "tasks_creator_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tasks_external_unique": { + "name": "tasks_external_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "external_provider", + "external_id" + ] + }, + "tasks_org_slug_unique": { + "name": "tasks_org_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users__slack_users": { + "name": "users__slack_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slack_user_id": { + "name": "slack_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "model_preference": { + "name": "model_preference", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users__slack_users_user_idx": { + "name": "users__slack_users_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users__slack_users_org_idx": { + "name": "users__slack_users_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "users__slack_users_user_id_users_id_fk": { + "name": "users__slack_users_user_id_users_id_fk", + "tableFrom": "users__slack_users", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "users__slack_users_organization_id_organizations_id_fk": { + "name": "users__slack_users_organization_id_organizations_id_fk", + "tableFrom": "users__slack_users", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users__slack_users_unique": { + "name": "users__slack_users_unique", + "nullsNotDistinct": false, + "columns": [ + "slack_user_id", + "team_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.v2_clients": { + "name": "v2_clients", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "machine_id": { + "name": "machine_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "v2_client_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "v2_clients_organization_id_idx": { + "name": "v2_clients_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "v2_clients_user_id_idx": { + "name": "v2_clients_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "v2_clients_organization_id_organizations_id_fk": { + "name": "v2_clients_organization_id_organizations_id_fk", + "tableFrom": "v2_clients", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "v2_clients_user_id_users_id_fk": { + "name": "v2_clients_user_id_users_id_fk", + "tableFrom": "v2_clients", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "v2_clients_organization_id_user_id_machine_id_pk": { + "name": "v2_clients_organization_id_user_id_machine_id_pk", + "columns": [ + "organization_id", + "user_id", + "machine_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.v2_hosts": { + "name": "v2_hosts", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "machine_id": { + "name": "machine_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_online": { + "name": "is_online", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "v2_hosts_organization_id_idx": { + "name": "v2_hosts_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "v2_hosts_organization_id_organizations_id_fk": { + "name": "v2_hosts_organization_id_organizations_id_fk", + "tableFrom": "v2_hosts", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "v2_hosts_created_by_user_id_users_id_fk": { + "name": "v2_hosts_created_by_user_id_users_id_fk", + "tableFrom": "v2_hosts", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "v2_hosts_organization_id_machine_id_pk": { + "name": "v2_hosts_organization_id_machine_id_pk", + "columns": [ + "organization_id", + "machine_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.v2_projects": { + "name": "v2_projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_clone_url": { + "name": "repo_clone_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_repository_id": { + "name": "github_repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "v2_projects_organization_id_idx": { + "name": "v2_projects_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "v2_projects_organization_id_organizations_id_fk": { + "name": "v2_projects_organization_id_organizations_id_fk", + "tableFrom": "v2_projects", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "v2_projects_github_repository_id_github_repositories_id_fk": { + "name": "v2_projects_github_repository_id_github_repositories_id_fk", + "tableFrom": "v2_projects", + "tableTo": "github_repositories", + "columnsFrom": [ + "github_repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "v2_projects_org_slug_unique": { + "name": "v2_projects_org_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.v2_users_hosts": { + "name": "v2_users_hosts", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "v2_users_host_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "v2_users_hosts_organization_id_idx": { + "name": "v2_users_hosts_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "v2_users_hosts_user_id_idx": { + "name": "v2_users_hosts_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "v2_users_hosts_host_id_idx": { + "name": "v2_users_hosts_host_id_idx", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "v2_users_hosts_organization_id_organizations_id_fk": { + "name": "v2_users_hosts_organization_id_organizations_id_fk", + "tableFrom": "v2_users_hosts", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "v2_users_hosts_user_id_users_id_fk": { + "name": "v2_users_hosts_user_id_users_id_fk", + "tableFrom": "v2_users_hosts", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "v2_users_hosts_host_fk": { + "name": "v2_users_hosts_host_fk", + "tableFrom": "v2_users_hosts", + "tableTo": "v2_hosts", + "columnsFrom": [ + "organization_id", + "host_id" + ], + "columnsTo": [ + "organization_id", + "machine_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "v2_users_hosts_organization_id_user_id_host_id_pk": { + "name": "v2_users_hosts_organization_id_user_id_host_id_pk", + "columns": [ + "organization_id", + "user_id", + "host_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.v2_workspaces": { + "name": "v2_workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "v2_workspace_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'worktree'" + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "task_id": { + "name": "task_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "v2_workspaces_project_id_idx": { + "name": "v2_workspaces_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "v2_workspaces_organization_id_idx": { + "name": "v2_workspaces_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "v2_workspaces_host_id_idx": { + "name": "v2_workspaces_host_id_idx", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "v2_workspaces_task_id_idx": { + "name": "v2_workspaces_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "v2_workspaces_one_main_per_host": { + "name": "v2_workspaces_one_main_per_host", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"v2_workspaces\".\"type\" = 'main'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "v2_workspaces_organization_id_organizations_id_fk": { + "name": "v2_workspaces_organization_id_organizations_id_fk", + "tableFrom": "v2_workspaces", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "v2_workspaces_project_id_v2_projects_id_fk": { + "name": "v2_workspaces_project_id_v2_projects_id_fk", + "tableFrom": "v2_workspaces", + "tableTo": "v2_projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "v2_workspaces_created_by_user_id_users_id_fk": { + "name": "v2_workspaces_created_by_user_id_users_id_fk", + "tableFrom": "v2_workspaces", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "v2_workspaces_task_id_tasks_id_fk": { + "name": "v2_workspaces_task_id_tasks_id_fk", + "tableFrom": "v2_workspaces", + "tableTo": "tasks", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "v2_workspaces_host_fk": { + "name": "v2_workspaces_host_fk", + "tableFrom": "v2_workspaces", + "tableTo": "v2_hosts", + "columnsFrom": [ + "organization_id", + "host_id" + ], + "columnsTo": [ + "organization_id", + "machine_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspaces": { + "name": "workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "workspace_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspaces_project_id_idx": { + "name": "workspaces_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspaces_organization_id_idx": { + "name": "workspaces_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspaces_type_idx": { + "name": "workspaces_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspaces_organization_id_organizations_id_fk": { + "name": "workspaces_organization_id_organizations_id_fk", + "tableFrom": "workspaces", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspaces_project_id_projects_id_fk": { + "name": "workspaces_project_id_projects_id_fk", + "tableFrom": "workspaces", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspaces_created_by_user_id_users_id_fk": { + "name": "workspaces_created_by_user_id_users_id_fk", + "tableFrom": "workspaces", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.automation_prompt_source": { + "name": "automation_prompt_source", + "schema": "public", + "values": [ + "human", + "agent", + "restore" + ] + }, + "public.automation_run_status": { + "name": "automation_run_status", + "schema": "public", + "values": [ + "dispatching", + "dispatched", + "skipped_offline", + "dispatch_failed" + ] + }, + "public.automation_session_kind": { + "name": "automation_session_kind", + "schema": "public", + "values": [ + "chat", + "terminal" + ] + }, + "public.command_status": { + "name": "command_status", + "schema": "public", + "values": [ + "pending", + "completed", + "failed", + "timeout" + ] + }, + "public.device_type": { + "name": "device_type", + "schema": "public", + "values": [ + "desktop", + "mobile", + "web" + ] + }, + "public.integration_provider": { + "name": "integration_provider", + "schema": "public", + "values": [ + "linear", + "github", + "slack" + ] + }, + "public.task_priority": { + "name": "task_priority", + "schema": "public", + "values": [ + "urgent", + "high", + "medium", + "low", + "none" + ] + }, + "public.task_status": { + "name": "task_status", + "schema": "public", + "values": [ + "backlog", + "todo", + "planning", + "working", + "needs-feedback", + "ready-to-merge", + "completed", + "canceled" + ] + }, + "public.v2_client_type": { + "name": "v2_client_type", + "schema": "public", + "values": [ + "desktop", + "mobile", + "web" + ] + }, + "public.v2_users_host_role": { + "name": "v2_users_host_role", + "schema": "public", + "values": [ + "owner", + "member" + ] + }, + "public.v2_workspace_type": { + "name": "v2_workspace_type", + "schema": "public", + "values": [ + "main", + "worktree" + ] + }, + "public.workspace_type": { + "name": "workspace_type", + "schema": "public", + "values": [ + "local", + "cloud" + ] + } + }, + "schemas": { + "auth": "auth", + "ingest": "ingest" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 94aaf3fb800..2f0a78cdbb8 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -400,6 +400,13 @@ "when": 1779349845635, "tag": "0056_add_onboarded_at", "breakpoints": true + }, + { + "idx": 57, + "version": "7", + "when": 1780460225448, + "tag": "0057_drop_remote_control_sessions", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/enums.ts b/packages/db/src/schema/enums.ts index d8050809d55..9e7ed5af919 100644 --- a/packages/db/src/schema/enums.ts +++ b/packages/db/src/schema/enums.ts @@ -92,23 +92,3 @@ export const automationPromptSourceValues = [ ] as const; export const automationPromptSourceEnum = z.enum(automationPromptSourceValues); export type AutomationPromptSource = z.infer; - -export const remoteControlSessionModeValues = ["command", "full"] as const; -export const remoteControlSessionModeEnum = z.enum( - remoteControlSessionModeValues, -); -export type RemoteControlSessionMode = z.infer< - typeof remoteControlSessionModeEnum ->; - -export const remoteControlSessionStatusValues = [ - "active", - "revoked", - "expired", -] as const; -export const remoteControlSessionStatusEnum = z.enum( - remoteControlSessionStatusValues, -); -export type RemoteControlSessionStatus = z.infer< - typeof remoteControlSessionStatusEnum ->; diff --git a/packages/db/src/schema/schema.ts b/packages/db/src/schema/schema.ts index 4b35a83c74b..804b77a146e 100644 --- a/packages/db/src/schema/schema.ts +++ b/packages/db/src/schema/schema.ts @@ -23,8 +23,6 @@ import { commandStatusValues, deviceTypeValues, integrationProviderValues, - remoteControlSessionModeValues, - remoteControlSessionStatusValues, taskPriorityValues, taskStatusEnumValues, v2ClientTypeValues, @@ -53,14 +51,6 @@ export const v2WorkspaceType = pgEnum( "v2_workspace_type", v2WorkspaceTypeValues, ); -export const remoteControlSessionMode = pgEnum( - "remote_control_session_mode", - remoteControlSessionModeValues, -); -export const remoteControlSessionStatus = pgEnum( - "remote_control_session_status", - remoteControlSessionStatusValues, -); export const taskStatuses = pgTable( "task_statuses", @@ -916,60 +906,3 @@ export const submittedPrompts = pgTable( export type InsertSubmittedPrompt = typeof submittedPrompts.$inferInsert; export type SelectSubmittedPrompt = typeof submittedPrompts.$inferSelect; - -export const v2RemoteControlSessions = pgTable( - "v2_remote_control_sessions", - { - id: uuid().primaryKey().defaultRandom(), - organizationId: uuid("organization_id") - .notNull() - .references(() => organizations.id, { onDelete: "cascade" }), - hostId: text("host_id").notNull(), - workspaceId: uuid("workspace_id") - .notNull() - .references(() => v2Workspaces.id, { onDelete: "cascade" }), - terminalId: text("terminal_id").notNull(), - createdByUserId: uuid("created_by_user_id") - .notNull() - .references(() => users.id, { onDelete: "cascade" }), - mode: remoteControlSessionMode().notNull(), - status: remoteControlSessionStatus().notNull().default("active"), - tokenHash: text("token_hash").notNull(), - expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), - revokedAt: timestamp("revoked_at", { withTimezone: true }), - revokedByUserId: uuid("revoked_by_user_id").references(() => users.id, { - onDelete: "set null", - }), - lastConnectedAt: timestamp("last_connected_at", { withTimezone: true }), - viewerCount: integer("viewer_count").notNull().default(0), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }) - .notNull() - .defaultNow() - .$onUpdate(() => new Date()), - }, - (table) => [ - foreignKey({ - columns: [table.organizationId, table.hostId], - foreignColumns: [v2Hosts.organizationId, v2Hosts.machineId], - name: "v2_remote_control_sessions_host_fk", - }).onDelete("cascade"), - uniqueIndex("v2_remote_control_sessions_token_hash_uniq").on( - table.tokenHash, - ), - index("v2_remote_control_sessions_organization_id_idx").on( - table.organizationId, - ), - index("v2_remote_control_sessions_host_id_idx").on(table.hostId), - index("v2_remote_control_sessions_workspace_id_idx").on(table.workspaceId), - index("v2_remote_control_sessions_terminal_id_idx").on(table.terminalId), - index("v2_remote_control_sessions_status_idx").on(table.status), - ], -); - -export type InsertV2RemoteControlSession = - typeof v2RemoteControlSessions.$inferInsert; -export type SelectV2RemoteControlSession = - typeof v2RemoteControlSessions.$inferSelect; diff --git a/packages/host-service/src/app.ts b/packages/host-service/src/app.ts index 607ee5f1d52..0edf155063f 100644 --- a/packages/host-service/src/app.ts +++ b/packages/host-service/src/app.ts @@ -17,13 +17,6 @@ import type { GitCredentialProvider } from "./runtime/git"; import { createGitFactory } from "./runtime/git"; import { runMainWorkspaceSweep } from "./runtime/main-workspace-sweep"; import { PullRequestRuntimeManager } from "./runtime/pull-requests"; -import { registerRemoteControlRoute } from "./terminal/remote-control/route"; -import { - initRemoteControlSecret, - revokeAllSessions, - startRemoteControlExpirySweep, - stopRemoteControlExpirySweep, -} from "./terminal/remote-control/session-manager"; import { registerWorkspaceTerminalRoute } from "./terminal/terminal"; import { TerminalAgentStore } from "./terminal-agents"; import { appRouter } from "./trpc/router"; @@ -40,7 +33,6 @@ export interface CreateAppOptions { cloudApiUrl: string; migrationsFolder: string; allowedOrigins: string[]; - hostServiceSecret?: string; }; providers: { auth: ApiAuthProvider; @@ -161,11 +153,6 @@ export function createApp(options: CreateAppOptions): CreateAppResult { }; app.use("/terminal/*", wsAuth); app.use("/events", wsAuth); - // `/remote-control/*` does NOT use `wsAuth` — viewers come in via the - // relay tunnel (already PSK-authenticated end-to-end) and authenticate - // per-session with an HMAC `remoteControlToken` validated by - // `authenticateSession` inside the route handler. The HMAC is the - // credential we ship to the browser, not the host PSK. registerEventBusRoute({ app, eventBus, upgradeWebSocket }); registerWorkspaceTerminalRoute({ @@ -175,12 +162,6 @@ export function createApp(options: CreateAppOptions): CreateAppResult { upgradeWebSocket, }); - if (config.hostServiceSecret) { - initRemoteControlSecret(config.hostServiceSecret); - startRemoteControlExpirySweep(); - registerRemoteControlRoute({ app, upgradeWebSocket }); - } - app.use( "/trpc/*", trpcServer({ @@ -208,16 +189,6 @@ export function createApp(options: CreateAppOptions): CreateAppResult { // Each step is best-effort and isolated: a throw in one cleanup must // not skip the others, otherwise a flaky `.stop()` could leak the // open SQLite handle for the rest of the process lifetime. - try { - stopRemoteControlExpirySweep(); - } catch (err) { - console.warn("[host-service] stopRemoteControlExpirySweep failed:", err); - } - try { - revokeAllSessions("host-shutdown"); - } catch (err) { - console.warn("[host-service] revokeAllSessions failed:", err); - } try { pullRequestRuntime.stop(); } catch (err) { diff --git a/packages/host-service/src/serve.ts b/packages/host-service/src/serve.ts index 39cae36e756..28e7ea15279 100644 --- a/packages/host-service/src/serve.ts +++ b/packages/host-service/src/serve.ts @@ -52,7 +52,6 @@ async function main(): Promise { cloudApiUrl: env.SUPERSET_API_URL, migrationsFolder: env.HOST_MIGRATIONS_FOLDER, allowedOrigins: env.CORS_ORIGINS ?? [], - hostServiceSecret: env.HOST_SERVICE_SECRET, }, providers: { auth: authProvider, diff --git a/packages/host-service/src/terminal/remote-control/route.ts b/packages/host-service/src/terminal/remote-control/route.ts deleted file mode 100644 index 98978e30875..00000000000 --- a/packages/host-service/src/terminal/remote-control/route.ts +++ /dev/null @@ -1,411 +0,0 @@ -import type { NodeWebSocket } from "@hono/node-ws"; -import { - capabilitiesForMode, - REMOTE_CONTROL_INPUT_RATE_PER_SEC, - REMOTE_CONTROL_RESIZE_RATE_PER_SEC, - REMOTE_CONTROL_TOKEN_PARAM, - type RemoteControlClientMessage, - type RemoteControlErrorCode, - type RemoteControlMode, - type RemoteControlServerMessage, -} from "@superset/shared/remote-control-protocol"; -import type { Hono } from "hono"; -import { z } from "zod"; -import { - attachTerminalViewer, - type TerminalViewerHandle, - type TerminalViewerListener, -} from "../terminal.ts"; -import { - addViewer, - authenticateSession, - onRevoke, - removeViewer, -} from "./session-manager.ts"; - -// Runtime validation for inbound WebSocket payloads. Without this, a -// malformed `runCommand` (no `command` field) or `resize` (string `cols`) -// would propagate as an uncaught exception out of `onMessage`. Mirrors -// `RemoteControlClientMessage` in `@superset/shared/remote-control-protocol`. -const clientMessageSchema = z.discriminatedUnion("type", [ - z.object({ type: z.literal("ping"), nonce: z.string().optional() }), - z.object({ type: z.literal("stop") }), - z.object({ type: z.literal("input"), data: z.string() }), - z.object({ - type: z.literal("resize"), - cols: z.number().int().positive(), - rows: z.number().int().positive(), - }), - z.object({ - type: z.literal("runCommand"), - command: z.string(), - commandId: z.string().optional(), - }), -]); - -interface RemoteControlSocket { - send: (data: string) => void; - close: (code?: number, reason?: string) => void; - readyState: number; -} - -const SOCKET_OPEN = 1; - -interface TokenBucket { - tokens: number; - lastRefillMs: number; - ratePerSec: number; -} - -function makeBucket(ratePerSec: number): TokenBucket { - return { tokens: ratePerSec, lastRefillMs: Date.now(), ratePerSec }; -} - -function consume(bucket: TokenBucket): boolean { - const now = Date.now(); - const elapsed = (now - bucket.lastRefillMs) / 1000; - if (elapsed > 0) { - bucket.tokens = Math.min( - bucket.ratePerSec, - bucket.tokens + elapsed * bucket.ratePerSec, - ); - bucket.lastRefillMs = now; - } - if (bucket.tokens < 1) return false; - bucket.tokens -= 1; - return true; -} - -function nowSec(): number { - return Math.floor(Date.now() / 1000); -} - -function bytesToBase64(bytes: Uint8Array): string { - const buf = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength); - return buf.toString("base64"); -} - -function base64ToBytes(s: string): Uint8Array { - return new Uint8Array(Buffer.from(s, "base64")); -} - -function send(ws: RemoteControlSocket, msg: RemoteControlServerMessage): void { - if (ws.readyState !== SOCKET_OPEN) return; - try { - ws.send(JSON.stringify(msg)); - } catch (err) { - console.warn("[remote-control] send failed:", err); - } -} - -function sendError( - ws: RemoteControlSocket, - code: RemoteControlErrorCode, - message: string, -): void { - send(ws, { type: "error", code, message }); -} - -export interface RegisterRemoteControlRouteOptions { - app: Hono; - upgradeWebSocket: NodeWebSocket["upgradeWebSocket"]; -} - -export function registerRemoteControlRoute( - options: RegisterRemoteControlRouteOptions, -): void { - const { app, upgradeWebSocket } = options; - - app.get( - "/remote-control/:sessionId", - upgradeWebSocket((c) => { - const sessionId = c.req.param("sessionId") ?? ""; - const token = c.req.query(REMOTE_CONTROL_TOKEN_PARAM) ?? ""; - - const ctx: { - viewer: TerminalViewerHandle | null; - listener: TerminalViewerListener | null; - viewerSocket: { close: () => void } | null; - unsubscribeRevoke: (() => void) | null; - inputBucket: TokenBucket; - resizeBucket: TokenBucket; - // Cached at handshake — `authenticateSession` re-hashes the - // HMAC every call, which we do NOT want at 200 msg/s/viewer. - // Per-message handling now just checks `expiresAt` against now. - authedMode: RemoteControlMode | null; - expiresAt: number | null; - cleaned: boolean; - } = { - viewer: null, - listener: null, - viewerSocket: null, - unsubscribeRevoke: null, - inputBucket: makeBucket(REMOTE_CONTROL_INPUT_RATE_PER_SEC), - resizeBucket: makeBucket(REMOTE_CONTROL_RESIZE_RATE_PER_SEC), - authedMode: null, - expiresAt: null, - cleaned: false, - }; - - // Single cleanup path. `onClose` and `onError` both delegate here — - // `onError` may fire without a subsequent `onClose` on abrupt - // teardown, so we cannot rely on `onClose` alone. `cleaned` makes - // it idempotent. - const cleanup = (): void => { - if (ctx.cleaned) return; - ctx.cleaned = true; - if (ctx.unsubscribeRevoke) { - try { - ctx.unsubscribeRevoke(); - } catch { - // best-effort - } - ctx.unsubscribeRevoke = null; - } - if (ctx.viewer) { - try { - ctx.viewer.detach(); - } catch { - // best-effort - } - ctx.viewer = null; - } - if (ctx.viewerSocket) { - removeViewer(sessionId, ctx.viewerSocket); - ctx.viewerSocket = null; - } - }; - - return { - onOpen: (_event, ws) => { - if (!sessionId) { - sendError(ws, "session-not-found", "Missing sessionId"); - ws.close(1011, "Missing sessionId"); - return; - } - if (!token) { - sendError(ws, "invalid-token", "Missing token"); - ws.close(1011, "Missing token"); - return; - } - - const auth = authenticateSession(sessionId, token); - if (!auth.ok) { - const code: RemoteControlErrorCode = - auth.reason === "session-not-found" - ? "session-not-found" - : auth.reason === "session-expired" - ? "session-expired" - : "invalid-token"; - console.warn(`[remote-control] auth failed: ${auth.reason}`); - sendError(ws, code, auth.reason); - ws.close(1008, auth.reason); - return; - } - - const listener: TerminalViewerListener = { - onData(bytes, sequence) { - send(ws, { - type: "data", - data: bytesToBase64(bytes), - outputSequence: sequence, - }); - }, - onTitle(title) { - send(ws, { type: "title", title }); - }, - onResize(_cols, _rows) { - // Host-driven resize is not propagated to viewers in `full` - // mode — viewer's own size wins (see plan OQ-2). - }, - onExit(exitCode, signal) { - send(ws, { type: "exit", exitCode, signal }); - }, - }; - - const handle = attachTerminalViewer({ - terminalId: auth.terminalId, - workspaceId: auth.workspaceId, - listener, - }); - if (!handle) { - sendError( - ws, - "session-not-found", - "Terminal session is no longer active", - ); - ws.close(1011, "terminal-not-found"); - return; - } - ctx.viewer = handle; - ctx.listener = listener; - ctx.authedMode = auth.mode; - ctx.expiresAt = auth.expiresAt; - - const viewerSocket = { - close: () => { - try { - ws.close(1000, "revoked"); - } catch { - // best-effort - } - }, - }; - ctx.viewerSocket = viewerSocket; - const added = addViewer(sessionId, viewerSocket); - if (!added.ok) { - console.warn( - `[remote-control] viewer cap reached for ${sessionId}`, - ); - sendError(ws, "max-viewers", "Maximum viewers reached"); - handle.detach(); - ctx.viewer = null; - ws.close(1013, "max-viewers"); - return; - } - - const capabilities = capabilitiesForMode(auth.mode); - const snap = handle.getSnapshot(); - send(ws, { - type: "hello", - sessionId, - terminalId: auth.terminalId, - mode: auth.mode, - capabilities, - cols: snap.cols, - rows: snap.rows, - title: snap.title, - }); - if (snap.tail.byteLength > 0) { - send(ws, { - type: "snapshot", - data: bytesToBase64(snap.tail), - outputSequence: snap.outputSequence, - }); - } - if (snap.exited) { - send(ws, { - type: "exit", - exitCode: snap.exitCode ?? 0, - signal: snap.signal ?? 0, - }); - ws.close(1000, "exited"); - return; - } - - ctx.unsubscribeRevoke = onRevoke(sessionId, (reason) => { - send(ws, { type: "revoked", reason }); - try { - ws.close(1000, `revoked:${reason}`); - } catch { - // best-effort - } - }); - }, - - onMessage: (event, ws) => { - if (!ctx.viewer || ctx.authedMode === null) return; - let raw: unknown; - try { - raw = JSON.parse(String(event.data)); - } catch { - sendError(ws, "internal", "Invalid message payload"); - return; - } - const validated = clientMessageSchema.safeParse(raw); - if (!validated.success) { - sendError(ws, "internal", "Invalid message payload"); - return; - } - const parsed: RemoteControlClientMessage = validated.data; - - // Lightweight expiry check — handshake already verified the - // HMAC + token-hash. Re-running them per message at 200/s - // is wasted CPU. - if (ctx.expiresAt !== null && ctx.expiresAt <= nowSec()) { - sendError(ws, "session-expired", "Session expired"); - ws.close(1008, "session-expired"); - return; - } - const capabilities = capabilitiesForMode(ctx.authedMode); - - switch (parsed.type) { - case "ping": - send(ws, { type: "pong", nonce: parsed.nonce }); - return; - case "stop": - ws.close(1000, "stop"); - return; - case "input": { - if (!capabilities.input) { - sendError( - ws, - "capability-denied", - "Input not allowed in this mode", - ); - return; - } - if (!consume(ctx.inputBucket)) { - sendError(ws, "rate-limited", "Input rate limit exceeded"); - return; - } - try { - ctx.viewer.sendInput(base64ToBytes(parsed.data)); - } catch (err) { - console.warn("[remote-control] sendInput failed:", err); - sendError(ws, "internal", "Failed to forward input"); - } - return; - } - case "resize": { - if (!capabilities.resize) { - sendError( - ws, - "capability-denied", - "Resize not allowed in this mode", - ); - return; - } - if (!consume(ctx.resizeBucket)) { - sendError(ws, "rate-limited", "Resize rate limit exceeded"); - return; - } - try { - ctx.viewer.resize(parsed.cols, parsed.rows); - } catch (err) { - console.warn("[remote-control] resize failed:", err); - sendError(ws, "internal", "Failed to resize"); - } - return; - } - case "runCommand": { - if (!capabilities.runCommand) { - sendError(ws, "capability-denied", "runCommand not allowed"); - return; - } - if (!consume(ctx.inputBucket)) { - sendError(ws, "rate-limited", "Command rate limit exceeded"); - return; - } - try { - ctx.viewer.runCommand(parsed.command); - } catch (err) { - console.warn("[remote-control] runCommand failed:", err); - sendError(ws, "internal", "Failed to run command"); - } - return; - } - } - }, - - onClose: (_event, _ws) => { - cleanup(); - }, - - onError: (_event, _ws) => { - cleanup(); - }, - }; - }), - ); -} diff --git a/packages/host-service/src/terminal/remote-control/session-manager.test.ts b/packages/host-service/src/terminal/remote-control/session-manager.test.ts deleted file mode 100644 index 054459d2b6c..00000000000 --- a/packages/host-service/src/terminal/remote-control/session-manager.test.ts +++ /dev/null @@ -1,244 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { - __resetRemoteControlForTesting, - addViewer, - authenticateSession, - getActiveSessionMode, - hashRemoteControlToken, - initRemoteControlSecret, - listActiveSessions, - mintRemoteControlToken, - onRevoke, - registerRemoteControlSession, - revokeSession, - revokeSessionsForTerminal, - startRemoteControlExpirySweep, - stopRemoteControlExpirySweep, - verifyRemoteControlToken, - viewerCount, -} from "./session-manager"; - -interface FakeViewer { - closed: boolean; - close(): void; -} - -function fakeViewer(): FakeViewer { - const v: FakeViewer = { - closed: false, - close() { - v.closed = true; - }, - }; - return v; -} - -describe("remote-control session-manager", () => { - beforeEach(() => { - __resetRemoteControlForTesting(); - initRemoteControlSecret("test-secret"); - }); - afterEach(() => { - __resetRemoteControlForTesting(); - }); - - test("token round-trip succeeds and decodes claims", () => { - const minted = mintRemoteControlToken({ - sessionId: "00000000-0000-0000-0000-000000000001", - terminalId: "term-1", - workspaceId: "00000000-0000-0000-0000-000000000aaa", - mode: "full", - createdByUserId: "00000000-0000-0000-0000-000000000bbb", - }); - expect(minted.token.split(".")).toHaveLength(3); - const verified = verifyRemoteControlToken(minted.token); - expect(verified.ok).toBe(true); - if (verified.ok) { - expect(verified.claims.sid).toBe("00000000-0000-0000-0000-000000000001"); - expect(verified.claims.mode).toBe("full"); - } - }); - - test("tampered signature is rejected", () => { - const { token } = mintRemoteControlToken({ - sessionId: "s", - terminalId: "t", - workspaceId: "w", - mode: "command", - createdByUserId: "u", - }); - const [c, _sig, n] = token.split("."); - const bad = `${c}.AAAA.${n}`; - const v = verifyRemoteControlToken(bad); - expect(v.ok).toBe(false); - if (!v.ok) expect(v.reason).toBe("bad-signature"); - }); - - test("expired token rejected", () => { - const { token } = mintRemoteControlToken({ - sessionId: "s", - terminalId: "t", - workspaceId: "w", - mode: "full", - createdByUserId: "u", - ttlSec: 60, - }); - const realDateNow = Date.now; - Date.now = () => realDateNow() + 120_000; - try { - const v = verifyRemoteControlToken(token); - expect(v.ok).toBe(false); - if (!v.ok) expect(v.reason).toBe("expired"); - } finally { - Date.now = realDateNow; - } - }); - - test("rotating the secret invalidates old tokens", () => { - const { token } = mintRemoteControlToken({ - sessionId: "s", - terminalId: "t", - workspaceId: "w", - mode: "full", - createdByUserId: "u", - }); - __resetRemoteControlForTesting(); - initRemoteControlSecret("different-secret"); - const v = verifyRemoteControlToken(token); - expect(v.ok).toBe(false); - }); - - test("authenticateSession rejects sessionId mismatch", () => { - const minted = mintRemoteControlToken({ - sessionId: "real-sid", - terminalId: "t", - workspaceId: "w", - mode: "full", - createdByUserId: "u", - }); - registerRemoteControlSession({ - sessionId: "real-sid", - terminalId: "t", - workspaceId: "w", - mode: "full", - tokenHash: minted.tokenHash, - expiresAt: minted.expiresAt, - }); - const r = authenticateSession("other-sid", minted.token); - expect(r.ok).toBe(false); - }); - - test("authenticateSession ok then viewer cap enforced", () => { - const sid = "cap-sid"; - const minted = mintRemoteControlToken({ - sessionId: sid, - terminalId: "t", - workspaceId: "w", - mode: "full", - createdByUserId: "u", - }); - registerRemoteControlSession({ - sessionId: sid, - terminalId: "t", - workspaceId: "w", - mode: "full", - tokenHash: minted.tokenHash, - expiresAt: minted.expiresAt, - }); - const r = authenticateSession(sid, minted.token); - expect(r.ok).toBe(true); - for (let i = 0; i < 4; i += 1) { - const ok = addViewer(sid, fakeViewer()); - expect(ok.ok).toBe(true); - } - const overflow = addViewer(sid, fakeViewer()); - expect(overflow.ok).toBe(false); - if (!overflow.ok) expect(overflow.reason).toBe("max-viewers"); - expect(viewerCount(sid)).toBe(4); - }); - - test("revokeSession fans out and closes viewers", () => { - const sid = "rev-sid"; - const minted = mintRemoteControlToken({ - sessionId: sid, - terminalId: "t", - workspaceId: "w", - mode: "full", - createdByUserId: "u", - }); - registerRemoteControlSession({ - sessionId: sid, - terminalId: "t", - workspaceId: "w", - mode: "full", - tokenHash: minted.tokenHash, - expiresAt: minted.expiresAt, - }); - const v1 = fakeViewer(); - const v2 = fakeViewer(); - addViewer(sid, v1); - addViewer(sid, v2); - const fired: string[] = []; - onRevoke(sid, (reason) => { - fired.push(reason); - }); - revokeSession(sid, "manual"); - expect(fired).toEqual(["manual"]); - expect(v1.closed).toBe(true); - expect(v2.closed).toBe(true); - expect(getActiveSessionMode(sid)).toBeNull(); - }); - - test("revokeSessionsForTerminal sweeps matching sessions", () => { - const term = "shared-term"; - for (const sid of ["a", "b", "c"]) { - const minted = mintRemoteControlToken({ - sessionId: sid, - terminalId: term, - workspaceId: "w", - mode: "full", - createdByUserId: "u", - }); - registerRemoteControlSession({ - sessionId: sid, - terminalId: term, - workspaceId: "w", - mode: "full", - tokenHash: minted.tokenHash, - expiresAt: minted.expiresAt, - }); - } - expect(listActiveSessions()).toHaveLength(3); - revokeSessionsForTerminal(term); - expect(listActiveSessions()).toHaveLength(0); - }); - - test("expiry sweep removes expired sessions", async () => { - const sid = "expiring"; - const minted = mintRemoteControlToken({ - sessionId: sid, - terminalId: "t", - workspaceId: "w", - mode: "full", - createdByUserId: "u", - }); - registerRemoteControlSession({ - sessionId: sid, - terminalId: "t", - workspaceId: "w", - mode: "full", - tokenHash: minted.tokenHash, - // already expired - expiresAt: Math.floor(Date.now() / 1000) - 1, - }); - startRemoteControlExpirySweep(20); - await new Promise((r) => setTimeout(r, 60)); - stopRemoteControlExpirySweep(); - expect(getActiveSessionMode(sid)).toBeNull(); - }); - - test("hashRemoteControlToken is deterministic", () => { - const t = "abc.def.ghi"; - expect(hashRemoteControlToken(t)).toBe(hashRemoteControlToken(t)); - }); -}); diff --git a/packages/host-service/src/terminal/remote-control/session-manager.ts b/packages/host-service/src/terminal/remote-control/session-manager.ts deleted file mode 100644 index 4eeb6df5e26..00000000000 --- a/packages/host-service/src/terminal/remote-control/session-manager.ts +++ /dev/null @@ -1,435 +0,0 @@ -import crypto from "node:crypto"; -import { - REMOTE_CONTROL_DEFAULT_TTL_SEC, - REMOTE_CONTROL_MAX_TTL_SEC, - REMOTE_CONTROL_MAX_VIEWERS, - REMOTE_CONTROL_MIN_TTL_SEC, - REMOTE_CONTROL_PROTOCOL_VERSION, - type RemoteControlMode, - type RemoteControlRevokeReason, - type RemoteControlTokenClaims, -} from "@superset/shared/remote-control-protocol"; - -interface ViewerSocket { - close(reason?: RemoteControlRevokeReason): void; -} - -interface ActiveSession { - sessionId: string; - terminalId: string; - workspaceId: string; - mode: RemoteControlMode; - tokenHash: string; - expiresAt: number; // unix seconds - viewers: Set; - revokeListeners: Set<(reason: RemoteControlRevokeReason) => void>; -} - -interface RemoteControlState { - secret: Buffer | null; - sessions: Map; - expiryTimer: NodeJS.Timeout | null; -} - -const state: RemoteControlState = { - secret: null, - sessions: new Map(), - expiryTimer: null, -}; - -const SECRET_DERIVATION_LABEL = "superset.remote-control.v1"; - -export function initRemoteControlSecret(baseSecret: string): void { - if (!baseSecret || baseSecret.length === 0) { - throw new Error("initRemoteControlSecret: baseSecret must be non-empty"); - } - const derived = crypto - .createHash("sha256") - .update(SECRET_DERIVATION_LABEL + baseSecret) - .digest(); - if (state.secret) { - if (state.secret.equals(derived)) return; - throw new Error( - "initRemoteControlSecret: already initialized with a different secret", - ); - } - state.secret = derived; -} - -function requireSecret(): Buffer { - if (!state.secret) { - throw new Error( - "remote-control secret is not initialized — call initRemoteControlSecret first", - ); - } - return state.secret; -} - -function base64UrlEncode(buf: Buffer | string): string { - const b = typeof buf === "string" ? Buffer.from(buf, "utf8") : buf; - return b - .toString("base64") - .replace(/\+/g, "-") - .replace(/\//g, "_") - .replace(/=+$/, ""); -} - -function base64UrlDecode(s: string): Buffer { - const padded = s + "=".repeat((4 - (s.length % 4)) % 4); - return Buffer.from(padded.replace(/-/g, "+").replace(/_/g, "/"), "base64"); -} - -function nowSec(): number { - return Math.floor(Date.now() / 1000); -} - -export function hashRemoteControlToken(token: string): string { - return crypto.createHash("sha256").update(token).digest("hex"); -} - -export interface MintRemoteControlTokenInput { - sessionId: string; - terminalId: string; - workspaceId: string; - mode: RemoteControlMode; - createdByUserId: string; - ttlSec?: number; -} - -export interface MintRemoteControlTokenResult { - token: string; - tokenHash: string; - expiresAt: number; // unix seconds -} - -export function mintRemoteControlToken( - input: MintRemoteControlTokenInput, -): MintRemoteControlTokenResult { - const secret = requireSecret(); - const ttlRaw = input.ttlSec ?? REMOTE_CONTROL_DEFAULT_TTL_SEC; - const ttl = Math.max( - REMOTE_CONTROL_MIN_TTL_SEC, - Math.min(REMOTE_CONTROL_MAX_TTL_SEC, Math.floor(ttlRaw)), - ); - const iat = nowSec(); - const exp = iat + ttl; - const claims: RemoteControlTokenClaims = { - v: REMOTE_CONTROL_PROTOCOL_VERSION, - sid: input.sessionId, - tid: input.terminalId, - wid: input.workspaceId, - mode: input.mode, - uid: input.createdByUserId, - iat, - exp, - }; - const claimsB64 = base64UrlEncode(JSON.stringify(claims)); - const nonceB64 = base64UrlEncode(crypto.randomBytes(16)); - const sig = crypto - .createHmac("sha256", secret) - .update(`${claimsB64}.${nonceB64}`) - .digest(); - const sigB64 = base64UrlEncode(sig); - const token = `${claimsB64}.${sigB64}.${nonceB64}`; - return { - token, - tokenHash: hashRemoteControlToken(token), - expiresAt: exp, - }; -} - -export type VerifyRemoteControlTokenResult = - | { ok: true; claims: RemoteControlTokenClaims } - | { ok: false; reason: "malformed" | "bad-signature" | "expired" }; - -export function verifyRemoteControlToken( - token: string, -): VerifyRemoteControlTokenResult { - const secret = requireSecret(); - const parts = token.split("."); - if (parts.length !== 3) return { ok: false, reason: "malformed" }; - const [claimsB64, sigB64, nonceB64] = parts; - if (!claimsB64 || !sigB64 || !nonceB64) { - return { ok: false, reason: "malformed" }; - } - let providedSig: Buffer; - try { - providedSig = base64UrlDecode(sigB64); - } catch { - return { ok: false, reason: "malformed" }; - } - const expectedSig = crypto - .createHmac("sha256", secret) - .update(`${claimsB64}.${nonceB64}`) - .digest(); - if ( - providedSig.length !== expectedSig.length || - !crypto.timingSafeEqual(providedSig, expectedSig) - ) { - return { ok: false, reason: "bad-signature" }; - } - let claims: RemoteControlTokenClaims; - try { - const json = base64UrlDecode(claimsB64).toString("utf8"); - claims = JSON.parse(json) as RemoteControlTokenClaims; - } catch { - return { ok: false, reason: "malformed" }; - } - if (claims.v !== REMOTE_CONTROL_PROTOCOL_VERSION) { - return { ok: false, reason: "malformed" }; - } - if ( - typeof claims.exp !== "number" || - typeof claims.iat !== "number" || - typeof claims.sid !== "string" || - typeof claims.tid !== "string" || - typeof claims.wid !== "string" || - typeof claims.uid !== "string" || - (claims.mode !== "command" && claims.mode !== "full") - ) { - return { ok: false, reason: "malformed" }; - } - if (claims.exp <= nowSec()) { - return { ok: false, reason: "expired" }; - } - return { ok: true, claims }; -} - -export interface RegisterRemoteControlSessionInput { - sessionId: string; - terminalId: string; - workspaceId: string; - mode: RemoteControlMode; - tokenHash: string; - expiresAt: number; -} - -export function registerRemoteControlSession( - input: RegisterRemoteControlSessionInput, -): ActiveSession { - const existing = state.sessions.get(input.sessionId); - if (existing && existing.tokenHash !== input.tokenHash) { - revokeSession(input.sessionId, "manual"); - } - const reused = state.sessions.get(input.sessionId); - if (reused) { - reused.tokenHash = input.tokenHash; - reused.expiresAt = input.expiresAt; - reused.terminalId = input.terminalId; - reused.workspaceId = input.workspaceId; - reused.mode = input.mode; - return reused; - } - const session: ActiveSession = { - sessionId: input.sessionId, - terminalId: input.terminalId, - workspaceId: input.workspaceId, - mode: input.mode, - tokenHash: input.tokenHash, - expiresAt: input.expiresAt, - viewers: new Set(), - revokeListeners: new Set(), - }; - state.sessions.set(input.sessionId, session); - return session; -} - -export interface AuthenticateSessionResult { - ok: true; - mode: RemoteControlMode; - terminalId: string; - workspaceId: string; - expiresAt: number; - createdByUserId: string; -} - -export type AuthenticateSessionFailure = { - ok: false; - reason: - | "invalid-token" - | "session-not-found" - | "session-expired" - | "session-mismatch"; -}; - -export function authenticateSession( - sessionId: string, - token: string, -): AuthenticateSessionResult | AuthenticateSessionFailure { - const verified = verifyRemoteControlToken(token); - if (!verified.ok) { - return { - ok: false, - reason: - verified.reason === "expired" ? "session-expired" : "invalid-token", - }; - } - if (verified.claims.sid !== sessionId) { - return { ok: false, reason: "session-mismatch" }; - } - const session = state.sessions.get(sessionId); - if (!session) return { ok: false, reason: "session-not-found" }; - const providedHash = Buffer.from(hashRemoteControlToken(token), "hex"); - const expectedHash = Buffer.from(session.tokenHash, "hex"); - if ( - providedHash.length !== expectedHash.length || - !crypto.timingSafeEqual(providedHash, expectedHash) - ) { - return { ok: false, reason: "invalid-token" }; - } - if (session.expiresAt <= nowSec()) { - revokeSession(sessionId, "expired"); - return { ok: false, reason: "session-expired" }; - } - return { - ok: true, - mode: session.mode, - terminalId: session.terminalId, - workspaceId: session.workspaceId, - expiresAt: session.expiresAt, - createdByUserId: verified.claims.uid, - }; -} - -export type AddViewerResult = - | { ok: true } - | { ok: false; reason: "max-viewers" | "session-not-found" }; - -export function addViewer( - sessionId: string, - viewer: ViewerSocket, -): AddViewerResult { - const session = state.sessions.get(sessionId); - if (!session) return { ok: false, reason: "session-not-found" }; - if (session.viewers.size >= REMOTE_CONTROL_MAX_VIEWERS) { - return { ok: false, reason: "max-viewers" }; - } - session.viewers.add(viewer); - return { ok: true }; -} - -export function removeViewer(sessionId: string, viewer: ViewerSocket): void { - const session = state.sessions.get(sessionId); - if (!session) return; - session.viewers.delete(viewer); -} - -export function viewerCount(sessionId: string): number { - return state.sessions.get(sessionId)?.viewers.size ?? 0; -} - -export function listViewers(sessionId: string): ViewerSocket[] { - const session = state.sessions.get(sessionId); - return session ? Array.from(session.viewers) : []; -} - -export function onRevoke( - sessionId: string, - listener: (reason: RemoteControlRevokeReason) => void, -): () => void { - const session = state.sessions.get(sessionId); - if (!session) { - // Session already gone — fire synchronously so caller can clean up. - queueMicrotask(() => listener("manual")); - return () => {}; - } - session.revokeListeners.add(listener); - return () => { - session.revokeListeners.delete(listener); - }; -} - -export function revokeSession( - sessionId: string, - reason: RemoteControlRevokeReason, -): void { - const session = state.sessions.get(sessionId); - if (!session) return; - state.sessions.delete(sessionId); - for (const listener of Array.from(session.revokeListeners)) { - try { - listener(reason); - } catch (err) { - console.warn("[remote-control] revoke listener threw:", err); - } - } - session.revokeListeners.clear(); - for (const viewer of Array.from(session.viewers)) { - try { - viewer.close(reason); - } catch (err) { - console.warn("[remote-control] viewer close threw:", err); - } - } - session.viewers.clear(); -} - -export function revokeSessionsForTerminal(terminalId: string): void { - const ids: string[] = []; - for (const [id, s] of state.sessions) { - if (s.terminalId === terminalId) ids.push(id); - } - for (const id of ids) revokeSession(id, "terminal"); -} - -export function revokeAllSessions(reason: RemoteControlRevokeReason): void { - for (const id of Array.from(state.sessions.keys())) { - revokeSession(id, reason); - } -} - -export function listActiveSessions(): Array<{ - sessionId: string; - terminalId: string; - workspaceId: string; - mode: RemoteControlMode; - viewerCount: number; - expiresAt: number; -}> { - return Array.from(state.sessions.values()).map((s) => ({ - sessionId: s.sessionId, - terminalId: s.terminalId, - workspaceId: s.workspaceId, - mode: s.mode, - viewerCount: s.viewers.size, - expiresAt: s.expiresAt, - })); -} - -export function getActiveSessionMode( - sessionId: string, -): RemoteControlMode | null { - return state.sessions.get(sessionId)?.mode ?? null; -} - -export function startRemoteControlExpirySweep(intervalMs = 60_000): void { - if (state.expiryTimer) return; - const timer = setInterval(() => { - const now = nowSec(); - const expired: string[] = []; - for (const [id, s] of state.sessions) { - if (s.expiresAt <= now) expired.push(id); - } - if (expired.length > 0) { - console.log( - `[remote-control] expiring ${expired.length} session(s) via sweep`, - ); - for (const id of expired) revokeSession(id, "expired"); - } - }, intervalMs); - timer.unref?.(); - state.expiryTimer = timer; -} - -export function stopRemoteControlExpirySweep(): void { - if (!state.expiryTimer) return; - clearInterval(state.expiryTimer); - state.expiryTimer = null; -} - -export function __resetRemoteControlForTesting(): void { - stopRemoteControlExpirySweep(); - revokeAllSessions("manual"); - state.secret = null; - state.sessions.clear(); -} diff --git a/packages/host-service/src/terminal/terminal.ts b/packages/host-service/src/terminal/terminal.ts index 8d80c928b70..ac481a807f5 100644 --- a/packages/host-service/src/terminal/terminal.ts +++ b/packages/host-service/src/terminal/terminal.ts @@ -2,7 +2,6 @@ import { existsSync } from "node:fs"; import { isAbsolute, join } from "node:path"; import { StringDecoder } from "node:string_decoder"; import type { NodeWebSocket } from "@hono/node-ws"; -import { REMOTE_CONTROL_TAIL_BYTES } from "@superset/shared/remote-control-protocol"; import { createScanState, SHELLS_WITH_READY_MARKER, @@ -35,7 +34,6 @@ import { getTerminalBaseEnv, resolveLaunchShell, } from "./env.ts"; -import { revokeSessionsForTerminal } from "./remote-control/session-manager.ts"; import { listTerminalResourceSessions } from "./resource-sessions.ts"; import { createModeTracker, @@ -46,7 +44,7 @@ import { * Thin adapter exposing approximately the IPty surface that the rest of * this file (and teardown.ts) was built against, so most of the call * sites stay unchanged after the daemon extraction. The PTY itself lives - * in pty-daemon; this is a remote control. + * in pty-daemon; this adapter forwards to it over the daemon socket. * * onData / onExit register additional subscribers on top of whatever the * session's primary subscription is doing — daemon supports multi- @@ -59,12 +57,6 @@ interface PtyDataDisposer { interface DaemonPty { pid: number; write(data: string): void; - /** - * Raw-byte input that bypasses the string round-trip in `write`. Used by - * the remote-control path so non-ASCII bytes (pasted UTF-8, non-Latin - * keyboards, control sequences) reach the PTY exactly as sent. - */ - writeBytes(bytes: Uint8Array): void; resize(cols: number, rows: number): void; kill(signal?: NodeJS.Signals): Promise; onData(cb: (data: string) => void): PtyDataDisposer; @@ -83,12 +75,6 @@ function makeDaemonPty( write(data) { daemon.input(sessionId, Buffer.from(data, "utf8")); }, - writeBytes(bytes) { - daemon.input( - sessionId, - Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength), - ); - }, resize(cols, rows) { try { daemon.resize(sessionId, cols, rows); @@ -217,42 +203,12 @@ const SHELL_READY_TIMEOUT_MS = 3_000; */ type ShellReadyState = "pending" | "ready" | "timed_out" | "unsupported"; -export interface TerminalViewerListener { - onData(bytes: Uint8Array, sequence: number): void; - onTitle(title: string | null): void; - onResize(cols: number, rows: number): void; - onExit(exitCode: number, signal: number): void; -} - -export interface TerminalViewerSnapshot { - tail: Uint8Array; - outputSequence: number; - cols: number; - rows: number; - title: string | null; - exited: boolean; - exitCode?: number; - signal?: number; -} - -export interface TerminalViewerHandle { - detach(): void; - sendInput(bytes: Uint8Array): void; - resize(cols: number, rows: number): void; - runCommand(command: string): void; - getSnapshot(): TerminalViewerSnapshot; -} - interface TerminalSession { terminalId: string; workspaceId: string; pty: DaemonPty; cols: number; rows: number; - outputSequence: number; - tailRing: Uint8Array[]; - tailRingBytes: number; - viewers: Set; /** Unsubscribe from the daemon's output/exit stream when disposed. */ unsubscribeDaemon: (() => void) | null; sockets: Set; @@ -501,178 +457,6 @@ function setSessionTitle(session: TerminalSession, title: string | null) { if (session.title === title) return; session.title = title; broadcastMessage(session, { type: "title", title }); - notifyViewersTitle(session, title); -} - -function pushToTailRing(session: TerminalSession, bytes: Uint8Array) { - if (bytes.byteLength === 0) return; - // If a single chunk is larger than the cap, keep only its tail. Otherwise - // the FIFO eviction below would push then immediately shift the same - // chunk and leave the snapshot empty. - const chunk = - bytes.byteLength > REMOTE_CONTROL_TAIL_BYTES - ? new Uint8Array( - bytes.subarray(bytes.byteLength - REMOTE_CONTROL_TAIL_BYTES), - ) - : new Uint8Array(bytes); - session.tailRing.push(chunk); - session.tailRingBytes += chunk.byteLength; - while ( - session.tailRingBytes > REMOTE_CONTROL_TAIL_BYTES && - session.tailRing.length > 1 - ) { - const removed = session.tailRing.shift(); - if (removed) session.tailRingBytes -= removed.byteLength; - } -} - -function tailRingSnapshot(session: TerminalSession): Uint8Array { - if (session.tailRing.length === 0) return new Uint8Array(0); - const out = new Uint8Array(session.tailRingBytes); - let off = 0; - for (const chunk of session.tailRing) { - out.set(chunk, off); - off += chunk.byteLength; - } - return out; -} - -function notifyViewersData( - session: TerminalSession, - bytes: Uint8Array, - sequence: number, -) { - for (const v of session.viewers) { - try { - v.onData(bytes, sequence); - } catch (err) { - console.warn("[terminal] viewer onData threw:", err); - } - } -} - -function notifyViewersTitle(session: TerminalSession, title: string | null) { - for (const v of session.viewers) { - try { - v.onTitle(title); - } catch (err) { - console.warn("[terminal] viewer onTitle threw:", err); - } - } -} - -function notifyViewersResize( - session: TerminalSession, - cols: number, - rows: number, -) { - for (const v of session.viewers) { - try { - v.onResize(cols, rows); - } catch (err) { - console.warn("[terminal] viewer onResize threw:", err); - } - } -} - -function notifyViewersExit( - session: TerminalSession, - exitCode: number, - signal: number, -) { - for (const v of session.viewers) { - try { - v.onExit(exitCode, signal); - } catch (err) { - console.warn("[terminal] viewer onExit threw:", err); - } - } -} - -export function terminalSessionExists( - terminalId: string, - workspaceId?: string, -): boolean { - const session = sessions.get(terminalId); - if (!session) return false; - if (workspaceId !== undefined && session.workspaceId !== workspaceId) { - return false; - } - return !session.exited; -} - -export interface AttachTerminalViewerOptions { - terminalId: string; - workspaceId: string; - listener: TerminalViewerListener; -} - -export function attachTerminalViewer( - options: AttachTerminalViewerOptions, -): TerminalViewerHandle | null { - const session = sessions.get(options.terminalId); - if (!session) return null; - if (session.workspaceId !== options.workspaceId) return null; - - session.viewers.add(options.listener); - - let detached = false; - - const handle: TerminalViewerHandle = { - detach() { - if (detached) return; - detached = true; - session.viewers.delete(options.listener); - }, - sendInput(bytes) { - if (detached || session.exited) return; - // Raw-byte path. Earlier versions round-tripped via a latin1 string - // here, but `pty.write` re-encodes its argument as UTF-8 so any - // byte ≥ 0x80 (non-ASCII typed input, pasted UTF-8 sequences, - // kitty/keyboard-protocol bytes) was being mangled on the wire. - session.pty.writeBytes(bytes); - }, - resize(cols, rows) { - if (detached || session.exited) return; - const c = normalizeTerminalDimension( - cols, - MIN_TERMINAL_COLS, - DEFAULT_TERMINAL_COLS, - ); - const r = normalizeTerminalDimension( - rows, - MIN_TERMINAL_ROWS, - DEFAULT_TERMINAL_ROWS, - ); - session.pty.resize(c, r); - session.modeTracker.resize(c, r); - session.cols = c; - session.rows = r; - notifyViewersResize(session, c, r); - }, - runCommand(command) { - if (detached || session.exited) return; - // FLAG: plan referenced enqueueTrackedCommand (command-records system), - // which is not present on this branch. Falling back to a raw write so - // the feature still works; revisit when command-records lands. - const cmd = command.endsWith("\n") ? command : `${command}\n`; - session.pty.write(cmd); - }, - getSnapshot() { - return { - tail: tailRingSnapshot(session), - outputSequence: session.outputSequence, - cols: session.cols, - rows: session.rows, - title: session.title, - exited: session.exited, - exitCode: session.exited ? session.exitCode : undefined, - signal: session.exited ? session.exitSignal : undefined, - }; - }, - }; - - return handle; } function bufferOutput(session: TerminalSession, data: Uint8Array) { @@ -901,11 +685,6 @@ export async function disposeSessionAndWait( let closePromise: Promise | null = null; if (session) { - try { - revokeSessionsForTerminal(terminalId); - } catch (err) { - console.warn("[terminal] revokeSessionsForTerminal failed:", err); - } if (session.shellReadyTimeoutId) { clearTimeout(session.shellReadyTimeoutId); session.shellReadyTimeoutId = null; @@ -1253,10 +1032,6 @@ export async function createTerminalSessionInternal({ pty, cols, rows, - outputSequence: 0, - tailRing: [], - tailRingBytes: 0, - viewers: new Set(), unsubscribeDaemon: null, sockets: new Set(), buffer: [], @@ -1335,10 +1110,6 @@ export async function createTerminalSessionInternal({ // so this is the only path that catches startup mode escapes. session.modeTracker.feed(bytes); - pushToTailRing(session, bytes); - session.outputSequence += 1; - notifyViewersData(session, bytes, session.outputSequence); - if (broadcastBytes(session, bytes) === 0) { bufferOutput(session, bytes); } @@ -1362,8 +1133,6 @@ export async function createTerminalSessionInternal({ signal: session.exitSignal, }); - notifyViewersExit(session, session.exitCode, session.exitSignal); - eventBus?.broadcastTerminalLifecycle({ workspaceId, terminalId, @@ -1640,7 +1409,6 @@ export function registerWorkspaceTerminalRoute({ session.modeTracker.resize(cols, rows); session.cols = cols; session.rows = rows; - notifyViewersResize(session, cols, rows); } }, diff --git a/packages/host-service/src/trpc/router/terminal/remote-control.ts b/packages/host-service/src/trpc/router/terminal/remote-control.ts deleted file mode 100644 index 5c4c7102a3d..00000000000 --- a/packages/host-service/src/trpc/router/terminal/remote-control.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { - REMOTE_CONTROL_MAX_TTL_SEC, - REMOTE_CONTROL_MIN_TTL_SEC, -} from "@superset/shared/remote-control-protocol"; -import { TRPCError } from "@trpc/server"; -import { z } from "zod"; -import { - listActiveSessions, - mintRemoteControlToken, - registerRemoteControlSession, - revokeSession, -} from "../../../terminal/remote-control/session-manager"; -import { terminalSessionExists } from "../../../terminal/terminal"; -import { protectedProcedure, router } from "../../index"; - -const mintTokenInput = z.object({ - sessionId: z.string().uuid(), - terminalId: z.string().min(1), - workspaceId: z.string().uuid(), - mode: z.enum(["command", "full"]), - createdByUserId: z.string().uuid(), - // Host is the final HMAC authority — clamp the schema here too so a - // bug or compromised upstream caller cannot ask for a viewer credential - // that outlives the documented limit. `mintRemoteControlToken` ALSO - // clamps the value internally; this is a belt-and-braces guard at the - // API boundary. - ttlSec: z - .number() - .int() - .min(REMOTE_CONTROL_MIN_TTL_SEC) - .max(REMOTE_CONTROL_MAX_TTL_SEC) - .optional(), -}); - -const revokeInput = z.object({ - sessionId: z.string().uuid(), -}); - -export const remoteControlRouter = router({ - mintToken: protectedProcedure.input(mintTokenInput).mutation(({ input }) => { - if (!terminalSessionExists(input.terminalId, input.workspaceId)) { - throw new TRPCError({ - code: "NOT_FOUND", - message: `Terminal "${input.terminalId}" not found in workspace "${input.workspaceId}"`, - }); - } - const minted = mintRemoteControlToken({ - sessionId: input.sessionId, - terminalId: input.terminalId, - workspaceId: input.workspaceId, - mode: input.mode, - createdByUserId: input.createdByUserId, - ttlSec: input.ttlSec, - }); - registerRemoteControlSession({ - sessionId: input.sessionId, - terminalId: input.terminalId, - workspaceId: input.workspaceId, - mode: input.mode, - tokenHash: minted.tokenHash, - expiresAt: minted.expiresAt, - }); - return { - token: minted.token, - tokenHash: minted.tokenHash, - expiresAt: minted.expiresAt, - }; - }), - - revoke: protectedProcedure.input(revokeInput).mutation(({ input }) => { - revokeSession(input.sessionId, "manual"); - return { sessionId: input.sessionId, status: "revoked" as const }; - }), - - listActive: protectedProcedure.query(() => listActiveSessions()), -}); diff --git a/packages/host-service/src/trpc/router/terminal/terminal.ts b/packages/host-service/src/trpc/router/terminal/terminal.ts index 8023530ab1d..46675478e42 100644 --- a/packages/host-service/src/trpc/router/terminal/terminal.ts +++ b/packages/host-service/src/trpc/router/terminal/terminal.ts @@ -13,7 +13,6 @@ import { } from "../../../terminal/terminal"; import type { HostServiceContext } from "../../../types"; import { protectedProcedure, router } from "../../index"; -import { remoteControlRouter } from "./remote-control"; const createSessionInputSchema = z.object({ workspaceId: z.string(), @@ -197,6 +196,4 @@ export const terminalRouter = router({ }), daemon: daemonRouter, - - remoteControl: remoteControlRouter, }); diff --git a/packages/shared/package.json b/packages/shared/package.json index 89e318832fd..23c7f3921b6 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -131,10 +131,6 @@ "./rrule": { "types": "./src/rrule.ts", "default": "./src/rrule.ts" - }, - "./remote-control-protocol": { - "types": "./src/remote-control-protocol.ts", - "default": "./src/remote-control-protocol.ts" } }, "scripts": { diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 1bcd4c27b6d..60e45c9f3a8 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -84,13 +84,6 @@ export const FEATURE_FLAGS = { * piggybacks on the existing All Access cohort. Off → v1. */ SLACK_MCP_V2: "slack-mcp-v2", - /** - * Gates the v2 desktop terminal's "Share remote control" button. - * Evaluated against the sharer's Superset user id — anyone with the - * resulting share link can still open it (the per-session HMAC is - * the credential), so this only controls who can START a session. - */ - WEB_REMOTE_CONTROL_ACCESS: "web-remote-control-access", /** * Per-user override for the relay base URL. Payload shape: * `{ "url": "https://..." }`. When set, both the host-service tunnel and diff --git a/packages/shared/src/remote-control-protocol.ts b/packages/shared/src/remote-control-protocol.ts deleted file mode 100644 index 97597490395..00000000000 --- a/packages/shared/src/remote-control-protocol.ts +++ /dev/null @@ -1,159 +0,0 @@ -export const REMOTE_CONTROL_TAIL_BYTES = 262144; -export const REMOTE_CONTROL_DEFAULT_TTL_SEC = 14400; -export const REMOTE_CONTROL_MAX_TTL_SEC = 86400; -export const REMOTE_CONTROL_MIN_TTL_SEC = 60; -export const REMOTE_CONTROL_INPUT_RATE_PER_SEC = 200; -export const REMOTE_CONTROL_RESIZE_RATE_PER_SEC = 10; -export const REMOTE_CONTROL_MAX_VIEWERS = 4; -export const REMOTE_CONTROL_TOKEN_PARAM = "remoteControlToken"; -export const REMOTE_CONTROL_PROTOCOL_VERSION = 1 as const; - -export const REMOTE_CONTROL_MODES = ["command", "full"] as const; -export type RemoteControlMode = (typeof REMOTE_CONTROL_MODES)[number]; - -export const REMOTE_CONTROL_STATUSES = [ - "active", - "revoked", - "expired", -] as const; -export type RemoteControlStatus = (typeof REMOTE_CONTROL_STATUSES)[number]; - -export interface RemoteControlCapabilities { - read: true; - input: boolean; - resize: boolean; - runCommand: boolean; - dispose: false; -} - -export function capabilitiesForMode( - mode: RemoteControlMode, -): RemoteControlCapabilities { - if (mode === "full") { - return { - read: true, - input: true, - resize: true, - runCommand: true, - dispose: false, - }; - } - return { - read: true, - input: false, - resize: false, - runCommand: true, - dispose: false, - }; -} - -export type RemoteControlErrorCode = - | "invalid-token" - | "session-not-found" - | "session-expired" - | "capability-denied" - | "rate-limited" - | "max-viewers" - | "internal"; - -export type RemoteControlRevokeReason = - | "manual" - | "expired" - | "host-shutdown" - | "terminal"; - -export interface RemoteControlInputMessage { - type: "input"; - data: string; -} -export interface RemoteControlResizeMessage { - type: "resize"; - cols: number; - rows: number; -} -export interface RemoteControlRunCommandMessage { - type: "runCommand"; - command: string; - commandId?: string; -} -export interface RemoteControlPingMessage { - type: "ping"; - nonce?: string; -} -export interface RemoteControlStopMessage { - type: "stop"; -} -export type RemoteControlClientMessage = - | RemoteControlInputMessage - | RemoteControlResizeMessage - | RemoteControlRunCommandMessage - | RemoteControlPingMessage - | RemoteControlStopMessage; - -export interface RemoteControlHelloMessage { - type: "hello"; - sessionId: string; - terminalId: string; - mode: RemoteControlMode; - capabilities: RemoteControlCapabilities; - cols: number; - rows: number; - title: string | null; -} -export interface RemoteControlSnapshotMessage { - type: "snapshot"; - data: string; - outputSequence: number; -} -export interface RemoteControlDataMessage { - type: "data"; - data: string; - outputSequence: number; -} -export interface RemoteControlTitleMessage { - type: "title"; - title: string | null; -} -export interface RemoteControlExitMessage { - type: "exit"; - exitCode: number; - signal: number; -} -export interface RemoteControlRevokedMessage { - type: "revoked"; - reason: RemoteControlRevokeReason; -} -export interface RemoteControlPongMessage { - type: "pong"; - nonce?: string; -} -export interface RemoteControlErrorMessage { - type: "error"; - code: RemoteControlErrorCode; - message: string; -} -export interface RemoteControlPresenceMessage { - type: "presence"; - viewerCount: number; -} -export type RemoteControlServerMessage = - | RemoteControlHelloMessage - | RemoteControlSnapshotMessage - | RemoteControlDataMessage - | RemoteControlTitleMessage - | RemoteControlExitMessage - | RemoteControlRevokedMessage - | RemoteControlPongMessage - | RemoteControlErrorMessage - | RemoteControlPresenceMessage; - -export interface RemoteControlTokenClaims { - v: typeof REMOTE_CONTROL_PROTOCOL_VERSION; - sid: string; - tid: string; - wid: string; - mode: RemoteControlMode; - uid: string; - iat: number; - exp: number; -} diff --git a/packages/trpc/src/root.ts b/packages/trpc/src/root.ts index cd917967542..288e80e8da3 100644 --- a/packages/trpc/src/root.ts +++ b/packages/trpc/src/root.ts @@ -12,7 +12,6 @@ import { hostRouter } from "./router/host"; import { integrationRouter } from "./router/integration"; import { organizationRouter } from "./router/organization"; import { projectRouter } from "./router/project"; -import { remoteControlRouter } from "./router/remote-control"; import { supportRouter } from "./router/support/support"; import { taskRouter } from "./router/task"; import { teamRouter } from "./router/team"; @@ -36,7 +35,6 @@ export const appRouter = createTRPCRouter({ integration: integrationRouter, organization: organizationRouter, project: projectRouter, - remoteControl: remoteControlRouter, support: supportRouter, task: taskRouter, team: teamRouter, diff --git a/packages/trpc/src/router/remote-control/index.ts b/packages/trpc/src/router/remote-control/index.ts deleted file mode 100644 index 42ae01d5e50..00000000000 --- a/packages/trpc/src/router/remote-control/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { remoteControlRouter } from "./remote-control"; diff --git a/packages/trpc/src/router/remote-control/remote-control.ts b/packages/trpc/src/router/remote-control/remote-control.ts deleted file mode 100644 index 382dcf73d2e..00000000000 --- a/packages/trpc/src/router/remote-control/remote-control.ts +++ /dev/null @@ -1,554 +0,0 @@ -import crypto from "node:crypto"; -import { mintUserJwt } from "@superset/auth/server"; -import { dbWs } from "@superset/db/client"; -import { - remoteControlSessionModeValues, - remoteControlSessionStatusValues, -} from "@superset/db/enums"; -import { - users, - v2Hosts, - v2RemoteControlSessions, - v2UsersHosts, - v2Workspaces, -} from "@superset/db/schema"; -import { buildHostRoutingKey } from "@superset/shared/host-routing"; -import { - REMOTE_CONTROL_DEFAULT_TTL_SEC, - REMOTE_CONTROL_MAX_TTL_SEC, - REMOTE_CONTROL_MIN_TTL_SEC, - REMOTE_CONTROL_TOKEN_PARAM, -} from "@superset/shared/remote-control-protocol"; -import { TRPCError } from "@trpc/server"; -import { and, desc, eq, lt } from "drizzle-orm"; -import { z } from "zod"; -import { env } from "../../env"; -import { - createTRPCRouter, - protectedProcedure, - publicProcedure, -} from "../../trpc"; -import { relayMutation } from "../automation/relay-client"; -import { requireActiveOrgMembership } from "../utils/active-org"; - -interface MintTokenResult { - token: string; - tokenHash: string; - expiresAt: number; -} - -const createInput = z.object({ - workspaceId: z.string().uuid(), - terminalId: z.string().min(1), - mode: z.enum(remoteControlSessionModeValues), - ttlSec: z - .number() - .int() - .min(REMOTE_CONTROL_MIN_TTL_SEC) - .max(REMOTE_CONTROL_MAX_TTL_SEC) - .optional(), -}); - -const sessionIdInput = z.object({ sessionId: z.string().uuid() }); -const getInput = z.object({ - sessionId: z.string().uuid(), - token: z.string().min(1), -}); -const listInput = z.object({ workspaceId: z.string().uuid() }); - -function sha256Hex(input: string): string { - return crypto.createHash("sha256").update(input).digest("hex"); -} - -function constantTimeHexEqual(a: string, b: string): boolean { - if (a.length !== b.length) return false; - const ab = Buffer.from(a, "hex"); - const bb = Buffer.from(b, "hex"); - if (ab.length !== bb.length) return false; - return crypto.timingSafeEqual(ab, bb); -} - -// Fixed dummy used for constant-time compare when the row lookup misses, -// so that "session doesn't exist" and "session exists but wrong token" -// take the same amount of CPU and emit the same response. Prevents -// sessionId enumeration via timing or error-code differentiation. -const DUMMY_TOKEN_HASH = sha256Hex( - "remote-control:nonexistent-session:not-a-real-token", -); - -// Single generic response for both "session not found" and "wrong token" -// — see DUMMY_TOKEN_HASH. Always 401; never 404, so an attacker can't -// distinguish the two states. -function throwInvalidTokenError(): never { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "Invalid remote control session or token", - }); -} - -function buildWebUrl(sessionId: string, token: string): string { - const base = env.NEXT_PUBLIC_WEB_URL.replace(/\/$/, ""); - const t = encodeURIComponent(token); - // Pass the bearer token as a URL fragment, not a query param. The - // fragment is never sent to any server, never appears in `Referer` - // when the viewer navigates away, and stays out of access logs. - // The web viewer reads it client-side from `location.hash`. - return `${base}/agents/remote-control/${sessionId}#${REMOTE_CONTROL_TOKEN_PARAM}=${t}`; -} - -function buildWsUrl(routingKey: string, sessionId: string): string { - const httpToWs = env.RELAY_URL.replace(/^http/, "ws").replace(/\/$/, ""); - return `${httpToWs}/hosts/${routingKey}/remote-control/${sessionId}`; -} - -async function getWorkspaceWithHost( - workspaceId: string, - organizationId: string, -) { - const ws = await dbWs.query.v2Workspaces.findFirst({ - where: and( - eq(v2Workspaces.id, workspaceId), - eq(v2Workspaces.organizationId, organizationId), - ), - }); - if (!ws) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Workspace not found in this organization", - }); - } - const host = await dbWs.query.v2Hosts.findFirst({ - where: and( - eq(v2Hosts.organizationId, organizationId), - eq(v2Hosts.machineId, ws.hostId), - ), - }); - if (!host) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Host record missing for workspace", - }); - } - return { workspace: ws, host }; -} - -async function ensureUserOnHost( - userId: string, - organizationId: string, - hostId: string, -) { - const membership = await dbWs.query.v2UsersHosts.findFirst({ - where: and( - eq(v2UsersHosts.organizationId, organizationId), - eq(v2UsersHosts.userId, userId), - eq(v2UsersHosts.hostId, hostId), - ), - }); - if (!membership) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "You are not a member of this host", - }); - } -} - -// Authoritative tear-down on the host. The cloud row should already be -// transitioned to `revoked` BEFORE calling this so future viewer attaches -// fail (via `get`) even if the host call below fails. Throwing here is -// deliberate: the host owns the in-memory session/viewer set, so if we -// can't reach it we cannot promise the user that connected viewers were -// disconnected. The caller surfaces this to the user, who can retry — -// the cloud UPDATE is gated on `status='active'`, so retries are idempotent. -async function callHostRevoke(args: { - organizationId: string; - hostId: string; - sessionId: string; - actorUserId: string; - actorEmail?: string; -}): Promise { - const jwt = await mintUserJwt({ - userId: args.actorUserId, - email: args.actorEmail, - organizationIds: [args.organizationId], - scope: "remote-control", - ttlSeconds: 60, - }); - const routingKey = buildHostRoutingKey(args.organizationId, args.hostId); - await relayMutation<{ sessionId: string }, unknown>( - { relayUrl: env.RELAY_URL, hostId: routingKey, jwt, timeoutMs: 5000 }, - "terminal.remoteControl.revoke", - { sessionId: args.sessionId }, - ); -} - -export const remoteControlRouter = createTRPCRouter({ - create: protectedProcedure - .input(createInput) - .mutation(async ({ ctx, input }) => { - const organizationId = await requireActiveOrgMembership(ctx); - const userId = ctx.session.user.id; - const { workspace, host } = await getWorkspaceWithHost( - input.workspaceId, - organizationId, - ); - await ensureUserOnHost(userId, organizationId, host.machineId); - - const sessionId = crypto.randomUUID(); - const ttlSec = input.ttlSec ?? REMOTE_CONTROL_DEFAULT_TTL_SEC; - - const [owner] = await dbWs - .select({ email: users.email }) - .from(users) - .where(eq(users.id, userId)) - .limit(1); - - const jwt = await mintUserJwt({ - userId, - email: owner?.email, - organizationIds: [organizationId], - scope: "remote-control", - ttlSeconds: 300, - }); - const routingKey = buildHostRoutingKey(organizationId, host.machineId); - - const minted = await relayMutation< - { - sessionId: string; - terminalId: string; - workspaceId: string; - mode: "command" | "full"; - createdByUserId: string; - ttlSec?: number; - }, - MintTokenResult - >( - // Bound the host call so a stuck relay/host doesn't pin the - // Share button in "Starting…" forever. Matches `revoke`. - { relayUrl: env.RELAY_URL, hostId: routingKey, jwt, timeoutMs: 5000 }, - "terminal.remoteControl.mintToken", - { - sessionId, - terminalId: input.terminalId, - workspaceId: input.workspaceId, - mode: input.mode, - createdByUserId: userId, - ttlSec, - }, - ); - - const expiresAt = new Date(minted.expiresAt * 1000); - // If the DB insert fails, the host already has a live session - // keyed to a token-hash the cloud never persisted — invisible to - // `listForWorkspace`/`revoke` until the host TTL sweep. Best- - // effort revoke it on the host so the minted token is unusable. - try { - await dbWs.insert(v2RemoteControlSessions).values({ - id: sessionId, - organizationId, - hostId: host.machineId, - workspaceId: workspace.id, - terminalId: input.terminalId, - createdByUserId: userId, - mode: input.mode, - status: "active", - tokenHash: minted.tokenHash, - expiresAt, - }); - } catch (insertErr) { - let orphanRevokeFailed = false; - try { - await callHostRevoke({ - organizationId, - hostId: host.machineId, - sessionId, - actorUserId: userId, - actorEmail: owner?.email, - }); - } catch (revokeErr) { - // Both the cloud INSERT and the orphan-cleanup host revoke - // failed. The host still has a live session keyed to a - // token-hash the cloud never persisted, invisible to - // `listForWorkspace`/`revoke` until the host TTL sweep. - // `console.error` with a structured marker so the log - // scraper can alert. Use a distinct prefix so future - // Sentry `captureConsoleIntegration` picks it up. - orphanRevokeFailed = true; - console.error("[remote-control:orphan-host-session]", { - sessionId, - hostId: host.machineId, - organizationId, - insertError: - insertErr instanceof Error - ? insertErr.message - : String(insertErr), - revokeError: - revokeErr instanceof Error - ? revokeErr.message - : String(revokeErr), - }); - } - // Wrap the raw drizzle/pg error so its message (which often - // contains constraint / column names — schema info) does - // not leak to the client through tRPC's default serializer. - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: orphanRevokeFailed - ? "Failed to create remote control session; an orphan host session may still exist and will expire on its own." - : "Failed to create remote control session.", - cause: insertErr, - }); - } - - return { - sessionId, - token: minted.token, - expiresAt: expiresAt.toISOString(), - webUrl: buildWebUrl(sessionId, minted.token), - wsUrl: buildWsUrl(routingKey, sessionId), - routingKey, - mode: input.mode, - }; - }), - - // `get` is intentionally `publicProcedure`: the share-link recipient is - // often anonymous (a colleague's browser, a phone, a kiosk). Holding the - // raw token IS the credential — we hash it and compare against the row's - // `token_hash` in constant time. No org membership required, no other - // fields exposed without proof of token possession. - // - // Implemented as a `mutation` (not `query`) so tRPC's `httpBatchLink` - // puts the input — including the bearer token — in the request BODY - // instead of serializing it into the URL query string, which would - // otherwise land the token in server access logs and undo the - // fragment-URL fix. - get: publicProcedure.input(getInput).mutation(async ({ input }) => { - const row = await dbWs.query.v2RemoteControlSessions.findFirst({ - where: eq(v2RemoteControlSessions.id, input.sessionId), - }); - // Always run the constant-time compare — against the row's hash - // when present, otherwise a fixed dummy — so missing-session and - // wrong-token paths are indistinguishable in both timing and - // response (single UNAUTHORIZED, generic message). - const providedHash = sha256Hex(input.token); - const expectedHash = row?.tokenHash ?? DUMMY_TOKEN_HASH; - const matches = constantTimeHexEqual(providedHash, expectedHash); - if (!row || !matches) throwInvalidTokenError(); - // Cloud-side gate: refuse to hand out a WS endpoint for sessions - // that are revoked, expired, or past their TTL even if the sweep - // hasn't promoted the row to `expired` yet. Host auth would also - // reject the attach, but this is defense-in-depth and prevents - // the viewer UI from taking the live-connect path at all. - const effectiveStatus = - row.status === "active" && row.expiresAt <= new Date() - ? "expired" - : row.status; - if (effectiveStatus !== "active") { - return { - sessionId: row.id, - workspaceId: row.workspaceId, - terminalId: row.terminalId, - mode: row.mode, - status: effectiveStatus, - expiresAt: row.expiresAt.toISOString(), - wsUrl: null, - routingKey: null, - }; - } - const routingKey = buildHostRoutingKey(row.organizationId, row.hostId); - return { - sessionId: row.id, - workspaceId: row.workspaceId, - terminalId: row.terminalId, - mode: row.mode, - status: effectiveStatus, - expiresAt: row.expiresAt.toISOString(), - wsUrl: buildWsUrl(routingKey, row.id), - routingKey, - }; - }), - - // Owner / host-member revoke. Requires both an active org membership - // AND host membership — otherwise an org member who isn't on the host - // could revoke other people's sessions on a host they have no claim - // to. Anonymous viewers reach `revokeWithToken` below instead. - revoke: protectedProcedure - .input(sessionIdInput) - .mutation(async ({ ctx, input }) => { - const organizationId = await requireActiveOrgMembership(ctx); - const userId = ctx.session.user.id; - const row = await dbWs.query.v2RemoteControlSessions.findFirst({ - where: and( - eq(v2RemoteControlSessions.id, input.sessionId), - eq(v2RemoteControlSessions.organizationId, organizationId), - ), - }); - if (!row) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Remote control session not found", - }); - } - await ensureUserOnHost(userId, organizationId, row.hostId); - // The cloud row gets revoked first so even if the host call fails, - // future attaches via the host see "session not found" or are denied - // when the host is told later via retry / re-sync. - // Belt-and-braces: scope by org to defend against a row mutating - // between the SELECT and the UPDATE, and gate on `status='active'` - // so a re-revoke (or revoke-after-natural-expiry) doesn't - // overwrite the original `revokedAt`/`revokedByUserId` or - // transition an `expired` row to `revoked`. - await dbWs - .update(v2RemoteControlSessions) - .set({ - status: "revoked", - revokedAt: new Date(), - revokedByUserId: userId, - }) - .where( - and( - eq(v2RemoteControlSessions.id, input.sessionId), - eq(v2RemoteControlSessions.organizationId, organizationId), - eq(v2RemoteControlSessions.status, "active"), - ), - ); - - const [owner] = await dbWs - .select({ email: users.email }) - .from(users) - .where(eq(users.id, userId)) - .limit(1); - try { - await callHostRevoke({ - organizationId, - hostId: row.hostId, - sessionId: input.sessionId, - actorUserId: userId, - actorEmail: owner?.email, - }); - } catch (err) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: - "Marked the session revoked, but could not reach the host to disconnect connected viewers. Retry to ensure viewers are disconnected.", - cause: err, - }); - } - - return { sessionId: input.sessionId, status: "revoked" as const }; - }), - - // Anonymous-viewer revoke. The bearer token IS the credential; if you - // hold it, you have the same authority as whoever you got the link - // from. We hash the token in constant time, then revoke the matching - // row. `revokedByUserId` is left null because we don't know which (if - // any) Superset user is on the other end of this WebSocket. - revokeWithToken: publicProcedure - .input(getInput) - .mutation(async ({ input }) => { - const row = await dbWs.query.v2RemoteControlSessions.findFirst({ - where: eq(v2RemoteControlSessions.id, input.sessionId), - }); - // Single response for missing-row and wrong-token — see `get` - // for the rationale. - const providedHash = sha256Hex(input.token); - const expectedHash = row?.tokenHash ?? DUMMY_TOKEN_HASH; - const matches = constantTimeHexEqual(providedHash, expectedHash); - if (!row || !matches) throwInvalidTokenError(); - await dbWs - .update(v2RemoteControlSessions) - .set({ status: "revoked", revokedAt: new Date() }) - .where( - and( - eq(v2RemoteControlSessions.id, input.sessionId), - eq(v2RemoteControlSessions.organizationId, row.organizationId), - eq(v2RemoteControlSessions.status, "active"), - ), - ); - // Authoritative host tear-down using the row creator's identity - // (the JWT only needs to be valid enough to traverse the relay). - // If the host call fails we still keep the cloud row as `revoked` - // — but we MUST surface the error so the viewer doesn't see a - // success toast while still controlling the terminal. - const [owner] = await dbWs - .select({ email: users.email }) - .from(users) - .where(eq(users.id, row.createdByUserId)) - .limit(1); - try { - await callHostRevoke({ - organizationId: row.organizationId, - hostId: row.hostId, - sessionId: input.sessionId, - actorUserId: row.createdByUserId, - actorEmail: owner?.email, - }); - } catch (err) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: - "Marked the session revoked, but could not reach the host to disconnect connected viewers. Retry to ensure viewers are disconnected.", - cause: err, - }); - } - return { sessionId: input.sessionId, status: "revoked" as const }; - }), - - // Lists sessions for a workspace, scoped to host members. Org-wide - // visibility would let anyone in the org enumerate other people's - // share sessions on hosts they don't belong to. - listForWorkspace: protectedProcedure - .input(listInput) - .query(async ({ ctx, input }) => { - const organizationId = await requireActiveOrgMembership(ctx); - const userId = ctx.session.user.id; - const workspace = await dbWs.query.v2Workspaces.findFirst({ - where: and( - eq(v2Workspaces.id, input.workspaceId), - eq(v2Workspaces.organizationId, organizationId), - ), - }); - if (!workspace) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Workspace not found in this organization", - }); - } - await ensureUserOnHost(userId, organizationId, workspace.hostId); - const rows = await dbWs.query.v2RemoteControlSessions.findMany({ - where: and( - eq(v2RemoteControlSessions.workspaceId, input.workspaceId), - eq(v2RemoteControlSessions.organizationId, organizationId), - ), - orderBy: [desc(v2RemoteControlSessions.createdAt)], - limit: 50, - }); - return rows.map((r) => ({ - sessionId: r.id, - terminalId: r.terminalId, - mode: r.mode, - status: r.status, - createdAt: r.createdAt.toISOString(), - expiresAt: r.expiresAt.toISOString(), - revokedAt: r.revokedAt ? r.revokedAt.toISOString() : null, - })); - }), - - expireStale: protectedProcedure.mutation(async ({ ctx }) => { - const organizationId = await requireActiveOrgMembership(ctx); - // Idempotent — safe for cron / manual sweep. - const updated = await dbWs - .update(v2RemoteControlSessions) - .set({ status: "expired" }) - .where( - and( - eq(v2RemoteControlSessions.organizationId, organizationId), - eq(v2RemoteControlSessions.status, "active"), - lt(v2RemoteControlSessions.expiresAt, new Date()), - ), - ) - .returning({ id: v2RemoteControlSessions.id }); - return { count: updated.length }; - }), - - statuses: protectedProcedure.query(() => remoteControlSessionStatusValues), -}); diff --git a/plans/20260511-remote-control-remaining-work.md b/plans/20260511-remote-control-remaining-work.md deleted file mode 100644 index 927cba20b16..00000000000 --- a/plans/20260511-remote-control-remaining-work.md +++ /dev/null @@ -1,145 +0,0 @@ -# Remote Control Remaining Work Plan - -## Context - -PR #4345 adds browser-based remote control for v2 desktop terminals. The current branch has addressed most review comments: anonymous viewers can resolve sessions, the share-page token moved to a URL fragment, `get` is a mutation, revoked/expired sessions no longer return a WebSocket URL, relay mint/revoke calls are bounded, and orphan host-session cleanup is attempted on cloud insert failure. - -The remaining merge concern is that the bearer token is still placed on the WebSocket upgrade URL. That token grants terminal control, so relying on relay logger redaction is not enough for a production rollout. - -## Merge Blocker - -### 1. Move WebSocket bearer auth out of the URL - -Current behavior: - -- `apps/web/src/app/(public)/agents/remote-control/[sessionId]/components/RemoteTerminal/RemoteTerminal.tsx` opens the socket as `meta.wsUrl?remoteControlToken=...`. -- `packages/host-service/src/terminal/remote-control/route.ts` reads the token from `c.req.query(REMOTE_CONTROL_TOKEN_PARAM)`. -- `apps/relay/src/index.ts` redacts `remoteControlToken` in Hono logs, but the token can still appear in infrastructure access logs, replay/debug logs, browser tooling, or any logging path that records request URLs before app-level redaction. - -Target behavior: - -- The public share URL keeps the token in the fragment. -- `remoteControl.get` keeps accepting the token in a tRPC mutation body. -- The WebSocket URL contains no bearer token. -- The viewer authenticates by sending a first WebSocket message over `wss`. - -Implementation outline: - -1. Extend `packages/shared/src/remote-control-protocol.ts` with an auth client message: - - Add `RemoteControlAuthMessage`. - - Add `{ type: "auth"; token: string }` to `RemoteControlClientMessage`. - - Keep `REMOTE_CONTROL_TOKEN_PARAM` only for the page fragment unless it is still used elsewhere. - -2. Update the web viewer: - - In `RemoteTerminal.tsx`, change `new WebSocket(urlWithToken)` to `new WebSocket(meta.wsUrl)`. - - On `ws.onopen`, immediately send `{ type: "auth", token }`. - - Do not mark the terminal fully open until the host returns `hello`, or add a distinct `"authenticating"` state if useful. - - Keep all existing `input`, `resize`, `ping`, `stop`, and revoke behavior after auth. - -3. Update the host WebSocket route: - - Stop reading `remoteControlToken` from `c.req.query`. - - Do not authenticate or call `attachTerminalViewer` in `onOpen`. - - Track an unauthenticated connection state in `ctx`. - - In `onMessage`, if the socket is not authenticated: - - Only accept `{ type: "auth", token }`. - - Validate the token with `authenticateSession(sessionId, token)`. - - Attach the terminal viewer only after successful auth. - - Send the existing `hello`, `snapshot`, and possible `exit` messages after attach. - - Close with `1008` on invalid auth. - - Add a short auth timeout, for example 5 seconds, that closes unauthenticated sockets. - - Ensure `cleanup()` remains idempotent for unauthenticated, partially attached, and fully attached sockets. - -4. Update relay comments/logging: - - Remove or rewrite comments that say the remote-control viewer must put its token on the WS upgrade URL. - - Keep query redaction if normal host tunnel JWTs still use `?token=...`. - -5. Add tests: - - Host route/session-manager coverage for successful auth-first attach. - - Host route coverage for missing auth message / auth timeout if practical. - - Host route coverage for invalid auth message closing before terminal attach. - - Web unit coverage is optional if the repo has no established pattern for this component. - -## Strongly Recommended Before Broad Rollout - -### 2. Make revoke retry semantics match the UI - -Current behavior: - -- Cloud marks the DB row `revoked` before calling the host. -- If the host revoke fails, the mutation throws. -- The desktop UI can later hydrate from `listForWorkspace`, see no active session, and lose the Stop affordance even though already-connected viewers may remain attached until host TTL sweep or host-side revoke. - -Options: - -1. Host-first revoke: - - Call the host revoke first. - - Only mark the DB row revoked after host teardown succeeds. - - Tradeoff: future attaches could still pass cloud `get` while host revoke is in flight or failing. - -2. Add a pending/failed revoke state: - - Add statuses such as `revoking` or `revoke_failed`. - - Keep the owner UI in a retryable state while connected viewers might still be attached. - - `get` should still refuse to return `wsUrl` for `revoking` / `revoke_failed`. - -3. Keep DB-first revoke but keep retry affordance: - - `listForWorkspace` returns enough metadata for recently revoked sessions whose host teardown failed. - - Desktop shows a "Retry host disconnect" action. - -Recommended path: option 2 if the product wants accurate state, option 3 if we want minimal scope for this PR. - -### 3. Enforce the PostHog feature flag server-side if it is an access gate - -Current behavior: - -- `FEATURE_FLAGS.WEB_REMOTE_CONTROL_ACCESS` hides the desktop Share button. -- `remoteControl.create` still allows any authenticated org+host member to start a session if they call the API directly. - -Decision: - -- If the flag is only UI rollout, update comments to say it is not an authorization boundary. -- If the flag controls who may start sessions, enforce it in `packages/trpc/src/router/remote-control/remote-control.ts` before minting a host token. - -### 4. Require or explicitly configure web relay CSP - -Current behavior: - -- `apps/web/next.config.ts` adds the relay `wss:` origin to `connect-src` only when `process.env.RELAY_URL` exists. -- `apps/web/src/env.ts` does not validate `RELAY_URL`. - -Add one of: - -- A required web server env var for `RELAY_URL`. -- A dedicated `NEXT_PUBLIC_RELAY_URL` / `NEXT_PUBLIC_RELAY_WS_ORIGIN`. -- Deployment documentation and CI validation that web has the relay origin configured. - -## Quality Cleanup - -These are not merge blockers, but they are still valid review leftovers: - -- Make the expiry-sweep test deterministic instead of sleeping around a timer. -- Use Node's native `"base64url"` encoding in `session-manager.ts`. -- Remove the unreachable try/catch around signature `base64UrlDecode`. -- Fix the `onRevoke` comment that says it fires synchronously while using `queueMicrotask`. -- Decide whether `runCommand` should share the input token bucket or have its own command bucket. -- Set `ctx.viewerSocket` only after `addViewer` succeeds to avoid cleanup doing a no-op remove on an unregistered socket. - -## Validation Checklist - -Run before asking for final review: - -```bash -bun run --cwd apps/web typecheck -bun run --cwd packages/trpc typecheck -bun run --cwd packages/host-service typecheck -bun test packages/host-service/src/terminal/remote-control/session-manager.test.ts -bun run lint -git diff --check -``` - -If the WebSocket auth flow changes substantially, also manually verify: - -- Anonymous viewer opens a copied share link without a Superset session. -- Invalid token never attaches to a terminal. -- Revoked and expired sessions do not receive `wsUrl` from cloud and cannot attach to host. -- First WebSocket request URL contains no `remoteControlToken` or raw bearer token. -- Relay and host logs do not print the auth message payload.