From 7c7e97d11e0ba44125a13817e53bfee8716e7c1b Mon Sep 17 00:00:00 2001 From: Aman Ganapathy <84686202+nams1570@users.noreply.github.com> Date: Fri, 6 Feb 2026 09:25:53 -0800 Subject: [PATCH 01/65] [Chore] Bump timeout for email monitor (#1163) ### Context A small amount of email monitor requests take slightly less than 3 minutes to complete. This meant our old timeout of 2 minutes was flagging a few kuma pulls as "down" when they weren't. ### Summary of Changes We just bumped the timeout. This should be ok in prod because the uptime kuma timeout is set to 200 seconds. --- apps/backend/src/app/health/email/route.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/src/app/health/email/route.tsx b/apps/backend/src/app/health/email/route.tsx index b317ed42fd..90eafada78 100644 --- a/apps/backend/src/app/health/email/route.tsx +++ b/apps/backend/src/app/health/email/route.tsx @@ -103,7 +103,7 @@ const isExpectedVerificationEmail = (email: ResendEmail, testEmail: string): boo const waitForVerificationEmail = async (testEmail: string, useInbucket: boolean) => { await traceSpan("waiting for verification email", async () => { - const MAX_POLL_ATTEMPTS = 24; + const MAX_POLL_ATTEMPTS = 36; const POLL_INTERVAL_MS = 5000; for (let attempt = 1; attempt <= MAX_POLL_ATTEMPTS; attempt++) { From b182c1b03d4790a882ef9348a6406faf60b845c3 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Fri, 6 Feb 2026 18:33:31 -0800 Subject: [PATCH 02/65] Metadata on teams detail page --- .../teams/[teamId]/page-client.tsx | 17 ++ .../users/[userId]/page-client.tsx | 162 ++---------------- .../src/components/metadata-editor.tsx | 154 +++++++++++++++++ 3 files changed, 190 insertions(+), 143 deletions(-) create mode 100644 apps/dashboard/src/components/metadata-editor.tsx diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/teams/[teamId]/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/teams/[teamId]/page-client.tsx index 8473b2023b..94e45419bb 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/teams/[teamId]/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/teams/[teamId]/page-client.tsx @@ -2,6 +2,7 @@ import { TeamMemberSearchTable } from '@/components/data-table/team-member-search-table'; import { TeamMemberTable } from '@/components/data-table/team-member-table'; import { InputField } from '@/components/form-fields'; +import { MetadataSection } from '@/components/metadata-editor'; import { ActionDialog, Button, Form, Separator } from '@/components/ui'; import { yupResolver } from '@hookform/resolvers/yup'; import { ServerTeam } from '@stackframe/stack'; @@ -118,6 +119,22 @@ export default function PageClient(props: { teamId: string }) { } > + { + await team.update({ clientMetadata: value }); + }} + onUpdateClientReadOnlyMetadata={async (value) => { + await team.update({ clientReadOnlyMetadata: value }); + }} + onUpdateServerMetadata={async (value) => { + await team.update({ serverMetadata: value }); + }} + /> ); diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/page-client.tsx index 95565c82ba..d438a6083f 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/page-client.tsx @@ -3,8 +3,7 @@ import { EditableInput } from "@/components/editable-input"; import { FormDialog, SmartFormDialog } from "@/components/form-dialog"; import { InputField, SelectField } from "@/components/form-fields"; -import { StyledLink } from "@/components/link"; -import { SettingCard } from "@/components/settings"; +import { MetadataSection } from "@/components/metadata-editor"; import { Accordion, AccordionContent, @@ -32,7 +31,6 @@ import { DropdownMenuTrigger, Input, Separator, - SimpleTooltip, Table, TableBody, TableCell, @@ -44,22 +42,19 @@ import { useToast } from "@/components/ui"; import { DeleteUserDialog, ImpersonateUserDialog } from "@/components/user-dialogs"; -import { useThemeWatcher } from '@/lib/theme'; -import MonacoEditor from '@monaco-editor/react'; import { AtIcon, CalendarIcon, CheckIcon, DotsThreeIcon, EnvelopeIcon, HashIcon, ProhibitIcon, ShieldIcon, SquareIcon, XIcon } from "@phosphor-icons/react"; import { ServerContactChannel, ServerOAuthProvider, ServerUser } from "@stackframe/stack"; import { KnownErrors } from "@stackframe/stack-shared"; import { fromNow } from "@stackframe/stack-shared/dist/utils/dates"; import { captureError, StackAssertionError } from '@stackframe/stack-shared/dist/utils/errors'; -import { isJsonSerializable } from "@stackframe/stack-shared/dist/utils/json"; import { deindent } from "@stackframe/stack-shared/dist/utils/strings"; -import { useEffect, useMemo, useState } from "react"; +import { useState } from "react"; import * as yup from "yup"; import { AppEnabledGuard } from "../../app-enabled-guard"; import { PageLayout } from "../../page-layout"; import { useAdminApp } from "../../use-admin-app"; -const metadataDocsUrl = "https://docs.stack-auth.com/docs/concepts/custom-user-data"; +const userMetadataDocsUrl = "https://docs.stack-auth.com/docs/concepts/custom-user-data"; type UserInfoProps = { icon: React.ReactNode, @@ -79,95 +74,6 @@ function UserInfo({ icon, name, children }: UserInfoProps) { ); } -type MetadataEditorProps = { - title: string, - initialValue: string, - hint: string, - onUpdate?: (value: any) => Promise, -} -function MetadataEditor({ title, initialValue, onUpdate, hint }: MetadataEditorProps) { - const formatJson = (json: string) => JSON.stringify(JSON.parse(json), null, 2); - const [hasChanged, setHasChanged] = useState(false); - const [isMounted, setIsMounted] = useState(false); - - const { mounted, theme } = useThemeWatcher(); - - const [value, setValue] = useState(formatJson(initialValue)); - const isJson = useMemo(() => { - return isJsonSerializable(value); - }, [value]); - - // Ensure proper mounting lifecycle - useEffect(() => { - setIsMounted(true); - return () => { - setIsMounted(false); - }; - }, []); - - const handleSave = async () => { - if (isJson) { - const formatted = formatJson(value); - setValue(formatted); - await onUpdate?.(JSON.parse(formatted)); - setHasChanged(false); - } - }; - - // Only render Monaco when both mounted states are true - const shouldRenderMonaco = mounted && isMounted; - - return
-

- {title} - -

- {shouldRenderMonaco ? ( -
- { - setValue(x ?? ''); - setHasChanged(true); - }} - theme={theme === 'dark' ? 'vs-dark' : 'vs'} - options={{ - tabSize: 2, - minimap: { - enabled: false, - }, - scrollBeyondLastLine: false, - overviewRulerLanes: 0, - lineNumbersMinChars: 3, - showFoldingControls: 'never', - }} - /> -
- ) : ( -
-
Loading editor...
-
- )} -
- - -
-
; -} - export default function PageClient({ userId }: { userId: string }) { const stackAdminApp = useAdminApp(); const user = stackAdminApp.useUser(userId); @@ -1356,51 +1262,6 @@ function OAuthProvidersSection({ user }: OAuthProvidersSectionProps) { ); } -type MetadataSectionProps = { - user: ServerUser, -}; - -function MetadataSection({ user }: MetadataSectionProps) { - return ( - - Use metadata to store a custom JSON object on the user.{" "} - Learn more in the docs. - - } - > -
- { - await user.setClientMetadata(value); - }} - /> - { - await user.setClientReadOnlyMetadata(value); - }} - /> - { - await user.setServerMetadata(value); - }} - /> -
-
- ); -} - function UserPage({ user }: { user: ServerUser }) { return ( @@ -1413,7 +1274,22 @@ function UserPage({ user }: { user: ServerUser }) { - + { + await user.setClientMetadata(value); + }} + onUpdateClientReadOnlyMetadata={async (value) => { + await user.setClientReadOnlyMetadata(value); + }} + onUpdateServerMetadata={async (value) => { + await user.setServerMetadata(value); + }} + /> ); diff --git a/apps/dashboard/src/components/metadata-editor.tsx b/apps/dashboard/src/components/metadata-editor.tsx new file mode 100644 index 0000000000..1be4ef1ada --- /dev/null +++ b/apps/dashboard/src/components/metadata-editor.tsx @@ -0,0 +1,154 @@ +"use client"; + +import { StyledLink } from "@/components/link"; +import { SettingCard } from "@/components/settings"; +import { Button, cn, SimpleTooltip } from "@/components/ui"; +import { useThemeWatcher } from '@/lib/theme'; +import MonacoEditor from '@monaco-editor/react'; +import { isJsonSerializable } from "@stackframe/stack-shared/dist/utils/json"; +import { useEffect, useMemo, useState } from "react"; + +type MetadataEditorProps = { + title: string, + initialValue: string, + hint: string, + onUpdate?: (value: any) => Promise, +}; + +export function MetadataEditor({ title, initialValue, onUpdate, hint }: MetadataEditorProps) { + const formatJson = (json: string) => JSON.stringify(JSON.parse(json), null, 2); + const [hasChanged, setHasChanged] = useState(false); + const [isMounted, setIsMounted] = useState(false); + + const { mounted, theme } = useThemeWatcher(); + + const [value, setValue] = useState(formatJson(initialValue)); + const isJson = useMemo(() => { + return isJsonSerializable(value); + }, [value]); + + // Ensure proper mounting lifecycle + useEffect(() => { + setIsMounted(true); + return () => { + setIsMounted(false); + }; + }, []); + + const handleSave = async () => { + if (isJson) { + const formatted = formatJson(value); + setValue(formatted); + await onUpdate?.(JSON.parse(formatted)); + setHasChanged(false); + } + }; + + // Only render Monaco when both mounted states are true + const shouldRenderMonaco = mounted && isMounted; + + return
+

+ {title} + +

+ {shouldRenderMonaco ? ( +
+ { + setValue(x ?? ''); + setHasChanged(true); + }} + theme={theme === 'dark' ? 'vs-dark' : 'vs'} + options={{ + tabSize: 2, + minimap: { + enabled: false, + }, + scrollBeyondLastLine: false, + overviewRulerLanes: 0, + lineNumbersMinChars: 3, + showFoldingControls: 'never', + }} + /> +
+ ) : ( +
+
Loading editor...
+
+ )} +
+ + +
+
; +} + +type MetadataSectionProps = { + clientMetadata: any, + clientReadOnlyMetadata: any, + serverMetadata: any, + onUpdateClientMetadata: (value: any) => Promise, + onUpdateClientReadOnlyMetadata: (value: any) => Promise, + onUpdateServerMetadata: (value: any) => Promise, + docsUrl: string, + entityName: string, +}; + +export function MetadataSection({ + clientMetadata, + clientReadOnlyMetadata, + serverMetadata, + onUpdateClientMetadata, + onUpdateClientReadOnlyMetadata, + onUpdateServerMetadata, + docsUrl, + entityName, +}: MetadataSectionProps) { + return ( + + Use metadata to store a custom JSON object on the {entityName}.{" "} + Learn more in the docs. + + } + > +
+ + + +
+
+ ); +} From 2072dd4b3d2a85e644a0dd653cc6c60cab0331ad Mon Sep 17 00:00:00 2001 From: BilalG1 Date: Mon, 9 Feb 2026 10:53:55 -0800 Subject: [PATCH 03/65] force db sync button (#1167) --- .../internal/external-db-sync/poller/route.ts | 15 +- .../external-db-sync/sequencer/route.ts | 9 +- .../external-db-sync/page-client.tsx | 142 +++++++++++++----- 3 files changed, 113 insertions(+), 53 deletions(-) diff --git a/apps/backend/src/app/api/latest/internal/external-db-sync/poller/route.ts b/apps/backend/src/app/api/latest/internal/external-db-sync/poller/route.ts index 6e82f8bb48..a12edab49f 100644 --- a/apps/backend/src/app/api/latest/internal/external-db-sync/poller/route.ts +++ b/apps/backend/src/app/api/latest/internal/external-db-sync/poller/route.ts @@ -53,11 +53,6 @@ function getPollerClaimLimit(): number { return parsed; } -function getLocalApiBaseUrl(): string { - const prefix = getEnvVariable("NEXT_PUBLIC_STACK_PORT_PREFIX", "81"); - return `http://localhost:${prefix}02`; -} - export const GET = createSmartRouteHandler({ metadata: { summary: "Poll outgoing requests and push to QStash", @@ -70,7 +65,7 @@ export const GET = createSmartRouteHandler({ auth: yupObject({}).nullable().optional(), method: yupString().oneOf(["GET"]).defined(), headers: yupObject({ - authorization: yupTuple([yupString().defined()]).defined(), + authorization: yupTuple([yupString().defined()]).optional(), }).defined(), query: yupObject({ maxDurationMs: yupString().optional(), @@ -85,12 +80,14 @@ export const GET = createSmartRouteHandler({ requests_processed: yupNumber().defined(), }).defined(), }), - handler: async ({ headers, query }) => { - const authHeader = headers.authorization[0]; - if (authHeader !== `Bearer ${getEnvVariable("CRON_SECRET")}`) { + handler: async ({ headers, query, auth }) => { + const isAdmin = auth?.type === "admin" && auth.project.id === "internal"; + const authHeader = headers.authorization?.[0]; + if (!isAdmin && authHeader !== `Bearer ${getEnvVariable("CRON_SECRET")}`) { throw new StatusError(401, "Unauthorized"); } + return await traceSpan("external-db-sync.poller", async (span) => { const startTime = performance.now(); const maxDurationMs = parseMaxDurationMs(query.maxDurationMs); diff --git a/apps/backend/src/app/api/latest/internal/external-db-sync/sequencer/route.ts b/apps/backend/src/app/api/latest/internal/external-db-sync/sequencer/route.ts index 2a40b3e0ef..7a51da1f27 100644 --- a/apps/backend/src/app/api/latest/internal/external-db-sync/sequencer/route.ts +++ b/apps/backend/src/app/api/latest/internal/external-db-sync/sequencer/route.ts @@ -165,7 +165,7 @@ export const GET = createSmartRouteHandler({ auth: yupObject({}).nullable().optional(), method: yupString().oneOf(["GET"]).defined(), headers: yupObject({ - authorization: yupTuple([yupString().defined()]).defined(), + authorization: yupTuple([yupString().defined()]).optional(), }).defined(), query: yupObject({ maxDurationMs: yupString().optional(), @@ -180,9 +180,10 @@ export const GET = createSmartRouteHandler({ iterations: yupNumber().defined(), }).defined(), }), - handler: async ({ headers, query }) => { - const authHeader = headers.authorization[0]; - if (authHeader !== `Bearer ${getEnvVariable("CRON_SECRET")}`) { + handler: async ({ headers, query, auth }) => { + const isAdmin = auth?.type === "admin" && auth.project.id === "internal"; + const authHeader = headers.authorization?.[0]; + if (!isAdmin && authHeader !== `Bearer ${getEnvVariable("CRON_SECRET")}`) { throw new StatusError(401, "Unauthorized"); } diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/external-db-sync/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/external-db-sync/page-client.tsx index 3a68d8d9d6..c1e7d57951 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/external-db-sync/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/external-db-sync/page-client.tsx @@ -226,6 +226,8 @@ export default function PageClient() { const [loading, setLoading] = useState(false); const [autoRefresh, setAutoRefresh] = useState(true); const [savingFusebox, setSavingFusebox] = useState(false); + const [forceSyncRunning, setForceSyncRunning] = useState(false); + const forceSyncAbortRef = useRef(null); const inFlightRef = useRef(false); const summarySamplesRef = useRef { + const abortController = new AbortController(); + forceSyncAbortRef.current = abortController; + setForceSyncRunning(true); + try { + const endpoints = [ + "/internal/external-db-sync/sequencer", + "/internal/external-db-sync/poller", + ]; + await Promise.all(endpoints.map(async (endpoint) => { + const response = await adminApp[stackAppInternalsSymbol].sendRequest( + endpoint, + { method: "GET", signal: abortController.signal }, + "admin", + ); + if (!response.ok) { + const body = await response.json().catch(() => null); + const message = typeof body?.error === "string" ? body.error : `Failed to trigger ${endpoint}: ${response.status}`; + throw new Error(message); + } + })); + await loadStatus(); + } catch (err) { + if (err instanceof DOMException && err.name === "AbortError") return; + throw err; + } finally { + forceSyncAbortRef.current = null; + setForceSyncRunning(false); + } + }, [adminApp, loadStatus]); + + const cancelForceSync = useCallback(() => { + forceSyncAbortRef.current?.abort(); + }, []); + useEffect(() => { runAsynchronously(loadStatus); }, [loadStatus]); @@ -682,50 +719,75 @@ export default function PageClient() { - - - Fusebox - - - {!fusebox ? ( -
- - - - -
- ) : ( - <> -
-
- Sequencer - Assigns sequence IDs and queues sync work. -
- setFusebox((current) => current ? { ...current, sequencerEnabled: checked } : current)} - /> +
+ + + Fusebox + + + {!fusebox ? ( +
+ + + +
-
-
- Poller - Dispatches queued sync jobs to QStash. + ) : ( + <> +
+
+ Sequencer + Assigns sequence IDs and queues sync work. +
+ setFusebox((current) => current ? { ...current, sequencerEnabled: checked } : current)} + />
- setFusebox((current) => current ? { ...current, pollerEnabled: checked } : current)} - /> -
+
+
+ Poller + Dispatches queued sync jobs to QStash. +
+ setFusebox((current) => current ? { ...current, pollerEnabled: checked } : current)} + /> +
+ +
+ +
+ + )} + + -
- + {forceSyncRunning && ( + -
- - )} - - + )} +
+
+
+
); From f2f44086d8f4b5b2bfb691dde0501182ce7e77fc Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Mon, 9 Feb 2026 11:20:15 -0800 Subject: [PATCH 04/65] Merge existing DB sync migrations --- AGENTS.md | 3 +- .../migration.sql | 116 ++++++++++++------ .../migration.sql | 24 ---- apps/backend/prisma/schema.prisma | 6 +- 4 files changed, 82 insertions(+), 67 deletions(-) delete mode 100644 apps/backend/prisma/migrations/20260204014127_external_db_metadata/migration.sql diff --git a/AGENTS.md b/AGENTS.md index 8fe66efc9a..d05907b13b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -96,7 +96,8 @@ To see all development ports, refer to the index.html of `apps/dev-launchpad/pub - When building frontend or React code for the dashboard, refer to DESIGN-GUIDE.md. - NEVER implement a hacky solution without EXPLICIT approval from the user. Always go the extra mile to make sure the solution is clean, maintainable, and robust. - Fail early, fail loud. Fail fast with an error instead of silently continuing. -- Do NOT use `as`/`any`/type casts or anything else like that to bypass the type system unless you specifically asked the user about it. Most of the time a place where you would use type casts is not one where you actually need them. Avoid wherever possible. +- Do NOT use `as`/`any`/type casts or anything else like that to bypass the type system unless you specifically asked the user about it. Most of the time a place where you would use type casts is not one where you actually need them. Avoid wherever possible. +- When writing database migration files, assume that we have >1,000,000 rows in every table (unless otherwise specified). This means you may have to use CONDITIONALLY_REPEAT_MIGRATION_SENTINEL to avoid running the migration and things like concurrent index builds; see the existing migrations for examples. ### Code-related - Use ES6 maps instead of records wherever you can. diff --git a/apps/backend/prisma/migrations/20251125030551_external_db_sync/migration.sql b/apps/backend/prisma/migrations/20251125030551_external_db_sync/migration.sql index e90bcf2451..2c66a9e821 100644 --- a/apps/backend/prisma/migrations/20251125030551_external_db_sync/migration.sql +++ b/apps/backend/prisma/migrations/20251125030551_external_db_sync/migration.sql @@ -1,6 +1,6 @@ -- Creates a global sequence starting at 1 with increment of 11 for tracking row changes. -- This sequence is used to order data changes across all tables in the database. -CREATE SEQUENCE global_seq_id +CREATE SEQUENCE global_seq_id AS BIGINT START 1 INCREMENT BY 11 @@ -8,33 +8,24 @@ CREATE SEQUENCE global_seq_id NO MAXVALUE; -- SPLIT_STATEMENT_SENTINEL --- Adds sequenceId column to ContactChannel and ProjectUser tables. --- This column stores the sequence number from global_seq_id to track when each row was last modified. -ALTER TABLE "ContactChannel" ADD COLUMN "sequenceId" BIGINT; +-- Adds sequenceId and shouldUpdateSequenceId columns to ContactChannel and ProjectUser tables. +-- sequenceId stores the sequence number from global_seq_id to track when each row was last modified. +-- shouldUpdateSequenceId is a flag to track which rows need their sequenceId updated. +ALTER TABLE "ContactChannel" ADD COLUMN "sequenceId" BIGINT; -- SPLIT_STATEMENT_SENTINEL -ALTER TABLE "ProjectUser" ADD COLUMN "sequenceId" BIGINT; - --- SPLIT_STATEMENT_SENTINEL --- Creates unique indexes on sequenceId columns to ensure no duplicate sequence IDs exist. --- This guarantees each row has a unique position in the change sequence. -CREATE UNIQUE INDEX "ContactChannel_sequenceId_key" ON "ContactChannel"("sequenceId"); - --- SPLIT_STATEMENT_SENTINEL -CREATE UNIQUE INDEX "ProjectUser_sequenceId_key" ON "ProjectUser"("sequenceId"); +ALTER TABLE "ContactChannel" ADD COLUMN "shouldUpdateSequenceId" BOOLEAN NOT NULL DEFAULT TRUE; -- SPLIT_STATEMENT_SENTINEL --- Creates composite indexes on (tenancyId, sequenceId) for efficient sync-engine queries. --- These allow fast lookups of rows by tenant ordered by sequence number. -CREATE INDEX "ProjectUser_tenancyId_sequenceId_idx" ON "ProjectUser"("tenancyId", "sequenceId"); +ALTER TABLE "ProjectUser" ADD COLUMN "sequenceId" BIGINT; -- SPLIT_STATEMENT_SENTINEL -CREATE INDEX "ContactChannel_tenancyId_sequenceId_idx" ON "ContactChannel"("tenancyId", "sequenceId"); +ALTER TABLE "ProjectUser" ADD COLUMN "shouldUpdateSequenceId" BOOLEAN NOT NULL DEFAULT TRUE; -- SPLIT_STATEMENT_SENTINEL -- Creates OutgoingRequest table to queue sync requests to external databases. -- Each request stores the QStash options for making HTTP requests and tracks when fulfillment started. -CREATE TABLE "OutgoingRequest" ( +CREATE TABLE "OutgoingRequest" ( "id" UUID NOT NULL, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "deduplicationKey" TEXT, @@ -45,14 +36,6 @@ CREATE TABLE "OutgoingRequest" ( CONSTRAINT "OutgoingRequest_deduplicationKey_key" UNIQUE ("deduplicationKey") ); --- SPLIT_STATEMENT_SENTINEL -CREATE INDEX "OutgoingRequest_startedFulfillingAt_deduplicationKey_idx" ON "OutgoingRequest"("startedFulfillingAt", "deduplicationKey"); - --- SPLIT_STATEMENT_SENTINEL --- Creates composite index on startedFulfillingAt and createdAt for efficient querying of pending requests in order. --- This allows fast lookups of pending requests (WHERE startedFulfillingAt IS NULL) ordered by createdAt. -CREATE INDEX "OutgoingRequest_startedFulfillingAt_createdAt_idx" ON "OutgoingRequest"("startedFulfillingAt", "createdAt"); - -- SPLIT_STATEMENT_SENTINEL -- Creates DeletedRow table to log information about deleted rows from other tables. -- Stores the primary key and full data of deleted rows so external databases can be notified of deletions. @@ -65,41 +48,96 @@ CREATE TABLE "DeletedRow" ( "data" JSONB, "deletedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "startedFulfillingAt" TIMESTAMP(3), + "shouldUpdateSequenceId" BOOLEAN NOT NULL DEFAULT TRUE, CONSTRAINT "DeletedRow_pkey" PRIMARY KEY ("id") ); -- SPLIT_STATEMENT_SENTINEL --- Creates indexes on DeletedRow table for efficient querying by sequence, table name, and tenant. -CREATE UNIQUE INDEX "DeletedRow_sequenceId_key" ON "DeletedRow"("sequenceId"); +-- Creates ExternalDbSyncMetadata table to store external database sync configuration. +-- Uses a singleton constraint to ensure only one row exists. +CREATE TABLE "ExternalDbSyncMetadata" ( + "id" TEXT NOT NULL DEFAULT gen_random_uuid(), + "singleton" "BooleanTrue" NOT NULL DEFAULT 'TRUE'::"BooleanTrue", + "sequencerEnabled" BOOLEAN NOT NULL DEFAULT true, + "pollerEnabled" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ExternalDbSyncMetadata_pkey" PRIMARY KEY ("id") +); -- SPLIT_STATEMENT_SENTINEL -CREATE INDEX "DeletedRow_tableName_idx" ON "DeletedRow"("tableName"); +-- SINGLE_STATEMENT_SENTINEL +-- RUN_OUTSIDE_TRANSACTION_SENTINEL +-- Creates unique indexes on sequenceId columns to ensure no duplicate sequence IDs exist. +CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "ContactChannel_sequenceId_key" ON /* SCHEMA_NAME_SENTINEL */."ContactChannel"("sequenceId"); -- SPLIT_STATEMENT_SENTINEL -CREATE INDEX "DeletedRow_tenancyId_idx" ON "DeletedRow"("tenancyId"); +-- SINGLE_STATEMENT_SENTINEL +-- RUN_OUTSIDE_TRANSACTION_SENTINEL +CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "ProjectUser_sequenceId_key" ON /* SCHEMA_NAME_SENTINEL */."ProjectUser"("sequenceId"); -- SPLIT_STATEMENT_SENTINEL --- Creates composite index for efficient querying of deleted rows by tenant and table, ordered by sequence. -CREATE INDEX "DeletedRow_tenancyId_tableName_sequenceId_idx" ON "DeletedRow"("tenancyId", "tableName", "sequenceId"); +-- SINGLE_STATEMENT_SENTINEL +-- RUN_OUTSIDE_TRANSACTION_SENTINEL +CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "DeletedRow_sequenceId_key" ON /* SCHEMA_NAME_SENTINEL */."DeletedRow"("sequenceId"); -- SPLIT_STATEMENT_SENTINEL --- Adds shouldUpdateSequenceId flag to track which rows need their sequenceId updated. -ALTER TABLE "ProjectUser" ADD COLUMN "shouldUpdateSequenceId" BOOLEAN NOT NULL DEFAULT TRUE; +-- SINGLE_STATEMENT_SENTINEL +-- RUN_OUTSIDE_TRANSACTION_SENTINEL +CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "ExternalDbSyncMetadata_singleton_key" ON /* SCHEMA_NAME_SENTINEL */."ExternalDbSyncMetadata"("singleton"); -- SPLIT_STATEMENT_SENTINEL -ALTER TABLE "ContactChannel" ADD COLUMN "shouldUpdateSequenceId" BOOLEAN NOT NULL DEFAULT TRUE; +-- SINGLE_STATEMENT_SENTINEL +-- RUN_OUTSIDE_TRANSACTION_SENTINEL +-- Creates composite indexes on (tenancyId, sequenceId) for efficient sync-engine queries. +CREATE INDEX CONCURRENTLY IF NOT EXISTS "ProjectUser_tenancyId_sequenceId_idx" ON /* SCHEMA_NAME_SENTINEL */."ProjectUser"("tenancyId", "sequenceId"); + +-- SPLIT_STATEMENT_SENTINEL +-- SINGLE_STATEMENT_SENTINEL +-- RUN_OUTSIDE_TRANSACTION_SENTINEL +CREATE INDEX CONCURRENTLY IF NOT EXISTS "ContactChannel_tenancyId_sequenceId_idx" ON /* SCHEMA_NAME_SENTINEL */."ContactChannel"("tenancyId", "sequenceId"); + +-- SPLIT_STATEMENT_SENTINEL +-- SINGLE_STATEMENT_SENTINEL +-- RUN_OUTSIDE_TRANSACTION_SENTINEL +CREATE INDEX CONCURRENTLY IF NOT EXISTS "OutgoingRequest_startedFulfillingAt_deduplicationKey_idx" ON /* SCHEMA_NAME_SENTINEL */."OutgoingRequest"("startedFulfillingAt", "deduplicationKey"); -- SPLIT_STATEMENT_SENTINEL -ALTER TABLE "DeletedRow" ADD COLUMN "shouldUpdateSequenceId" BOOLEAN NOT NULL DEFAULT TRUE; +-- SINGLE_STATEMENT_SENTINEL +-- RUN_OUTSIDE_TRANSACTION_SENTINEL +CREATE INDEX CONCURRENTLY IF NOT EXISTS "OutgoingRequest_startedFulfillingAt_createdAt_idx" ON /* SCHEMA_NAME_SENTINEL */."OutgoingRequest"("startedFulfillingAt", "createdAt"); + +-- SPLIT_STATEMENT_SENTINEL +-- SINGLE_STATEMENT_SENTINEL +-- RUN_OUTSIDE_TRANSACTION_SENTINEL +CREATE INDEX CONCURRENTLY IF NOT EXISTS "DeletedRow_tableName_idx" ON /* SCHEMA_NAME_SENTINEL */."DeletedRow"("tableName"); + +-- SPLIT_STATEMENT_SENTINEL +-- SINGLE_STATEMENT_SENTINEL +-- RUN_OUTSIDE_TRANSACTION_SENTINEL +CREATE INDEX CONCURRENTLY IF NOT EXISTS "DeletedRow_tenancyId_idx" ON /* SCHEMA_NAME_SENTINEL */."DeletedRow"("tenancyId"); + +-- SPLIT_STATEMENT_SENTINEL +-- SINGLE_STATEMENT_SENTINEL +-- RUN_OUTSIDE_TRANSACTION_SENTINEL +-- Creates composite index for efficient querying of deleted rows by tenant and table, ordered by sequence. +CREATE INDEX CONCURRENTLY IF NOT EXISTS "DeletedRow_tenancyId_tableName_sequenceId_idx" ON /* SCHEMA_NAME_SENTINEL */."DeletedRow"("tenancyId", "tableName", "sequenceId"); -- SPLIT_STATEMENT_SENTINEL +-- SINGLE_STATEMENT_SENTINEL +-- RUN_OUTSIDE_TRANSACTION_SENTINEL -- Creates indexes on (shouldUpdateSequenceId, tenancyId) to quickly find rows that need updates -- and support ORDER BY tenancyId for less fragmented updates. -CREATE INDEX "ProjectUser_shouldUpdateSequenceId_idx" ON "ProjectUser"("shouldUpdateSequenceId", "tenancyId"); +CREATE INDEX CONCURRENTLY IF NOT EXISTS "ProjectUser_shouldUpdateSequenceId_idx" ON /* SCHEMA_NAME_SENTINEL */."ProjectUser"("shouldUpdateSequenceId", "tenancyId"); -- SPLIT_STATEMENT_SENTINEL -CREATE INDEX "ContactChannel_shouldUpdateSequenceId_idx" ON "ContactChannel"("shouldUpdateSequenceId", "tenancyId"); +-- SINGLE_STATEMENT_SENTINEL +-- RUN_OUTSIDE_TRANSACTION_SENTINEL +CREATE INDEX CONCURRENTLY IF NOT EXISTS "ContactChannel_shouldUpdateSequenceId_idx" ON /* SCHEMA_NAME_SENTINEL */."ContactChannel"("shouldUpdateSequenceId", "tenancyId"); -- SPLIT_STATEMENT_SENTINEL -CREATE INDEX "DeletedRow_shouldUpdateSequenceId_idx" ON "DeletedRow"("shouldUpdateSequenceId", "tenancyId"); +-- SINGLE_STATEMENT_SENTINEL +-- RUN_OUTSIDE_TRANSACTION_SENTINEL +CREATE INDEX CONCURRENTLY IF NOT EXISTS "DeletedRow_shouldUpdateSequenceId_idx" ON /* SCHEMA_NAME_SENTINEL */."DeletedRow"("shouldUpdateSequenceId", "tenancyId"); diff --git a/apps/backend/prisma/migrations/20260204014127_external_db_metadata/migration.sql b/apps/backend/prisma/migrations/20260204014127_external_db_metadata/migration.sql deleted file mode 100644 index aa0a767c0b..0000000000 --- a/apps/backend/prisma/migrations/20260204014127_external_db_metadata/migration.sql +++ /dev/null @@ -1,24 +0,0 @@ --- DropIndex -DROP INDEX "ContactChannel_shouldUpdateSequenceId_idx"; - --- DropIndex -DROP INDEX "DeletedRow_shouldUpdateSequenceId_idx"; - --- DropIndex -DROP INDEX "ProjectUser_shouldUpdateSequenceId_idx"; - --- CreateTable -CREATE TABLE "ExternalDbSyncMetadata" ( - "id" TEXT NOT NULL DEFAULT gen_random_uuid(), - "singleton" "BooleanTrue" NOT NULL DEFAULT 'TRUE', - "sequencerEnabled" BOOLEAN NOT NULL DEFAULT true, - "pollerEnabled" BOOLEAN NOT NULL DEFAULT true, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "ExternalDbSyncMetadata_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE UNIQUE INDEX "ExternalDbSyncMetadata_singleton_key" ON "ExternalDbSyncMetadata"("singleton"); - diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index e0cf9744d7..46e39119eb 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -243,7 +243,7 @@ model ProjectUser { @@index([tenancyId, createdAt(sort: Asc)], name: "ProjectUser_createdAt_asc") @@index([tenancyId, createdAt(sort: Desc)], name: "ProjectUser_createdAt_desc") @@index([tenancyId, sequenceId], name: "ProjectUser_tenancyId_sequenceId_idx") - // Partial index for external db sync backfill lives in migration SQL. + @@index([shouldUpdateSequenceId, tenancyId], name: "ProjectUser_shouldUpdateSequenceId_idx") } // This should be renamed to "OAuthAccount" as it is not always bound to a user @@ -309,7 +309,7 @@ model ContactChannel { // only one contact channel per project with the same value and type can be used for auth @@unique([tenancyId, type, value, usedForAuth]) @@index([tenancyId, sequenceId], name: "ContactChannel_tenancyId_sequenceId_idx") - // Partial index for external db sync backfill lives in migration SQL (WHERE shouldUpdateSequenceId = TRUE). + @@index([shouldUpdateSequenceId, tenancyId], name: "ContactChannel_shouldUpdateSequenceId_idx") } model AuthMethod { @@ -1113,5 +1113,5 @@ model DeletedRow { @@index([tenancyId]) // composite index for efficient querying of deleted rows by tenant and table, ordered by sequence @@index([tenancyId, tableName, sequenceId]) - // Partial index for external db sync backfill lives in migration SQL (WHERE shouldUpdateSequenceId = TRUE). + @@index([shouldUpdateSequenceId, tenancyId], name: "DeletedRow_shouldUpdateSequenceId_idx") } From d914d7f3ecb4527afeaa4a0a23cf0d148fcb0b86 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Mon, 9 Feb 2026 11:25:50 -0800 Subject: [PATCH 05/65] Split DB migration into two files --- .../migration.sql | 75 ------------------- .../migration.sql | 74 ++++++++++++++++++ 2 files changed, 74 insertions(+), 75 deletions(-) create mode 100644 apps/backend/prisma/migrations/20251125030552_external_db_sync_indexes/migration.sql diff --git a/apps/backend/prisma/migrations/20251125030551_external_db_sync/migration.sql b/apps/backend/prisma/migrations/20251125030551_external_db_sync/migration.sql index 2c66a9e821..410a625590 100644 --- a/apps/backend/prisma/migrations/20251125030551_external_db_sync/migration.sql +++ b/apps/backend/prisma/migrations/20251125030551_external_db_sync/migration.sql @@ -66,78 +66,3 @@ CREATE TABLE "ExternalDbSyncMetadata" ( CONSTRAINT "ExternalDbSyncMetadata_pkey" PRIMARY KEY ("id") ); - --- SPLIT_STATEMENT_SENTINEL --- SINGLE_STATEMENT_SENTINEL --- RUN_OUTSIDE_TRANSACTION_SENTINEL --- Creates unique indexes on sequenceId columns to ensure no duplicate sequence IDs exist. -CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "ContactChannel_sequenceId_key" ON /* SCHEMA_NAME_SENTINEL */."ContactChannel"("sequenceId"); - --- SPLIT_STATEMENT_SENTINEL --- SINGLE_STATEMENT_SENTINEL --- RUN_OUTSIDE_TRANSACTION_SENTINEL -CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "ProjectUser_sequenceId_key" ON /* SCHEMA_NAME_SENTINEL */."ProjectUser"("sequenceId"); - --- SPLIT_STATEMENT_SENTINEL --- SINGLE_STATEMENT_SENTINEL --- RUN_OUTSIDE_TRANSACTION_SENTINEL -CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "DeletedRow_sequenceId_key" ON /* SCHEMA_NAME_SENTINEL */."DeletedRow"("sequenceId"); - --- SPLIT_STATEMENT_SENTINEL --- SINGLE_STATEMENT_SENTINEL --- RUN_OUTSIDE_TRANSACTION_SENTINEL -CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "ExternalDbSyncMetadata_singleton_key" ON /* SCHEMA_NAME_SENTINEL */."ExternalDbSyncMetadata"("singleton"); - --- SPLIT_STATEMENT_SENTINEL --- SINGLE_STATEMENT_SENTINEL --- RUN_OUTSIDE_TRANSACTION_SENTINEL --- Creates composite indexes on (tenancyId, sequenceId) for efficient sync-engine queries. -CREATE INDEX CONCURRENTLY IF NOT EXISTS "ProjectUser_tenancyId_sequenceId_idx" ON /* SCHEMA_NAME_SENTINEL */."ProjectUser"("tenancyId", "sequenceId"); - --- SPLIT_STATEMENT_SENTINEL --- SINGLE_STATEMENT_SENTINEL --- RUN_OUTSIDE_TRANSACTION_SENTINEL -CREATE INDEX CONCURRENTLY IF NOT EXISTS "ContactChannel_tenancyId_sequenceId_idx" ON /* SCHEMA_NAME_SENTINEL */."ContactChannel"("tenancyId", "sequenceId"); - --- SPLIT_STATEMENT_SENTINEL --- SINGLE_STATEMENT_SENTINEL --- RUN_OUTSIDE_TRANSACTION_SENTINEL -CREATE INDEX CONCURRENTLY IF NOT EXISTS "OutgoingRequest_startedFulfillingAt_deduplicationKey_idx" ON /* SCHEMA_NAME_SENTINEL */."OutgoingRequest"("startedFulfillingAt", "deduplicationKey"); - --- SPLIT_STATEMENT_SENTINEL --- SINGLE_STATEMENT_SENTINEL --- RUN_OUTSIDE_TRANSACTION_SENTINEL -CREATE INDEX CONCURRENTLY IF NOT EXISTS "OutgoingRequest_startedFulfillingAt_createdAt_idx" ON /* SCHEMA_NAME_SENTINEL */."OutgoingRequest"("startedFulfillingAt", "createdAt"); - --- SPLIT_STATEMENT_SENTINEL --- SINGLE_STATEMENT_SENTINEL --- RUN_OUTSIDE_TRANSACTION_SENTINEL -CREATE INDEX CONCURRENTLY IF NOT EXISTS "DeletedRow_tableName_idx" ON /* SCHEMA_NAME_SENTINEL */."DeletedRow"("tableName"); - --- SPLIT_STATEMENT_SENTINEL --- SINGLE_STATEMENT_SENTINEL --- RUN_OUTSIDE_TRANSACTION_SENTINEL -CREATE INDEX CONCURRENTLY IF NOT EXISTS "DeletedRow_tenancyId_idx" ON /* SCHEMA_NAME_SENTINEL */."DeletedRow"("tenancyId"); - --- SPLIT_STATEMENT_SENTINEL --- SINGLE_STATEMENT_SENTINEL --- RUN_OUTSIDE_TRANSACTION_SENTINEL --- Creates composite index for efficient querying of deleted rows by tenant and table, ordered by sequence. -CREATE INDEX CONCURRENTLY IF NOT EXISTS "DeletedRow_tenancyId_tableName_sequenceId_idx" ON /* SCHEMA_NAME_SENTINEL */."DeletedRow"("tenancyId", "tableName", "sequenceId"); - --- SPLIT_STATEMENT_SENTINEL --- SINGLE_STATEMENT_SENTINEL --- RUN_OUTSIDE_TRANSACTION_SENTINEL --- Creates indexes on (shouldUpdateSequenceId, tenancyId) to quickly find rows that need updates --- and support ORDER BY tenancyId for less fragmented updates. -CREATE INDEX CONCURRENTLY IF NOT EXISTS "ProjectUser_shouldUpdateSequenceId_idx" ON /* SCHEMA_NAME_SENTINEL */."ProjectUser"("shouldUpdateSequenceId", "tenancyId"); - --- SPLIT_STATEMENT_SENTINEL --- SINGLE_STATEMENT_SENTINEL --- RUN_OUTSIDE_TRANSACTION_SENTINEL -CREATE INDEX CONCURRENTLY IF NOT EXISTS "ContactChannel_shouldUpdateSequenceId_idx" ON /* SCHEMA_NAME_SENTINEL */."ContactChannel"("shouldUpdateSequenceId", "tenancyId"); - --- SPLIT_STATEMENT_SENTINEL --- SINGLE_STATEMENT_SENTINEL --- RUN_OUTSIDE_TRANSACTION_SENTINEL -CREATE INDEX CONCURRENTLY IF NOT EXISTS "DeletedRow_shouldUpdateSequenceId_idx" ON /* SCHEMA_NAME_SENTINEL */."DeletedRow"("shouldUpdateSequenceId", "tenancyId"); diff --git a/apps/backend/prisma/migrations/20251125030552_external_db_sync_indexes/migration.sql b/apps/backend/prisma/migrations/20251125030552_external_db_sync_indexes/migration.sql new file mode 100644 index 0000000000..9a4e25a712 --- /dev/null +++ b/apps/backend/prisma/migrations/20251125030552_external_db_sync_indexes/migration.sql @@ -0,0 +1,74 @@ +-- SPLIT_STATEMENT_SENTINEL +-- SINGLE_STATEMENT_SENTINEL +-- RUN_OUTSIDE_TRANSACTION_SENTINEL +-- Creates unique indexes on sequenceId columns to ensure no duplicate sequence IDs exist. +CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "ContactChannel_sequenceId_key" ON /* SCHEMA_NAME_SENTINEL */."ContactChannel"("sequenceId"); + +-- SPLIT_STATEMENT_SENTINEL +-- SINGLE_STATEMENT_SENTINEL +-- RUN_OUTSIDE_TRANSACTION_SENTINEL +CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "ProjectUser_sequenceId_key" ON /* SCHEMA_NAME_SENTINEL */."ProjectUser"("sequenceId"); + +-- SPLIT_STATEMENT_SENTINEL +-- SINGLE_STATEMENT_SENTINEL +-- RUN_OUTSIDE_TRANSACTION_SENTINEL +CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "DeletedRow_sequenceId_key" ON /* SCHEMA_NAME_SENTINEL */."DeletedRow"("sequenceId"); + +-- SPLIT_STATEMENT_SENTINEL +-- SINGLE_STATEMENT_SENTINEL +-- RUN_OUTSIDE_TRANSACTION_SENTINEL +CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "ExternalDbSyncMetadata_singleton_key" ON /* SCHEMA_NAME_SENTINEL */."ExternalDbSyncMetadata"("singleton"); + +-- SPLIT_STATEMENT_SENTINEL +-- SINGLE_STATEMENT_SENTINEL +-- RUN_OUTSIDE_TRANSACTION_SENTINEL +-- Creates composite indexes on (tenancyId, sequenceId) for efficient sync-engine queries. +CREATE INDEX CONCURRENTLY IF NOT EXISTS "ProjectUser_tenancyId_sequenceId_idx" ON /* SCHEMA_NAME_SENTINEL */."ProjectUser"("tenancyId", "sequenceId"); + +-- SPLIT_STATEMENT_SENTINEL +-- SINGLE_STATEMENT_SENTINEL +-- RUN_OUTSIDE_TRANSACTION_SENTINEL +CREATE INDEX CONCURRENTLY IF NOT EXISTS "ContactChannel_tenancyId_sequenceId_idx" ON /* SCHEMA_NAME_SENTINEL */."ContactChannel"("tenancyId", "sequenceId"); + +-- SPLIT_STATEMENT_SENTINEL +-- SINGLE_STATEMENT_SENTINEL +-- RUN_OUTSIDE_TRANSACTION_SENTINEL +CREATE INDEX CONCURRENTLY IF NOT EXISTS "OutgoingRequest_startedFulfillingAt_deduplicationKey_idx" ON /* SCHEMA_NAME_SENTINEL */."OutgoingRequest"("startedFulfillingAt", "deduplicationKey"); + +-- SPLIT_STATEMENT_SENTINEL +-- SINGLE_STATEMENT_SENTINEL +-- RUN_OUTSIDE_TRANSACTION_SENTINEL +CREATE INDEX CONCURRENTLY IF NOT EXISTS "OutgoingRequest_startedFulfillingAt_createdAt_idx" ON /* SCHEMA_NAME_SENTINEL */."OutgoingRequest"("startedFulfillingAt", "createdAt"); + +-- SPLIT_STATEMENT_SENTINEL +-- SINGLE_STATEMENT_SENTINEL +-- RUN_OUTSIDE_TRANSACTION_SENTINEL +CREATE INDEX CONCURRENTLY IF NOT EXISTS "DeletedRow_tableName_idx" ON /* SCHEMA_NAME_SENTINEL */."DeletedRow"("tableName"); + +-- SPLIT_STATEMENT_SENTINEL +-- SINGLE_STATEMENT_SENTINEL +-- RUN_OUTSIDE_TRANSACTION_SENTINEL +CREATE INDEX CONCURRENTLY IF NOT EXISTS "DeletedRow_tenancyId_idx" ON /* SCHEMA_NAME_SENTINEL */."DeletedRow"("tenancyId"); + +-- SPLIT_STATEMENT_SENTINEL +-- SINGLE_STATEMENT_SENTINEL +-- RUN_OUTSIDE_TRANSACTION_SENTINEL +-- Creates composite index for efficient querying of deleted rows by tenant and table, ordered by sequence. +CREATE INDEX CONCURRENTLY IF NOT EXISTS "DeletedRow_tenancyId_tableName_sequenceId_idx" ON /* SCHEMA_NAME_SENTINEL */."DeletedRow"("tenancyId", "tableName", "sequenceId"); + +-- SPLIT_STATEMENT_SENTINEL +-- SINGLE_STATEMENT_SENTINEL +-- RUN_OUTSIDE_TRANSACTION_SENTINEL +-- Creates indexes on (shouldUpdateSequenceId, tenancyId) to quickly find rows that need updates +-- and support ORDER BY tenancyId for less fragmented updates. +CREATE INDEX CONCURRENTLY IF NOT EXISTS "ProjectUser_shouldUpdateSequenceId_idx" ON /* SCHEMA_NAME_SENTINEL */."ProjectUser"("shouldUpdateSequenceId", "tenancyId"); + +-- SPLIT_STATEMENT_SENTINEL +-- SINGLE_STATEMENT_SENTINEL +-- RUN_OUTSIDE_TRANSACTION_SENTINEL +CREATE INDEX CONCURRENTLY IF NOT EXISTS "ContactChannel_shouldUpdateSequenceId_idx" ON /* SCHEMA_NAME_SENTINEL */."ContactChannel"("shouldUpdateSequenceId", "tenancyId"); + +-- SPLIT_STATEMENT_SENTINEL +-- SINGLE_STATEMENT_SENTINEL +-- RUN_OUTSIDE_TRANSACTION_SENTINEL +CREATE INDEX CONCURRENTLY IF NOT EXISTS "DeletedRow_shouldUpdateSequenceId_idx" ON /* SCHEMA_NAME_SENTINEL */."DeletedRow"("shouldUpdateSequenceId", "tenancyId"); From 419a9c43671ce034e4ae1b978760e39e25bd9a3d Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Tue, 10 Feb 2026 18:16:30 -0800 Subject: [PATCH 06/65] Better logging for unknown email errors --- apps/backend/src/lib/emails-low-level.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/backend/src/lib/emails-low-level.tsx b/apps/backend/src/lib/emails-low-level.tsx index c203e44836..94a28d9eb1 100644 --- a/apps/backend/src/lib/emails-low-level.tsx +++ b/apps/backend/src/lib/emails-low-level.tsx @@ -184,6 +184,7 @@ async function _lowLevelSendEmailWithoutRetries(options: LowLevelSendEmailOption } // ============ unknown error ============ + captureError("unknown-email-send-error", new StackAssertionError("Unknown error while sending email. We should add a better error description for the user.", { cause: error })); return Result.error({ rawError: error, errorType: 'UNKNOWN', From 7bda141ca5265963a4b120bed8d748442b2dbc21 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Tue, 10 Feb 2026 18:31:56 -0800 Subject: [PATCH 07/65] Show request duration warning after 240s for long requests --- .../src/route-handlers/smart-route-handler.tsx | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/apps/backend/src/route-handlers/smart-route-handler.tsx b/apps/backend/src/route-handlers/smart-route-handler.tsx index 7de3a36b16..19bcb370ab 100644 --- a/apps/backend/src/route-handlers/smart-route-handler.tsx +++ b/apps/backend/src/route-handlers/smart-route-handler.tsx @@ -114,15 +114,13 @@ export function handleApiRequest(handler: (req: NextRequest, options: any, reque "/api/latest/internal/external-db-sync/sequencer", "/api/latest/internal/external-db-sync/sync-engine", ]; - if (!allowedLongRequestPaths.includes(req.nextUrl.pathname)) { - const warnAfterSeconds = 12; - runAsynchronously(async () => { - await wait(warnAfterSeconds * 1000); - if (!hasRequestFinished) { - captureError("request-timeout-watcher", new Error(`Request with ID ${requestId} to ${req.method} ${req.nextUrl.pathname} has been running for ${warnAfterSeconds} seconds. Try to keep requests short. The request may be cancelled by the serverless provider if it takes too long.`)); - } - }); - } + const warnAfterSeconds = allowedLongRequestPaths.includes(req.nextUrl.pathname) ? 180 : 12; + runAsynchronously(async () => { + await wait(warnAfterSeconds * 1000); + if (!hasRequestFinished) { + captureError("request-timeout-watcher", new Error(`Request with ID ${requestId} to ${req.method} ${req.nextUrl.pathname} has been running for ${warnAfterSeconds} seconds. Try to keep requests short. The request may be cancelled by the serverless provider if it takes too long.`)); + } + }); if (!disableExtendedLogging) console.log(`[API REQ] [${requestId}] ${req.method} ${censoredUrl}`); const timeStart = performance.now(); From adb14f1635f83b4effd13c41144b6c0c3fdc750f Mon Sep 17 00:00:00 2001 From: Konsti Wohlwend Date: Tue, 10 Feb 2026 19:43:10 -0800 Subject: [PATCH 08/65] [Refactor] Improve CI Run Times by Reducing Test Flakiness and Speeding up Test Suite (#1166) --- .github/workflows/e2e-api-tests.yaml | 3 +- .../e2e-custom-base-port-api-tests.yaml | 1 - .../e2e-source-of-truth-api-tests.yaml | 1 - ...rt-dev-and-test-with-custom-base-port.yaml | 1 - .github/workflows/restart-dev-and-test.yaml | 1 - .../setup-tests-with-custom-base-port.yaml | 1 - .github/workflows/setup-tests.yaml | 1 - .../internal/external-db-sync/poller/route.ts | 16 +- .../external-db-sync/sequencer/route.ts | 15 +- .../src/lib/external-db-sync-metadata.ts | 13 +- apps/e2e/tests/backend/backend-helpers.ts | 53 +++++- .../endpoints/api/v1/emails/email-helpers.ts | 19 +- .../api/v1/emails/email-queue.test.ts | 166 +++++------------ .../api/v1/emails/outbox-api.test.ts | 176 ++++++++---------- .../api/v1/external-db-sync-advanced.test.ts | 29 +-- .../api/v1/external-db-sync-basics.test.ts | 4 +- .../v1/external-db-sync-high-volume.test.ts | 2 +- .../api/v1/external-db-sync-race.test.ts | 70 ++++--- .../api/v1/external-db-sync-utils.ts | 59 +----- .../backend/endpoints/api/v1/users.test.ts | 2 +- 20 files changed, 261 insertions(+), 372 deletions(-) diff --git a/.github/workflows/e2e-api-tests.yaml b/.github/workflows/e2e-api-tests.yaml index 6aa1e55e2d..3d33c11066 100644 --- a/.github/workflows/e2e-api-tests.yaml +++ b/.github/workflows/e2e-api-tests.yaml @@ -19,7 +19,6 @@ jobs: NODE_ENV: test STACK_ENABLE_HARDCODED_PASSKEY_CHALLENGE_FOR_TESTING: yes STACK_DATABASE_CONNECTION_STRING: "postgres://postgres:PASSWORD-PLACEHOLDER--uqfEC1hmmv@localhost:8128/stackframe" - STACK_FORCE_EXTERNAL_DB_SYNC: "true" STACK_EXTERNAL_DB_SYNC_MAX_DURATION_MS: "20000" STACK_EXTERNAL_DB_SYNC_DIRECT: "false" @@ -160,7 +159,7 @@ jobs: run: sleep 10 - name: Run tests - run: pnpm test run ${{ matrix.freestyle-mode == 'prod' && '--min-workers=1 --max-workers=1' || '' }} + run: pnpm test run ${{ matrix.freestyle-mode == 'prod' && '--min-workers=1 --max-workers=1' || '' }} ${{ matrix.freestyle-mode == 'prod' && github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' && 'mail' || '' }} - name: Run tests again (attempt 1) if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' diff --git a/.github/workflows/e2e-custom-base-port-api-tests.yaml b/.github/workflows/e2e-custom-base-port-api-tests.yaml index 1a1cf52efe..d458b0653d 100644 --- a/.github/workflows/e2e-custom-base-port-api-tests.yaml +++ b/.github/workflows/e2e-custom-base-port-api-tests.yaml @@ -19,7 +19,6 @@ jobs: STACK_ENABLE_HARDCODED_PASSKEY_CHALLENGE_FOR_TESTING: yes STACK_DATABASE_CONNECTION_STRING: "postgres://postgres:PASSWORD-PLACEHOLDER--uqfEC1hmmv@localhost:6728/stackframe" NEXT_PUBLIC_STACK_PORT_PREFIX: "67" - STACK_FORCE_EXTERNAL_DB_SYNC: "true" STACK_EXTERNAL_DB_SYNC_MAX_DURATION_MS: "20000" STACK_EXTERNAL_DB_SYNC_DIRECT: "false" diff --git a/.github/workflows/e2e-source-of-truth-api-tests.yaml b/.github/workflows/e2e-source-of-truth-api-tests.yaml index 99b66e51b6..46f5cebfc5 100644 --- a/.github/workflows/e2e-source-of-truth-api-tests.yaml +++ b/.github/workflows/e2e-source-of-truth-api-tests.yaml @@ -21,7 +21,6 @@ jobs: STACK_OVERRIDE_SOURCE_OF_TRUTH: '{"type": "postgres", "connectionString": "postgres://postgres:PASSWORD-PLACEHOLDER--uqfEC1hmmv@localhost:8128/source-of-truth-db?schema=sot-schema"}' STACK_TEST_SOURCE_OF_TRUTH: true STACK_DATABASE_CONNECTION_STRING: "postgres://postgres:PASSWORD-PLACEHOLDER--uqfEC1hmmv@localhost:8128/stackframe" - STACK_FORCE_EXTERNAL_DB_SYNC: "true" STACK_EXTERNAL_DB_SYNC_MAX_DURATION_MS: "20000" STACK_EXTERNAL_DB_SYNC_DIRECT: "false" diff --git a/.github/workflows/restart-dev-and-test-with-custom-base-port.yaml b/.github/workflows/restart-dev-and-test-with-custom-base-port.yaml index 65179046fb..75ccb222e3 100644 --- a/.github/workflows/restart-dev-and-test-with-custom-base-port.yaml +++ b/.github/workflows/restart-dev-and-test-with-custom-base-port.yaml @@ -19,7 +19,6 @@ jobs: runs-on: ubicloud-standard-16 env: NEXT_PUBLIC_STACK_PORT_PREFIX: "69" - STACK_FORCE_EXTERNAL_DB_SYNC: "true" STACK_EXTERNAL_DB_SYNC_MAX_DURATION_MS: "20000" STACK_EXTERNAL_DB_SYNC_DIRECT: "false" diff --git a/.github/workflows/restart-dev-and-test.yaml b/.github/workflows/restart-dev-and-test.yaml index 6d091edb3c..e9e133aa35 100644 --- a/.github/workflows/restart-dev-and-test.yaml +++ b/.github/workflows/restart-dev-and-test.yaml @@ -18,7 +18,6 @@ jobs: restart-dev-and-test: runs-on: ubicloud-standard-16 env: - STACK_FORCE_EXTERNAL_DB_SYNC: "true" STACK_EXTERNAL_DB_SYNC_MAX_DURATION_MS: "20000" STACK_EXTERNAL_DB_SYNC_DIRECT: "false" diff --git a/.github/workflows/setup-tests-with-custom-base-port.yaml b/.github/workflows/setup-tests-with-custom-base-port.yaml index 0c9ac4a6ca..95a04f2896 100644 --- a/.github/workflows/setup-tests-with-custom-base-port.yaml +++ b/.github/workflows/setup-tests-with-custom-base-port.yaml @@ -19,7 +19,6 @@ jobs: runs-on: ubicloud-standard-16 env: NEXT_PUBLIC_STACK_PORT_PREFIX: "69" - STACK_FORCE_EXTERNAL_DB_SYNC: "true" STACK_EXTERNAL_DB_SYNC_MAX_DURATION_MS: "20000" STACK_EXTERNAL_DB_SYNC_DIRECT: "false" diff --git a/.github/workflows/setup-tests.yaml b/.github/workflows/setup-tests.yaml index 66374120c5..fe81c28ecc 100644 --- a/.github/workflows/setup-tests.yaml +++ b/.github/workflows/setup-tests.yaml @@ -18,7 +18,6 @@ jobs: setup-tests: runs-on: ubicloud-standard-16 env: - STACK_FORCE_EXTERNAL_DB_SYNC: "true" STACK_EXTERNAL_DB_SYNC_MAX_DURATION_MS: "20000" STACK_EXTERNAL_DB_SYNC_DIRECT: "false" steps: diff --git a/apps/backend/src/app/api/latest/internal/external-db-sync/poller/route.ts b/apps/backend/src/app/api/latest/internal/external-db-sync/poller/route.ts index a12edab49f..995f8fbdaf 100644 --- a/apps/backend/src/app/api/latest/internal/external-db-sync/poller/route.ts +++ b/apps/backend/src/app/api/latest/internal/external-db-sync/poller/route.ts @@ -30,13 +30,6 @@ function parseMaxDurationMs(value: string | undefined): number { return parsed; } -function parseStopWhenIdle(value: string | undefined): boolean { - if (!value) return false; - if (value === "true") return true; - if (value === "false") return false; - throw new StatusError(400, "stopWhenIdle must be 'true' or 'false'"); -} - function directSyncEnabled(): boolean { return getEnvVariable(DIRECT_SYNC_ENV, "") === "true"; } @@ -69,7 +62,6 @@ export const GET = createSmartRouteHandler({ }).defined(), query: yupObject({ maxDurationMs: yupString().optional(), - stopWhenIdle: yupString().optional(), }).defined(), }), response: yupObject({ @@ -91,13 +83,11 @@ export const GET = createSmartRouteHandler({ return await traceSpan("external-db-sync.poller", async (span) => { const startTime = performance.now(); const maxDurationMs = parseMaxDurationMs(query.maxDurationMs); - const stopWhenIdle = parseStopWhenIdle(query.stopWhenIdle); const pollIntervalMs = 50; const staleClaimIntervalMinutes = 5; const pollerClaimLimit = getPollerClaimLimit(); span.setAttribute("stack.external-db-sync.max-duration-ms", maxDurationMs); - span.setAttribute("stack.external-db-sync.stop-when-idle", stopWhenIdle); span.setAttribute("stack.external-db-sync.poll-interval-ms", pollIntervalMs); span.setAttribute("stack.external-db-sync.poller-claim-limit", pollerClaimLimit); span.setAttribute("stack.external-db-sync.direct-sync", directSyncEnabled()); @@ -235,7 +225,7 @@ export const GET = createSmartRouteHandler({ } type PollerIterationResult = { - stopReason: "disabled" | "idle" | null, + stopReason: "disabled" | null, processed: number, }; @@ -255,10 +245,6 @@ export const GET = createSmartRouteHandler({ const pendingRequests = await claimPendingRequests(); iterationSpan.setAttribute("stack.external-db-sync.pending-count", pendingRequests.length); - if (stopWhenIdle && pendingRequests.length === 0) { - return { stopReason: "idle", processed: 0 }; - } - const processed = await processRequests(pendingRequests); iterationSpan.setAttribute("stack.external-db-sync.processed-count", processed); return { stopReason: null, processed }; diff --git a/apps/backend/src/app/api/latest/internal/external-db-sync/sequencer/route.ts b/apps/backend/src/app/api/latest/internal/external-db-sync/sequencer/route.ts index 7a51da1f27..e12ff716ce 100644 --- a/apps/backend/src/app/api/latest/internal/external-db-sync/sequencer/route.ts +++ b/apps/backend/src/app/api/latest/internal/external-db-sync/sequencer/route.ts @@ -27,13 +27,6 @@ function parseMaxDurationMs(value: string | undefined): number { return parsed; } -function parseStopWhenIdle(value: string | undefined): boolean { - if (!value) return false; - if (value === "true") return true; - if (value === "false") return false; - throw new StatusError(400, "stopWhenIdle must be 'true' or 'false'"); -} - function getSequencerBatchSize(): number { const rawValue = getEnvVariable(SEQUENCER_BATCH_SIZE_ENV, ""); if (!rawValue) return DEFAULT_BATCH_SIZE; @@ -169,7 +162,6 @@ export const GET = createSmartRouteHandler({ }).defined(), query: yupObject({ maxDurationMs: yupString().optional(), - stopWhenIdle: yupString().optional(), }).defined(), }), response: yupObject({ @@ -190,19 +182,17 @@ export const GET = createSmartRouteHandler({ return await traceSpan("external-db-sync.sequencer", async (span) => { const startTime = performance.now(); const maxDurationMs = parseMaxDurationMs(query.maxDurationMs); - const stopWhenIdle = parseStopWhenIdle(query.stopWhenIdle); const pollIntervalMs = 50; const batchSize = getSequencerBatchSize(); span.setAttribute("stack.external-db-sync.max-duration-ms", maxDurationMs); - span.setAttribute("stack.external-db-sync.stop-when-idle", stopWhenIdle); span.setAttribute("stack.external-db-sync.poll-interval-ms", pollIntervalMs); span.setAttribute("stack.external-db-sync.batch-size", batchSize); let iterations = 0; type SequencerIterationResult = { - stopReason: "disabled" | "idle" | null, + stopReason: "disabled" | null, }; while (performance.now() - startTime < maxDurationMs) { @@ -221,9 +211,6 @@ export const GET = createSmartRouteHandler({ try { const didUpdate = await backfillSequenceIds(batchSize); iterationSpan.setAttribute("stack.external-db-sync.did-update", didUpdate); - if (stopWhenIdle && !didUpdate) { - return { stopReason: "idle" }; - } } catch (error) { iterationSpan.setAttribute("stack.external-db-sync.iteration-error", true); captureError( diff --git a/apps/backend/src/lib/external-db-sync-metadata.ts b/apps/backend/src/lib/external-db-sync-metadata.ts index dd64cb039e..b037e74635 100644 --- a/apps/backend/src/lib/external-db-sync-metadata.ts +++ b/apps/backend/src/lib/external-db-sync-metadata.ts @@ -11,18 +11,25 @@ const fuseboxSelect = { pollerEnabled: true, }; +// Default values match the Prisma schema defaults +const defaultFusebox: ExternalDbSyncFusebox = { + sequencerEnabled: true, + pollerEnabled: true, +}; + export async function getExternalDbSyncFusebox(): Promise { - return await globalPrismaClient.externalDbSyncMetadata.upsert({ + const result = await globalPrismaClient.externalDbSyncMetadata.findFirst({ where: { singleton: BooleanTrue.TRUE }, - create: { singleton: BooleanTrue.TRUE }, - update: {}, select: fuseboxSelect, }); + // Return defaults if row doesn't exist yet (row is created on first update) + return result ?? defaultFusebox; } export async function updateExternalDbSyncFusebox( updates: ExternalDbSyncFusebox, ): Promise { + // Upsert is fine here - updates are infrequent and typically manual/admin actions return await globalPrismaClient.externalDbSyncMetadata.upsert({ where: { singleton: BooleanTrue.TRUE }, create: { singleton: BooleanTrue.TRUE, ...updates }, diff --git a/apps/e2e/tests/backend/backend-helpers.ts b/apps/e2e/tests/backend/backend-helpers.ts index b6e21122ce..7d1399043c 100644 --- a/apps/e2e/tests/backend/backend-helpers.ts +++ b/apps/e2e/tests/backend/backend-helpers.ts @@ -186,6 +186,53 @@ export async function bumpEmailAddress(options: { unindexed?: boolean } = {}) { return mailbox; } +// Type for outbox email items (simplified - full type is EmailOutboxCrud["Server"]["Read"]) +type OutboxEmail = { + id: string, + subject?: string, + status: string, + simple_status: string, + to?: { + type: string, + user_id?: string, + [key: string]: unknown, + }, + [key: string]: unknown, +}; + +// Helper to get emails from the outbox, filtered by subject if provided +export async function getOutboxEmails(options?: { subject?: string }): Promise { + const listResponse = await niceBackendFetch("/api/v1/emails/outbox", { + method: "GET", + accessType: "server", + }); + const items = listResponse.body.items as OutboxEmail[]; + if (options?.subject) { + return items.filter((e) => e.subject === options.subject); + } + return items; +} + +// Helper to poll the outbox until the most recent email with the expected subject has the expected status. +// Note: emails are returned ordered by createdAt desc (newest first), so we check emails[0] specifically +// to ensure we're waiting for the MOST RECENT email, not an older one with the same subject. +export async function waitForOutboxEmailWithStatus(subject: string, status: string): Promise { + const maxRetries = 24; + let emails: OutboxEmail[] = []; + for (let i = 0; i < maxRetries; i++) { + emails = await getOutboxEmails({ subject }); + // Check the most recent email (first in the list due to createdAt desc ordering) + if (emails.length > 0 && emails[0].status === status) { + return emails; + } + await wait(500); + } + throw new StackAssertionError( + `Timeout waiting for outbox email with subject "${subject}" and status "${status}"`, + { foundEmails: emails } + ); +} + export namespace Auth { export async function fastSignUp(body: any = {}) { const { userId } = await User.create(body); @@ -405,7 +452,11 @@ export namespace Auth { } await wait(100 + i * 20); if (i >= 30) { - throw new StackAssertionError(`Sign-in code message not found after ${i} attempts`, { response, messages: messages.map(m => ({ ...m, body: m.body && omit(m.body, ["html"]) })) }); + throw new StackAssertionError(`Sign-in code message not found after ${i} attempts`, { + response, + messages: messages.map(m => ({ ...m, body: m.body && omit(m.body, ["html"]) })), + outboxEmails: await getOutboxEmails(), + }); } } return { diff --git a/apps/e2e/tests/backend/endpoints/api/v1/emails/email-helpers.ts b/apps/e2e/tests/backend/endpoints/api/v1/emails/email-helpers.ts index 9cbba4b924..4fd8b724d8 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/emails/email-helpers.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/emails/email-helpers.ts @@ -1,18 +1 @@ -import { niceBackendFetch } from "../../../../backend-helpers"; - -/** - * Helper to get emails from the outbox, filtered by subject if provided. - * Shared across email test files to avoid duplication. - */ -export async function getOutboxEmails(options?: { subject?: string }) { - const listResponse = await niceBackendFetch("/api/v1/emails/outbox", { - method: "GET", - accessType: "server", - }); - if (options?.subject) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return listResponse.body.items.filter((e: any) => e.subject === options.subject); - } - return listResponse.body.items; -} - +export { getOutboxEmails, waitForOutboxEmailWithStatus } from "../../../../backend-helpers"; diff --git a/apps/e2e/tests/backend/endpoints/api/v1/emails/email-queue.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/emails/email-queue.test.ts index e343f06296..2bb050fd4f 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/emails/email-queue.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/emails/email-queue.test.ts @@ -1,49 +1,10 @@ -import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; import { wait } from "@stackframe/stack-shared/dist/utils/promises"; import { deindent, nicify } from "@stackframe/stack-shared/dist/utils/strings"; import beautify from "js-beautify"; import { describe } from "vitest"; import { it, logIfTestFails } from "../../../../../helpers"; import { withPortPrefix } from "../../../../../helpers/ports"; -import { Auth, Project, User, backendContext, bumpEmailAddress, niceBackendFetch } from "../../../../backend-helpers"; - -type OutboxEmail = { - id: string, - subject?: string, - status: string, - skipped_reason?: string, - is_transactional?: boolean, - tsx_source?: string, -}; - -// Helper to get emails from the outbox, filtered by subject if provided -async function getOutboxEmails(options?: { subject?: string }): Promise { - const listResponse = await niceBackendFetch("/api/v1/emails/outbox", { - method: "GET", - accessType: "server", - }); - if (options?.subject) { - return listResponse.body.items.filter((e: any) => e.subject === options.subject); - } - return listResponse.body.items; -} - -// Helper to poll the outbox until an email with the expected subject and status appears -async function waitForOutboxEmailWithStatus(subject: string, status: string): Promise { - const maxRetries = 20; - let emails: OutboxEmail[] = []; - for (let i = 0; i < maxRetries; i++) { - emails = await getOutboxEmails({ subject }); - if (emails.length > 0 && emails[0].status === status) { - return emails; - } - await wait(500); - } - throw new StackAssertionError( - `Timeout waiting for outbox email with subject "${subject}" and status "${status}"`, - { foundEmails: emails } - ); -} +import { Auth, Project, User, backendContext, bumpEmailAddress, getOutboxEmails, niceBackendFetch, waitForOutboxEmailWithStatus } from "../../../../backend-helpers"; const testEmailConfig = { type: "standard", @@ -136,19 +97,15 @@ describe("email queue edge cases", () => { }); expect(deleteResponse.status).toBe(200); - // Wait for email processing - await wait(10_000); + // Poll until outbox shows SKIPPED with USER_ACCOUNT_DELETED + const outboxEmails = await waitForOutboxEmailWithStatus("Slow Render Test Email", "skipped"); + expect(outboxEmails.length).toBe(1); + expect(outboxEmails[0].skipped_reason).toBe("USER_ACCOUNT_DELETED"); // Verify no email was received (user was deleted) const messages = await mailbox.fetchMessages(); const testEmails = messages.filter(m => m.subject === "Slow Render Test Email"); expect(testEmails).toHaveLength(0); - - // Verify outbox shows SKIPPED with USER_ACCOUNT_DELETED - const outboxEmails = await getOutboxEmails({ subject: "Slow Render Test Email" }); - expect(outboxEmails.length).toBe(1); - expect(outboxEmails[0].status).toBe("skipped"); - expect(outboxEmails[0].skipped_reason).toBe("USER_ACCOUNT_DELETED"); }); it("should skip email when user removes primary email after email is queued", async ({ expect }) => { @@ -207,19 +164,15 @@ describe("email queue edge cases", () => { }); expect(deleteChannelResponse.status).toBe(200); - // Wait for email processing to complete (rendering + sending) - await wait(10_000); + // Poll until outbox shows SKIPPED with USER_HAS_NO_PRIMARY_EMAIL + const outboxEmails = await waitForOutboxEmailWithStatus("Slow Render Test Email", "skipped"); + expect(outboxEmails.length).toBe(1); + expect(outboxEmails[0].skipped_reason).toBe("USER_HAS_NO_PRIMARY_EMAIL"); // Verify no email with our subject was received (primary email was removed before sending) const messages = await mailbox.fetchMessages(); const testEmails = messages.filter(m => m.subject === "Slow Render Test Email"); expect(testEmails).toHaveLength(0); - - // Verify outbox shows SKIPPED with USER_HAS_NO_PRIMARY_EMAIL - const outboxEmails = await getOutboxEmails({ subject: "Slow Render Test Email" }); - expect(outboxEmails.length).toBe(1); - expect(outboxEmails[0].status).toBe("skipped"); - expect(outboxEmails[0].skipped_reason).toBe("USER_HAS_NO_PRIMARY_EMAIL"); }); it("should skip email when user unsubscribes after email is queued", async ({ expect }) => { @@ -275,19 +228,15 @@ describe("email queue edge cases", () => { ); expect(unsubscribeResponse.status).toBe(200); - // Wait for email processing - await wait(10_000); + // Poll until outbox shows SKIPPED with USER_UNSUBSCRIBED + const outboxEmails = await waitForOutboxEmailWithStatus("Slow Render Test Email", "skipped"); + expect(outboxEmails.length).toBe(1); + expect(outboxEmails[0].skipped_reason).toBe("USER_UNSUBSCRIBED"); // Verify no email with our subject was received (user unsubscribed) const messages = await backendContext.value.mailbox.fetchMessages(); const testEmails = messages.filter(m => m.subject === "Slow Render Test Email"); expect(testEmails).toHaveLength(0); - - // Verify outbox shows SKIPPED with USER_UNSUBSCRIBED - const outboxEmails = await getOutboxEmails({ subject: "Slow Render Test Email" }); - expect(outboxEmails.length).toBe(1); - expect(outboxEmails[0].status).toBe("skipped"); - expect(outboxEmails[0].skipped_reason).toBe("USER_UNSUBSCRIBED"); }); it("should NOT skip transactional email even when user unsubscribes from marketing", async ({ expect }) => { @@ -1399,10 +1348,7 @@ describe("theme and template deletion after scheduling", () => { // For a proper test, we'd need to pause the email, but the send-email endpoint // doesn't support is_paused directly. - // Wait for email processing - await wait(5_000); - - // Verify the email was sent successfully + // Poll until email is received (waitForMessagesWithSubject already does polling) const messages = await mailbox.waitForMessagesWithSubject("Theme Fallback Test Email"); expect(messages.length).toBeGreaterThanOrEqual(1); @@ -1484,10 +1430,7 @@ describe("theme and template deletion after scheduling", () => { } } - // Wait for email processing - await wait(5_000); - - // Verify the email was sent successfully + // Poll until email is received (waitForMessagesWithSubject already does polling) const messages = await mailbox.waitForMessagesWithSubject("Theme Fallback Test Email"); expect(messages.length).toBeGreaterThanOrEqual(1); @@ -1574,10 +1517,7 @@ describe("theme and template deletion after scheduling", () => { // that the architecture is designed to handle template deletion safely // because the source is copied to the outbox at scheduling time. - // Wait for email processing - await wait(5_000); - - // Verify the email was sent successfully + // Poll until email is received (waitForMessagesWithSubject already does polling) const messages = await mailbox.waitForMessagesWithSubject("Template Deletion Test Email"); expect(messages.length).toBeGreaterThanOrEqual(1); expect(messages[0].body?.html).toContain("Content from template that will be deleted"); @@ -1664,10 +1604,7 @@ describe("theme and template deletion after scheduling", () => { }); expect(sendResponse.status).toBe(200); - // Wait for email processing - await wait(5_000); - - // Verify the email was sent successfully with the custom theme + // Poll until email is received (waitForMessagesWithSubject already does polling) const messages = await mailbox.waitForMessagesWithSubject("Custom Theme Baseline Test Email"); expect(messages.length).toBeGreaterThanOrEqual(1); @@ -1816,7 +1753,7 @@ describe("email outbox pagination", () => { expect(response.status).toBe(400); }); - it("should order emails with finishedSendingAt first (nulls last)", async ({ expect }) => { + it("should order emails by createdAt descending (newest first)", async ({ expect }) => { await Project.createAndSwitch({ display_name: "Test Ordering Project", config: { @@ -1824,16 +1761,9 @@ describe("email outbox pagination", () => { }, }); - // Create a slow-rendering draft (so we have time to pause it) const templateSource = deindent` import { Container } from "@react-email/components"; - import { Subject, NotificationCategory, Props } from "@stackframe/emails"; - - // Artificial delay to make the email slow to render - const startTime = performance.now(); - while (performance.now() - startTime < 200) { - // Busy wait - } + import { Subject, NotificationCategory } from "@stackframe/emails"; export function EmailTemplate({ user, project }) { return ( @@ -1871,8 +1801,8 @@ describe("email outbox pagination", () => { expect(createUserResponse.status).toBe(201); const userId = createUserResponse.body.id; - // Send 2 emails to the user and wait for them to be sent - for (let i = 0; i < 2; i++) { + // Send 3 emails sequentially (need distinct timestamps for ordering test) + for (let i = 0; i < 3; i++) { const sendResponse = await niceBackendFetch("/api/v1/emails/send-email", { method: "POST", accessType: "server", @@ -1884,35 +1814,35 @@ describe("email outbox pagination", () => { expect(sendResponse.status).toBe(200); } - // Wait for email processing - they should be sent - await wait(5_000); + // Poll until all 3 emails appear in outbox (wait up to 12s, matching waitForOutboxEmailWithStatus) + const maxAttempts = 24; + const pollInterval = 500; + let emails: Array<{ subject?: string, created_at_millis: number }> = []; - // Verify at least one email was sent - const listResponse = await niceBackendFetch("/api/v1/emails/outbox", { - method: "GET", - accessType: "server", - }); - expect(listResponse.status).toBe(200); - const emails = listResponse.body.items.filter((e: { subject?: string }) => - e.subject === "Ordering Test Email" - ); + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const listResponse = await niceBackendFetch("/api/v1/emails/outbox", { + method: "GET", + accessType: "server", + }); + expect(listResponse.status).toBe(200); - // Check ordering: finished emails should come before non-finished ones - // Statuses that have finishedSendingAt set (email has completed processing) - const finishedStatuses = new Set(["sent", "opened", "clicked", "marked-as-spam", "server-error", "bounced", "delivery-delayed", "skipped"]); - let foundNonFinished = false; - for (const email of emails) { - const hasFinished = finishedStatuses.has(email.status); - if (!hasFinished) { - foundNonFinished = true; - } else if (foundNonFinished) { - // We found a finished email after a non-finished email - wrong ordering - expect.fail(`Wrong ordering: found '${email.status}' after non-finished emails`); - } + emails = listResponse.body.items.filter((e: { subject?: string }) => + e.subject === "Ordering Test Email" + ); + + if (emails.length >= 3) break; + await wait(pollInterval); } - // We should have at least one finished email - const hasSentEmails = emails.some((e: { status: string }) => finishedStatuses.has(e.status)); - expect(hasSentEmails).toBe(true); - }); + + // Verify we have our emails + expect(emails.length).toBeGreaterThanOrEqual(3); + + // Check ordering: emails should be ordered by createdAt descending (newest first) + for (let i = 0; i < emails.length - 1; i++) { + const current = emails[i]; + const next = emails[i + 1]; + expect(current.created_at_millis).toBeGreaterThanOrEqual(next.created_at_millis); + } + }, 60_000); }); diff --git a/apps/e2e/tests/backend/endpoints/api/v1/emails/outbox-api.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/emails/outbox-api.test.ts index c01855d3a5..ff31fa42db 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/emails/outbox-api.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/emails/outbox-api.test.ts @@ -1,9 +1,10 @@ +import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; import { wait } from "@stackframe/stack-shared/dist/utils/promises"; import { deindent } from "@stackframe/stack-shared/dist/utils/strings"; import { describe } from "vitest"; import { it } from "../../../../../helpers"; import { withPortPrefix } from "../../../../../helpers/ports"; -import { Project, backendContext, bumpEmailAddress, niceBackendFetch } from "../../../../backend-helpers"; +import { Project, backendContext, bumpEmailAddress, getOutboxEmails, niceBackendFetch, waitForOutboxEmailWithStatus } from "../../../../backend-helpers"; const testEmailConfig = { type: "standard", @@ -52,18 +53,6 @@ const slowTemplate = deindent` } `; -// Helper to get emails from the outbox, filtered by subject if provided -async function getOutboxEmails(options?: { subject?: string }) { - const listResponse = await niceBackendFetch("/api/v1/emails/outbox", { - method: "GET", - accessType: "server", - }); - if (options?.subject) { - return listResponse.body.items.filter((e: any) => e.subject === options.subject); - } - return listResponse.body.items; -} - describe("email outbox API", () => { describe("list endpoint", () => { it("should list emails in the outbox", async ({ expect }) => { @@ -250,12 +239,8 @@ describe("email outbox API", () => { }, }); - // Wait for email to be processed - await wait(7_000); - - // Get the email from the list endpoint - const emails = await getOutboxEmails({ subject: "Get Test Email" }); - expect(emails.length).toBeGreaterThanOrEqual(1); + // Wait for email to reach sent status + const emails = await waitForOutboxEmailWithStatus("Get Test Email", "sent"); const emailId = emails[0].id; // Get the email by ID @@ -317,11 +302,8 @@ describe("email outbox API", () => { }, }); - await wait(7_000); - - // Get the email ID from first project - const emails = await getOutboxEmails({ subject: "Cross Project Test Email" }); - expect(emails.length).toBeGreaterThanOrEqual(1); + // Wait for email to reach sent status + const emails = await waitForOutboxEmailWithStatus("Cross Project Test Email", "sent"); const emailId = emails[0].id; // Create second project @@ -374,12 +356,8 @@ describe("email outbox API", () => { }, }); - // Wait for email to be sent - await wait(7_000); - - // Get the email ID - const emails = await getOutboxEmails({ subject: "Not Editable Test" }); - expect(emails.length).toBeGreaterThanOrEqual(1); + // Wait for email to reach sent status + const emails = await waitForOutboxEmailWithStatus("Not Editable Test", "sent"); const emailId = emails[0].id; // Try to edit @@ -423,17 +401,10 @@ describe("email outbox API", () => { }, }); - // Wait for email to be processed and skipped - await wait(7_000); - - // Get the email - const emails = await getOutboxEmails({ subject: "Skipped Test" }); - expect(emails.length).toBeGreaterThanOrEqual(1); + // Wait for email to reach skipped status + const emails = await waitForOutboxEmailWithStatus("Skipped Test", "skipped"); const email = emails[0]; - // Verify it's skipped - expect(email.status).toBe("skipped"); - // Try to edit const editResponse = await niceBackendFetch(`/api/v1/emails/outbox/${email.id}`, { method: "PATCH", @@ -479,10 +450,7 @@ describe("email outbox API", () => { }, }); - await wait(7_000); - - const emails = await getOutboxEmails({ subject: "Status Test Email" }); - expect(emails.length).toBeGreaterThanOrEqual(1); + const emails = await waitForOutboxEmailWithStatus("Status Test Email", "sent"); const email = emails[0]; // Check discriminated union fields @@ -526,10 +494,7 @@ describe("email outbox API", () => { }, }); - await wait(7_000); - - const emails = await getOutboxEmails({ subject: "Skipped Status Test" }); - expect(emails.length).toBeGreaterThanOrEqual(1); + const emails = await waitForOutboxEmailWithStatus("Skipped Status Test", "skipped"); const email = emails[0]; expect(email.status).toBe("skipped"); @@ -574,11 +539,8 @@ describe("email outbox API", () => { }); expect(sendResponse.status).toBe(200); - // Wait for email to be sent - await wait(7_000); - - const emails = await getOutboxEmails({ subject: "Edit TSX Test" }); - expect(emails.length).toBeGreaterThanOrEqual(1); + // Wait for email to reach sent status + const emails = await waitForOutboxEmailWithStatus("Edit TSX Test", "sent"); const emailId = emails[0].id; // For emails that are already SENT, we can't edit them @@ -640,10 +602,8 @@ describe("email outbox API", () => { expect(sendResponse.status).toBe(200); // Poll until we find the email and can pause it (with timeout) - let emailId: string | null = null; - let pauseSucceeded = false; - - for (let i = 0; i < 20; i++) { + let emailId: string; + for (let i = 0;; i++) { const listResponse = await niceBackendFetch("/api/v1/emails/outbox", { method: "GET", accessType: "server", @@ -661,19 +621,61 @@ describe("email outbox API", () => { }, }); - if (pauseResponse.status === 200 && pauseResponse.body.status === "paused") { - pauseSucceeded = true; - break; + expect(pauseResponse).toMatchInlineSnapshot(` + NiceResponse { + "status": 200, + "body": { + "created_at_millis": , + "has_delivered": false, + "has_rendered": false, + "id": "", + "is_paused": true, + "scheduled_at_millis": , + "simple_status": "in-progress", + "skip_deliverability_check": false, + "status": "paused", + "theme_id": null, + "to": { + "type": "user-primary-email", + "user_id": "", + }, + "tsx_source": deindent\` + import { Container } from "@react-email/components"; + import { Subject, NotificationCategory, Props } from "@stackframe/emails"; + + // Artificial delay to make the email slow to render + const startTime = performance.now(); + while (performance.now() - startTime < 500) { + // Busy wait - 500ms delay + } + + export function EmailTemplate({ user, project }) { + return ( + + + +
Slow email content
+
+ ); + } + \`, + "updated_at_millis": , + "variables": {}, + }, + "headers": Headers {
}> - - } /> - {/* Your other routes here */} - - - - - - ); -} -``` - -Do not log or print secret values. - -### 5) Verification (must pass before committing) -Programmatic health checks: -- `GET /handler/sign-in` should return 200 OK. -- `GET /handler/sign-up` should return 200 OK. - -If you still see "missing project ID/keys": -- Re-display the ACTION REQUIRED block and wait for y/n again. -- Only continue after successful restart and 200 responses. - - -### 6) Success Message -After successful setup, show this exact message: - -``` -✅ Stack Auth was successfully installed and you have pasted the keys at the correct place. - -Would you like to: -1. Add authentication UI using Stack Auth modern components? -2. Would you like me to explain what Stack Auth can do in your app? - -Reply with 1 or 2: -``` - -If user replies `1`: Proceed to UI Installation Workflow calling the tool install UI components. -If user replies `2`: Explain to the user what Stack Auth can do for him by reading our documentation using the MCP - diff --git a/docs/package.json b/docs/package.json index 7775872917..3d654eda8f 100644 --- a/docs/package.json +++ b/docs/package.json @@ -18,9 +18,8 @@ "clear-docs": "node scripts/clear-docs.js" }, "dependencies": { - "@ai-sdk/google": "^1.2.21", - "@ai-sdk/openai": "^1.3.22", "@ai-sdk/react": "^1.2.12", + "@openrouter/ai-sdk-provider": "0.7.5", "@modelcontextprotocol/sdk": "^1.17.2", "@phosphor-icons/react": "^2.1.10", "@radix-ui/react-collapsible": "^1.1.11", diff --git a/docs/src/app/api/chat/route.ts b/docs/src/app/api/chat/route.ts index a0f437f719..788bc41950 100644 --- a/docs/src/app/api/chat/route.ts +++ b/docs/src/app/api/chat/route.ts @@ -1,13 +1,13 @@ -import { createGoogleGenerativeAI } from '@ai-sdk/google'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { createOpenRouter } from '@openrouter/ai-sdk-provider'; import { experimental_createMCPClient as createMCPClient, streamText } from 'ai'; // Allow streaming responses up to 30 seconds export const maxDuration = 30; -// Create Google AI instance -const google = createGoogleGenerativeAI({ - apiKey: process.env.GOOGLE_AI_API_KEY, +// Create OpenRouter AI instance +const openrouter = createOpenRouter({ + apiKey: process.env.STACK_OPENROUTER_API_KEY, }); // Helper function to get error message @@ -26,7 +26,7 @@ export async function POST(request: Request) { try { // Use local MCP server in development, production server in production const mcpUrl = process.env.NODE_ENV === 'development' - ? new URL('/api/internal/mcp', 'https://localhost:8104') + ? new URL('/api/internal/mcp', 'http://localhost:8104') : new URL('/api/internal/mcp', 'https://mcp.stack-auth.com'); const stackAuthMcp = await createMCPClient({ @@ -105,12 +105,55 @@ When users need personalized support, have complex issues, or ask for help beyon ## CODE EXAMPLE GUIDELINES: - For API calls, show both the HTTP endpoint AND the SDK method - For example, when explaining "get current user": - * Show the HTTP API endpoint: GET /users/me + * Show the HTTP API endpoint: GET /api/v1/users/me * Show the SDK usage: const user = useUser(); * Include necessary imports and authentication headers - Always show complete, runnable code snippets with proper language tags - Include context like "HTTP API", "SDK (React)", "SDK (Next.js)" etc. +## STACK AUTH HTTP API HEADERS (CRITICAL): +Stack Auth does NOT use standard "Authorization: Bearer" headers. When showing HTTP/REST API examples, ALWAYS use these Stack Auth-specific headers: + +**For client-side requests (browser/mobile):** +\`\`\` +X-Stack-Access-Type: client +X-Stack-Project-Id: +X-Stack-Publishable-Client-Key: +X-Stack-Access-Token: // for authenticated requests +\`\`\` + +**For server-side requests (backend):** +\`\`\` +X-Stack-Access-Type: server +X-Stack-Project-Id: +X-Stack-Secret-Server-Key: +\`\`\` + +**Example HTTP request (client-side, authenticated):** +\`\`\`typescript +const response = await fetch('https://api.stack-auth.com/api/v1/users/me', { + headers: { + 'X-Stack-Access-Type': 'client', + 'X-Stack-Project-Id': 'YOUR_PROJECT_ID', + 'X-Stack-Publishable-Client-Key': 'YOUR_PUBLISHABLE_CLIENT_KEY', + 'X-Stack-Access-Token': 'USER_ACCESS_TOKEN', + }, +}); +\`\`\` + +**Example HTTP request (server-side):** +\`\`\`typescript +const response = await fetch('https://api.stack-auth.com/api/v1/users/USER_ID', { + headers: { + 'X-Stack-Access-Type': 'server', + 'X-Stack-Project-Id': 'YOUR_PROJECT_ID', + 'X-Stack-Secret-Server-Key': 'YOUR_SECRET_SERVER_KEY', + }, +}); +\`\`\` + +NEVER show "Authorization: Bearer" for Stack Auth API calls - this is incorrect and will not work. + ## WHEN UNSURE: - If you're unsure about a Stack Auth feature, say "As an AI, I don't know" or "As an AI, I'm not certain" clearly - Avoid saying things are "not possible" or "impossible", instead say that you don't know @@ -135,14 +178,15 @@ Remember: You're here to help users succeed with Stack Auth. Be helpful but conc try { const result = streamText({ - model: google('gemini-2.5-flash'), + model: openrouter('anthropic/claude-4.5-sonnet'), tools: { ...tools, }, maxSteps: 50, system: systemPrompt, messages, - temperature: 0.3, // Slightly higher for more natural, detailed responses + temperature: 0.3, + maxTokens: 4096, // Ensure we have enough tokens for complete responses }); return result.toDataStreamResponse({ diff --git a/docs/src/components/chat/ai-chat.tsx b/docs/src/components/chat/ai-chat.tsx index 9b158acafc..066be35981 100644 --- a/docs/src/components/chat/ai-chat.tsx +++ b/docs/src/components/chat/ai-chat.tsx @@ -236,6 +236,7 @@ export function AIChatDrawer() { const editableRef = useRef(null); const messagesEndRef = useRef(null); + const messagesContainerRef = useRef(null); const [isHomePage, setIsHomePage] = useState(false); const [isScrolled, setIsScrolled] = useState(false); const [pageLoadTime] = useState(Date.now()); @@ -331,8 +332,8 @@ export function AIChatDrawer() { // Calculate position based on homepage and scroll state - const topPosition = 'top-0'; - const height = isHomePage && isScrolled ? 'h-screen' : 'h-[calc(100vh)]'; + const topPosition = 'top-3'; + const height = isHomePage && isScrolled ? 'h-[calc(100vh-1.5rem)]' : 'h-[calc(100vh-1.5rem)]'; const { messages, @@ -355,8 +356,19 @@ export function AIChatDrawer() { // Auto-scroll to bottom when new messages are added useEffect(() => { - if (messagesEndRef.current) { - messagesEndRef.current.scrollIntoView({ behavior: 'smooth' }); + const container = messagesContainerRef.current; + if (!container) return; + + // Check if user is near the bottom (within 100px) + const isNearBottom = + container.scrollHeight - container.scrollTop - container.clientHeight < 100; + + // Only auto-scroll if user is near the bottom or if this is a new message + if (isNearBottom || messages.length === 0) { + // Use requestAnimationFrame for smoother scrolling during streaming + requestAnimationFrame(() => { + container.scrollTop = container.scrollHeight; + }); } }, [messages]); @@ -374,7 +386,7 @@ export function AIChatDrawer() { response: response, metadata: { sessionId: sessionId, - model: 'gemini-2.0-flash', + model: 'anthropic/claude-4.5-sonnet', temperature: 0, } }; @@ -476,14 +488,14 @@ export function AIChatDrawer() { return (
{/* Header */} -
+
@@ -524,7 +536,7 @@ export function AIChatDrawer() {
{/* Messages */} -
+
{messages.length === 0 ? (
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 64bc666232..da3daf490c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -709,18 +709,15 @@ importers: docs: dependencies: - '@ai-sdk/google': - specifier: ^1.2.21 - version: 1.2.22(zod@3.25.76) - '@ai-sdk/openai': - specifier: ^1.3.22 - version: 1.3.23(zod@3.25.76) '@ai-sdk/react': specifier: ^1.2.12 version: 1.2.12(react@18.3.1)(zod@3.25.76) '@modelcontextprotocol/sdk': specifier: ^1.17.2 version: 1.17.2 + '@openrouter/ai-sdk-provider': + specifier: 0.7.5 + version: 0.7.5(ai@4.3.17(react@18.3.1)(zod@3.25.76))(zod@3.25.76) '@phosphor-icons/react': specifier: ^2.1.10 version: 2.1.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -2222,12 +2219,6 @@ importers: packages: - '@ai-sdk/google@1.2.22': - resolution: {integrity: sha512-Ppxu3DIieF1G9pyQ5O1Z646GYR0gkC57YdBqXJ82qvCdhEhZHu0TWhmnOoeIWe2olSbuDeoOY+MfJrW8dzS3Hw==} - engines: {node: '>=18'} - peerDependencies: - zod: ^3.0.0 - '@ai-sdk/openai@1.3.23': resolution: {integrity: sha512-86U7rFp8yacUAOE/Jz8WbGcwMCqWvjK33wk5DXkfnAOEn3mx2r7tNSJdjukQFZbAK97VMXGPPHxF+aEARDXRXQ==} engines: {node: '>=18'} @@ -4908,6 +4899,13 @@ packages: '@one-ini/wasm@0.1.1': resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==} + '@openrouter/ai-sdk-provider@0.7.5': + resolution: {integrity: sha512-zm8vBhQ+GhxN03Y41xviB0nDa20uN77QnMXsIwDeJPqsul8+KycrYFxY4ulXpumeKxjKyOhfyA7a7CJpcYq2ng==} + engines: {node: '>=18'} + peerDependencies: + ai: ^4.3.17 + zod: ^3.25.34 + '@opentelemetry/api-logs@0.203.0': resolution: {integrity: sha512-9B9RU0H7Ya1Dx/Rkyc4stuBZSGVQF27WigitInx2QQoj6KUpEFYPKoWjdFTunJYxmXmh17HeBvbMa1EhGyPmqQ==} engines: {node: '>=8.0.0'} @@ -16021,12 +16019,6 @@ packages: snapshots: - '@ai-sdk/google@1.2.22(zod@3.25.76)': - dependencies: - '@ai-sdk/provider': 1.1.3 - '@ai-sdk/provider-utils': 2.2.8(zod@3.25.76) - zod: 3.25.76 - '@ai-sdk/openai@1.3.23(zod@3.25.76)': dependencies: '@ai-sdk/provider': 1.1.3 @@ -18976,6 +18968,13 @@ snapshots: '@one-ini/wasm@0.1.1': {} + '@openrouter/ai-sdk-provider@0.7.5(ai@4.3.17(react@18.3.1)(zod@3.25.76))(zod@3.25.76)': + dependencies: + '@ai-sdk/provider': 1.1.3 + '@ai-sdk/provider-utils': 2.2.8(zod@3.25.76) + ai: 4.3.17(react@18.3.1)(zod@3.25.76) + zod: 3.25.76 + '@opentelemetry/api-logs@0.203.0': dependencies: '@opentelemetry/api': 1.9.0 diff --git a/turbo.json b/turbo.json index cf6655b383..8da004f0be 100644 --- a/turbo.json +++ b/turbo.json @@ -12,7 +12,6 @@ "NODE_ENV", "QUETZAL_*", "OTEL_*", - "GOOGLE_AI_API_KEY", "DISCORD_WEBHOOK_URL", "DISCORD_CHANNEL_ID", "DISCORD_BOT_TOKEN", From 02c67ef5322ad8bdbf6b7f2265aab88ff0cf1cf5 Mon Sep 17 00:00:00 2001 From: Madison Date: Wed, 11 Feb 2026 10:36:24 -0600 Subject: [PATCH 10/65] fix docs overview date --- docs/content/docs/(guides)/overview.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/content/docs/(guides)/overview.mdx b/docs/content/docs/(guides)/overview.mdx index 06acbf3b57..f2efe4369c 100644 --- a/docs/content/docs/(guides)/overview.mdx +++ b/docs/content/docs/(guides)/overview.mdx @@ -1,6 +1,6 @@ --- title: Overview -lastModified: "December 9, 2025" +lastModified: "2026-02-11" ---
From 2288c9c65cf801734e42e09f958fec04cbf330d6 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Wed, 11 Feb 2026 10:08:57 -0800 Subject: [PATCH 11/65] Port 2465 should have implicit TLS from byte 1 --- apps/backend/src/lib/emails-low-level.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/backend/src/lib/emails-low-level.tsx b/apps/backend/src/lib/emails-low-level.tsx index 94a28d9eb1..e8d1571368 100644 --- a/apps/backend/src/lib/emails-low-level.tsx +++ b/apps/backend/src/lib/emails-low-level.tsx @@ -14,8 +14,10 @@ import { Resend } from 'resend'; import { getTenancy } from './tenancies'; export function isSecureEmailPort(port: number | string) { + // "secure" in most SMTP clients means implicit TLS from byte 1 (SMTPS) + // STARTTLS ports (25/587/2587) should return false. let parsedPort = parseInt(port.toString()); - return parsedPort === 465; + return parsedPort === 465 || parsedPort === 2465; } export type LowLevelEmailConfig = { From e5a354617c7a8bab2cab8c5dfdd0df801b8ca91a Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Wed, 11 Feb 2026 10:52:55 -0800 Subject: [PATCH 12/65] Fix restricted user schema --- .../preview-affected-users/route.tsx | 9 +++--- .../backend/src/app/api/latest/users/crud.tsx | 3 +- apps/backend/src/lib/tokens.tsx | 31 ++++++++++++++----- .../components/email-verification-setting.tsx | 3 +- .../src/interface/admin-interface.ts | 4 +-- .../stack-shared/src/interface/crud/users.ts | 4 +-- packages/stack-shared/src/known-errors.tsx | 2 +- packages/stack-shared/src/schema-fields.ts | 8 +++-- .../apps/implementations/admin-app-impl.ts | 5 +-- .../apps/implementations/client-app-impl.ts | 3 +- .../template/src/lib/stack-app/users/index.ts | 3 +- .../swift/Sources/StackAuth/Models/User.swift | 2 +- sdks/spec/src/apps/client-app.spec.md | 2 +- sdks/spec/src/types/users/base-user.spec.md | 2 +- 14 files changed, 50 insertions(+), 31 deletions(-) diff --git a/apps/backend/src/app/api/latest/internal/onboarding/preview-affected-users/route.tsx b/apps/backend/src/app/api/latest/internal/onboarding/preview-affected-users/route.tsx index f3eba92135..25e575fb70 100644 --- a/apps/backend/src/app/api/latest/internal/onboarding/preview-affected-users/route.tsx +++ b/apps/backend/src/app/api/latest/internal/onboarding/preview-affected-users/route.tsx @@ -1,6 +1,7 @@ import { getPrismaClientForTenancy } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; -import { adaptSchema, adminAuthTypeSchema, yupArray, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { adaptSchema, adminAuthTypeSchema, restrictedReasonSchema, yupArray, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import type { RestrictedReason } from "@stackframe/stack-shared/dist/schema-fields"; /** * Preview which users would be affected by onboarding config changes. @@ -38,9 +39,7 @@ export const POST = createSmartRouteHandler({ id: yupString().defined(), display_name: yupString().nullable().defined(), primary_email: yupString().nullable().defined(), - restricted_reason: yupObject({ - type: yupString().oneOf(["anonymous", "email_not_verified"]).defined(), - }).defined(), + restricted_reason: restrictedReasonSchema.defined(), }).defined()).defined(), total_affected_count: yupNumber().defined(), }).defined(), @@ -63,7 +62,7 @@ export const POST = createSmartRouteHandler({ id: string, display_name: string | null, primary_email: string | null, - restricted_reason: { type: "anonymous" | "email_not_verified" }, + restricted_reason: RestrictedReason, }> = []; let totalAffectedCount = 0; diff --git a/apps/backend/src/app/api/latest/users/crud.tsx b/apps/backend/src/app/api/latest/users/crud.tsx index d76b80e625..3a16996d3d 100644 --- a/apps/backend/src/app/api/latest/users/crud.tsx +++ b/apps/backend/src/app/api/latest/users/crud.tsx @@ -17,6 +17,7 @@ import { KnownErrors } from "@stackframe/stack-shared"; import { currentUserCrud } from "@stackframe/stack-shared/dist/interface/crud/current-user"; import { UsersCrud, usersCrud } from "@stackframe/stack-shared/dist/interface/crud/users"; import { userIdOrMeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import type { RestrictedReason } from "@stackframe/stack-shared/dist/schema-fields"; import { validateBase64Image } from "@stackframe/stack-shared/dist/utils/base64"; import { decodeBase64 } from "@stackframe/stack-shared/dist/utils/bytes"; import { StackAssertionError, StatusError, captureError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; @@ -104,7 +105,7 @@ export function computeRestrictedStatus( primaryEmailVerified: boolean, config: T, restrictedByAdmin?: boolean, -): { isRestricted: false, restrictedReason: null } | { isRestricted: true, restrictedReason: { type: "anonymous" | "email_not_verified" | "restricted_by_administrator" } } { +): { isRestricted: false, restrictedReason: null } | { isRestricted: true, restrictedReason: RestrictedReason } { // note: when you implement this function, make sure to also update the filter in the list users endpoint // Anonymous users are always restricted (they need to sign up first) diff --git a/apps/backend/src/lib/tokens.tsx b/apps/backend/src/lib/tokens.tsx index 0f5f96eea5..33418cf056 100644 --- a/apps/backend/src/lib/tokens.tsx +++ b/apps/backend/src/lib/tokens.tsx @@ -1,18 +1,19 @@ import { usersCrudHandlers } from '@/app/api/latest/users/crud'; -import { getPrismaClientForTenancy, globalPrismaClient } from '@/prisma-client'; import { withExternalDbSyncUpdate } from '@/lib/external-db-sync'; +import { getPrismaClientForTenancy, globalPrismaClient } from '@/prisma-client'; import { KnownErrors } from '@stackframe/stack-shared'; -import { yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import type { RestrictedReason } from "@stackframe/stack-shared/dist/schema-fields"; +import { restrictedReasonSchema, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import { AccessTokenPayload } from '@stackframe/stack-shared/dist/sessions'; import { generateSecureRandomString } from '@stackframe/stack-shared/dist/utils/crypto'; import { getEnvVariable } from '@stackframe/stack-shared/dist/utils/env'; -import { StackAssertionError, throwErr } from '@stackframe/stack-shared/dist/utils/errors'; +import { captureError, StackAssertionError, throwErr } from '@stackframe/stack-shared/dist/utils/errors'; import { getPrivateJwks, getPublicJwkSet, signJWT, verifyJWT } from '@stackframe/stack-shared/dist/utils/jwt'; import { Result } from '@stackframe/stack-shared/dist/utils/results'; import { traceSpan } from '@stackframe/stack-shared/dist/utils/telemetry'; import * as jose from 'jose'; import { JOSEError, JWTExpired } from 'jose/errors'; -import { SystemEventTypes, getEndUserIpInfoForEvent, logEvent } from './events'; +import { getEndUserIpInfoForEvent, logEvent, SystemEventTypes } from './events'; import { Tenancy } from './tenancies'; export const authorizationHeaderSchema = yupString().matches(/^StackSession [^ ]+$/); @@ -25,9 +26,7 @@ const accessTokenSchema = yupObject({ exp: yupNumber().defined(), isAnonymous: yupBoolean().defined(), isRestricted: yupBoolean().defined(), - restrictedReason: yupObject({ - type: yupString().oneOf(["anonymous", "email_not_verified"] as const).defined(), - }).nullable().defined(), + restrictedReason: restrictedReasonSchema.nullable().defined(), }).defined(); export const oauthCookieSchema = yupObject({ @@ -117,7 +116,7 @@ export async function decodeAccessToken(accessToken: string, { allowAnonymous, a // Legacy tokens default to non-restricted; also, anonymous users are always restricted const isRestricted = (payload.is_restricted as boolean | undefined) ?? isAnonymous; // For legacy anonymous tokens, infer restrictedReason as { type: "anonymous" } - const restrictedReason = (payload.restricted_reason as { type: "anonymous" | "email_not_verified" } | null | undefined) + const restrictedReason = (payload.restricted_reason as RestrictedReason | null | undefined) ?? (isAnonymous ? { type: "anonymous" as const } : null); // Anonymous users must be restricted @@ -305,6 +304,22 @@ export async function generateAccessTokenFromRefreshTokenIfValid(options: Refres restricted_reason: user.restricted_reason, }; + // Validate the payload matches the accessTokenSchema before signing, to catch inconsistencies early + try { + await accessTokenSchema.validate({ + projectId: options.tenancy.project.id, + userId: options.refreshTokenObj.projectUserId, + branchId: options.tenancy.branchId, + refreshTokenId: options.refreshTokenObj.id, + exp: 0, // placeholder, actual exp is set by signJWT + isAnonymous: user.is_anonymous, + isRestricted: user.is_restricted, + restrictedReason: user.restricted_reason, + }); + } catch (error) { + captureError("generated-access-token-payload-does-not-fit-the-access-token-schema", new StackAssertionError("Generated access token payload does not fit the accessTokenSchema. This is a bug — the token data is inconsistent.", { cause: error, payload })); + } + const userType = getUserType(user.is_anonymous, user.is_restricted); return await signJWT({ issuer: getIssuer(options.tenancy.project.id, userType), diff --git a/apps/dashboard/src/components/email-verification-setting.tsx b/apps/dashboard/src/components/email-verification-setting.tsx index a111e48a98..639543c3ea 100644 --- a/apps/dashboard/src/components/email-verification-setting.tsx +++ b/apps/dashboard/src/components/email-verification-setting.tsx @@ -5,6 +5,7 @@ import { SettingSwitch } from "@/components/settings"; import { ActionDialog, Typography } from "@/components/ui"; import { useUpdateConfig } from "@/lib/config-update"; import { EnvelopeSimpleIcon } from "@phosphor-icons/react"; +import type { RestrictedReason } from "@stackframe/stack-shared/dist/schema-fields"; import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises"; import { useState } from "react"; @@ -12,7 +13,7 @@ type AffectedUser = { id: string, displayName: string | null, primaryEmail: string | null, - restrictedReason: { type: "anonymous" | "email_not_verified" }, + restrictedReason: RestrictedReason, }; type PendingChange = { diff --git a/packages/stack-shared/src/interface/admin-interface.ts b/packages/stack-shared/src/interface/admin-interface.ts index 48de39d11b..8d23652d6e 100644 --- a/packages/stack-shared/src/interface/admin-interface.ts +++ b/packages/stack-shared/src/interface/admin-interface.ts @@ -1,6 +1,6 @@ import * as yup from "yup"; import { KnownErrors } from "../known-errors"; -import { branchConfigSourceSchema } from "../schema-fields"; +import { branchConfigSourceSchema, type RestrictedReason } from "../schema-fields"; import { AccessToken, InternalSession, RefreshToken } from "../sessions"; import type { MoneyAmount } from "../utils/currency-constants"; import { Result } from "../utils/results"; @@ -757,7 +757,7 @@ export class StackAdminInterface extends StackServerInterface { id: string, display_name: string | null, primary_email: string | null, - restricted_reason: { type: "anonymous" | "email_not_verified" }, + restricted_reason: RestrictedReason, }>, total_affected_count: number, }> { diff --git a/packages/stack-shared/src/interface/crud/users.ts b/packages/stack-shared/src/interface/crud/users.ts index bf3f9c2ed2..cbeb164370 100644 --- a/packages/stack-shared/src/interface/crud/users.ts +++ b/packages/stack-shared/src/interface/crud/users.ts @@ -60,9 +60,7 @@ export const usersCrudServerReadSchema = fieldSchema.yupObject({ last_active_at_millis: fieldSchema.userLastActiveAtMillisSchema.nonNullable().defined(), is_anonymous: fieldSchema.yupBoolean().defined(), is_restricted: fieldSchema.yupBoolean().defined().meta({ openapiField: { description: 'Whether the user is in restricted state (has signed up but not completed onboarding requirements)', exampleValue: false } }), - restricted_reason: fieldSchema.yupObject({ - type: fieldSchema.yupString().oneOf(fieldSchema.restrictedReasonTypes).defined(), - }).nullable().defined().meta({ openapiField: { description: 'The reason why the user is restricted (e.g., type: "email_not_verified", "anonymous", or "restricted_by_administrator"), null if not restricted', exampleValue: null } }), + restricted_reason: fieldSchema.restrictedReasonSchema.nullable().defined().meta({ openapiField: { description: 'The reason why the user is restricted (e.g., type: "email_not_verified", "anonymous", or "restricted_by_administrator"), null if not restricted', exampleValue: null } }), restricted_by_admin: fieldSchema.yupBoolean().defined().meta({ openapiField: { description: 'Whether the user is restricted by an administrator. Can be set manually or by sign-up rules.', exampleValue: false } }), restricted_by_admin_reason: fieldSchema.yupString().nullable().defined().meta({ openapiField: { description: 'Public reason shown to the user explaining why they are restricted. Optional.', exampleValue: null } }), restricted_by_admin_private_details: fieldSchema.yupString().nullable().defined().meta({ openapiField: { description: 'Private details about the restriction (e.g., which sign-up rule triggered). Only visible to server access and above.', exampleValue: null } }), diff --git a/packages/stack-shared/src/known-errors.tsx b/packages/stack-shared/src/known-errors.tsx index d4a0d64243..524d6b7560 100644 --- a/packages/stack-shared/src/known-errors.tsx +++ b/packages/stack-shared/src/known-errors.tsx @@ -653,7 +653,7 @@ const UserNotFound = createKnownErrorConstructor( const RestrictedUserNotAllowed = createKnownErrorConstructor( KnownError, "RESTRICTED_USER_NOT_ALLOWED", - (restrictedReason: { type: "anonymous" | "email_not_verified" }) => [ + (restrictedReason: { type: "anonymous" | "email_not_verified" | "restricted_by_administrator" }) => [ 403, `The user in the access token is in restricted state. Reason: ${restrictedReason.type}. Please pass the X-Stack-Allow-Restricted-User header if this is intended.`, { diff --git a/packages/stack-shared/src/schema-fields.ts b/packages/stack-shared/src/schema-fields.ts index aa4b8d149a..51d89685a7 100644 --- a/packages/stack-shared/src/schema-fields.ts +++ b/packages/stack-shared/src/schema-fields.ts @@ -733,6 +733,10 @@ export const userTotpSecretMutationSchema = base64Schema.nullable().meta({ opena // Auth export const restrictedReasonTypes = ["anonymous", "email_not_verified", "restricted_by_administrator"] as const; export type RestrictedReasonType = typeof restrictedReasonTypes[number]; +export const restrictedReasonSchema = yupObject({ + type: yupString().oneOf(restrictedReasonTypes).defined(), +}); +export type RestrictedReason = yup.InferType; export const accessTokenPayloadSchema = yupObject({ sub: yupString().defined(), @@ -750,9 +754,7 @@ export const accessTokenPayloadSchema = yupObject({ selected_team_id: yupString().defined().nullable(), is_anonymous: yupBoolean().defined(), is_restricted: yupBoolean().defined(), - restricted_reason: yupObject({ - type: yupString().oneOf(restrictedReasonTypes).defined(), - }).defined().nullable(), + restricted_reason: restrictedReasonSchema.defined().nullable(), }); export const signInEmailSchema = strictEmailSchema(undefined).meta({ openapiField: { description: 'The email to sign in with.', exampleValue: 'johndoe@example.com' } }); export const emailOtpSignInCallbackUrlSchema = urlSchema.meta({ openapiField: { description: 'The base callback URL to construct the magic link from. A query parameter `code` with the verification code will be appended to it. The page should then make a request to the `/auth/otp/sign-in` endpoint.', exampleValue: 'https://example.com/handler/magic-link-callback' } }); diff --git a/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts index e3b53c6a8e..72f2d82070 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts @@ -6,6 +6,7 @@ import { EmailTemplateCrud } from "@stackframe/stack-shared/dist/interface/crud/ import { InternalApiKeysCrud } from "@stackframe/stack-shared/dist/interface/crud/internal-api-keys"; import { ProjectsCrud } from "@stackframe/stack-shared/dist/interface/crud/projects"; import type { Transaction, TransactionType } from "@stackframe/stack-shared/dist/interface/crud/transactions"; +import type { RestrictedReason } from "@stackframe/stack-shared/dist/schema-fields"; import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import { pick } from "@stackframe/stack-shared/dist/utils/objects"; import { Result } from "@stackframe/stack-shared/dist/utils/results"; @@ -962,7 +963,7 @@ export class _StackAdminAppImplIncomplete, totalAffectedCount: number, }> { @@ -975,7 +976,7 @@ export class _StackAdminAppImplIncomplete Date: Wed, 11 Feb 2026 10:58:25 -0800 Subject: [PATCH 13/65] Increase max duratoin limit --- apps/backend/src/route-handlers/smart-route-handler.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/src/route-handlers/smart-route-handler.tsx b/apps/backend/src/route-handlers/smart-route-handler.tsx index 19bcb370ab..44e67a9681 100644 --- a/apps/backend/src/route-handlers/smart-route-handler.tsx +++ b/apps/backend/src/route-handlers/smart-route-handler.tsx @@ -114,7 +114,7 @@ export function handleApiRequest(handler: (req: NextRequest, options: any, reque "/api/latest/internal/external-db-sync/sequencer", "/api/latest/internal/external-db-sync/sync-engine", ]; - const warnAfterSeconds = allowedLongRequestPaths.includes(req.nextUrl.pathname) ? 180 : 12; + const warnAfterSeconds = allowedLongRequestPaths.includes(req.nextUrl.pathname) ? 240 : 12; runAsynchronously(async () => { await wait(warnAfterSeconds * 1000); if (!hasRequestFinished) { From fecc5a4a3fbeae5902b8e7af8c1827bae873aa80 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Wed, 11 Feb 2026 11:34:34 -0800 Subject: [PATCH 14/65] chore: update package versions --- apps/backend/package.json | 2 +- apps/dashboard/package.json | 2 +- apps/dev-launchpad/package.json | 2 +- apps/e2e/package.json | 2 +- apps/mock-oauth-server/package.json | 2 +- docs/package.json | 2 +- examples/cjs-test/package.json | 2 +- examples/convex/package.json | 2 +- examples/demo/package.json | 2 +- examples/docs-examples/package.json | 2 +- examples/e-commerce/package.json | 2 +- examples/js-example/package.json | 2 +- examples/lovable-react-18-example/package.json | 2 +- examples/middleware/package.json | 2 +- examples/react-example/package.json | 2 +- examples/supabase/package.json | 2 +- packages/init-stack/package.json | 2 +- packages/js/package.json | 2 +- packages/react/package.json | 2 +- packages/stack-sc/package.json | 2 +- packages/stack-shared/package.json | 2 +- packages/stack-ui/package.json | 2 +- packages/stack/package.json | 2 +- packages/template/package-template.json | 2 +- packages/template/package.json | 2 +- sdks/implementations/swift/package.json | 2 +- sdks/spec/package.json | 2 +- 27 files changed, 27 insertions(+), 27 deletions(-) diff --git a/apps/backend/package.json b/apps/backend/package.json index e5257b80d1..8e1e4410fc 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -1,6 +1,6 @@ { "name": "@stackframe/stack-backend", - "version": "2.8.65", + "version": "2.8.66", "repository": "https://github.com/stack-auth/stack-auth", "private": true, "type": "module", diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index df5d4145f9..90e644b6c5 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -1,6 +1,6 @@ { "name": "@stackframe/stack-dashboard", - "version": "2.8.65", + "version": "2.8.66", "repository": "https://github.com/stack-auth/stack-auth", "private": true, "scripts": { diff --git a/apps/dev-launchpad/package.json b/apps/dev-launchpad/package.json index b681742807..6283690dc4 100644 --- a/apps/dev-launchpad/package.json +++ b/apps/dev-launchpad/package.json @@ -1,6 +1,6 @@ { "name": "@stackframe/dev-launchpad", - "version": "2.8.65", + "version": "2.8.66", "repository": "https://github.com/stack-auth/stack-auth", "private": true, "scripts": { diff --git a/apps/e2e/package.json b/apps/e2e/package.json index 29481d0f22..bc6e136cc2 100644 --- a/apps/e2e/package.json +++ b/apps/e2e/package.json @@ -1,6 +1,6 @@ { "name": "@stackframe/e2e-tests", - "version": "2.8.65", + "version": "2.8.66", "repository": "https://github.com/stack-auth/stack-auth", "private": true, "type": "module", diff --git a/apps/mock-oauth-server/package.json b/apps/mock-oauth-server/package.json index ef61a3d34e..f764538bcd 100644 --- a/apps/mock-oauth-server/package.json +++ b/apps/mock-oauth-server/package.json @@ -1,6 +1,6 @@ { "name": "@stackframe/mock-oauth-server", - "version": "2.8.65", + "version": "2.8.66", "repository": "https://github.com/stack-auth/stack-auth", "private": true, "main": "index.js", diff --git a/docs/package.json b/docs/package.json index 3d654eda8f..a4c0522b85 100644 --- a/docs/package.json +++ b/docs/package.json @@ -1,6 +1,6 @@ { "name": "@stackframe/stack-docs", - "version": "2.8.65", + "version": "2.8.66", "repository": "https://github.com/stack-auth/stack-auth", "description": "", "main": "index.js", diff --git a/examples/cjs-test/package.json b/examples/cjs-test/package.json index 0f9204f4da..f22883d82c 100644 --- a/examples/cjs-test/package.json +++ b/examples/cjs-test/package.json @@ -1,6 +1,6 @@ { "name": "@stackframe/example-cjs-test", - "version": "2.8.65", + "version": "2.8.66", "repository": "https://github.com/stack-auth/stack-auth", "private": true, "scripts": { diff --git a/examples/convex/package.json b/examples/convex/package.json index 7069a97237..8e983efe8a 100644 --- a/examples/convex/package.json +++ b/examples/convex/package.json @@ -1,6 +1,6 @@ { "name": "@stackframe/convex-example", - "version": "2.8.65", + "version": "2.8.66", "repository": "https://github.com/stack-auth/stack-auth", "private": true, "scripts": { diff --git a/examples/demo/package.json b/examples/demo/package.json index defc6d4d0e..f25a47ff4f 100644 --- a/examples/demo/package.json +++ b/examples/demo/package.json @@ -1,6 +1,6 @@ { "name": "@stackframe/example-demo-app", - "version": "2.8.65", + "version": "2.8.66", "repository": "https://github.com/stack-auth/stack-auth", "description": "", "private": true, diff --git a/examples/docs-examples/package.json b/examples/docs-examples/package.json index 5b206551a8..70f281bea1 100644 --- a/examples/docs-examples/package.json +++ b/examples/docs-examples/package.json @@ -1,6 +1,6 @@ { "name": "@stackframe/docs-examples", - "version": "2.8.65", + "version": "2.8.66", "repository": "https://github.com/stack-auth/stack-auth", "description": "", "private": true, diff --git a/examples/e-commerce/package.json b/examples/e-commerce/package.json index 065a1c86eb..bb12675059 100644 --- a/examples/e-commerce/package.json +++ b/examples/e-commerce/package.json @@ -1,6 +1,6 @@ { "name": "@stackframe/e-commerce-demo", - "version": "2.8.65", + "version": "2.8.66", "repository": "https://github.com/stack-auth/stack-auth", "private": true, "scripts": { diff --git a/examples/js-example/package.json b/examples/js-example/package.json index 6961f27141..b9033a3301 100644 --- a/examples/js-example/package.json +++ b/examples/js-example/package.json @@ -1,6 +1,6 @@ { "name": "@stackframe/js-example", - "version": "2.8.65", + "version": "2.8.66", "repository": "https://github.com/stack-auth/stack-auth", "private": true, "description": "", diff --git a/examples/lovable-react-18-example/package.json b/examples/lovable-react-18-example/package.json index 1010dccb19..6eed8aaf09 100644 --- a/examples/lovable-react-18-example/package.json +++ b/examples/lovable-react-18-example/package.json @@ -1,7 +1,7 @@ { "name": "@stackframe/lovable-react-18-example", "private": true, - "version": "2.8.65", + "version": "2.8.66", "repository": "https://github.com/stack-auth/stack-auth", "type": "module", "scripts": { diff --git a/examples/middleware/package.json b/examples/middleware/package.json index c1a765f96f..edb59c741a 100644 --- a/examples/middleware/package.json +++ b/examples/middleware/package.json @@ -1,6 +1,6 @@ { "name": "@stackframe/example-middleware-demo", - "version": "2.8.65", + "version": "2.8.66", "repository": "https://github.com/stack-auth/stack-auth", "private": true, "scripts": { diff --git a/examples/react-example/package.json b/examples/react-example/package.json index 863a0d762f..026b750d20 100644 --- a/examples/react-example/package.json +++ b/examples/react-example/package.json @@ -1,7 +1,7 @@ { "name": "react-example", "private": true, - "version": "2.8.65", + "version": "2.8.66", "repository": "https://github.com/stack-auth/stack-auth", "type": "module", "scripts": { diff --git a/examples/supabase/package.json b/examples/supabase/package.json index c72757fd3f..d9189bd54b 100644 --- a/examples/supabase/package.json +++ b/examples/supabase/package.json @@ -1,6 +1,6 @@ { "name": "@stackframe/example-supabase", - "version": "2.8.65", + "version": "2.8.66", "repository": "https://github.com/stack-auth/stack-auth", "private": true, "scripts": { diff --git a/packages/init-stack/package.json b/packages/init-stack/package.json index b1a4e2da1a..7bbd8abe34 100644 --- a/packages/init-stack/package.json +++ b/packages/init-stack/package.json @@ -1,6 +1,6 @@ { "name": "@stackframe/init-stack", - "version": "2.8.65", + "version": "2.8.66", "repository": "https://github.com/stack-auth/stack-auth", "description": "The setup wizard for Stack. https://stack-auth.com", "main": "dist/index.js", diff --git a/packages/js/package.json b/packages/js/package.json index 55e2b2b6f5..7ef9e72ac5 100644 --- a/packages/js/package.json +++ b/packages/js/package.json @@ -1,7 +1,7 @@ { "//": "THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY, INSTEAD EDIT THE CORRESPONDING FILE IN packages/template (FOR package.json FILES, PLEASE EDIT package-template.json)", "name": "@stackframe/js", - "version": "2.8.65", + "version": "2.8.66", "repository": "https://github.com/stack-auth/stack-auth", "sideEffects": false, "main": "./dist/index.js", diff --git a/packages/react/package.json b/packages/react/package.json index 7cc7618e53..e1bbc7a9a7 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,7 +1,7 @@ { "//": "THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY, INSTEAD EDIT THE CORRESPONDING FILE IN packages/template (FOR package.json FILES, PLEASE EDIT package-template.json)", "name": "@stackframe/react", - "version": "2.8.65", + "version": "2.8.66", "repository": "https://github.com/stack-auth/stack-auth", "sideEffects": false, "main": "./dist/index.js", diff --git a/packages/stack-sc/package.json b/packages/stack-sc/package.json index 05257dbfd7..e65187e27c 100644 --- a/packages/stack-sc/package.json +++ b/packages/stack-sc/package.json @@ -1,6 +1,6 @@ { "name": "@stackframe/stack-sc", - "version": "2.8.65", + "version": "2.8.66", "repository": "https://github.com/stack-auth/stack-auth", "exports": { "./force-react-server": { diff --git a/packages/stack-shared/package.json b/packages/stack-shared/package.json index d35ceef7d0..4820b64bf0 100644 --- a/packages/stack-shared/package.json +++ b/packages/stack-shared/package.json @@ -1,6 +1,6 @@ { "name": "@stackframe/stack-shared", - "version": "2.8.65", + "version": "2.8.66", "repository": "https://github.com/stack-auth/stack-auth", "scripts": { "build": "rimraf dist && tsup-node", diff --git a/packages/stack-ui/package.json b/packages/stack-ui/package.json index 5694dacb42..f283e90a88 100644 --- a/packages/stack-ui/package.json +++ b/packages/stack-ui/package.json @@ -1,6 +1,6 @@ { "name": "@stackframe/stack-ui", - "version": "2.8.65", + "version": "2.8.66", "repository": "https://github.com/stack-auth/stack-auth", "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/packages/stack/package.json b/packages/stack/package.json index d08fc1c806..cf661fdae2 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -1,7 +1,7 @@ { "//": "THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY, INSTEAD EDIT THE CORRESPONDING FILE IN packages/template (FOR package.json FILES, PLEASE EDIT package-template.json)", "name": "@stackframe/stack", - "version": "2.8.65", + "version": "2.8.66", "repository": "https://github.com/stack-auth/stack-auth", "sideEffects": false, "main": "./dist/index.js", diff --git a/packages/template/package-template.json b/packages/template/package-template.json index 99ffb8a01a..28b8807a47 100644 --- a/packages/template/package-template.json +++ b/packages/template/package-template.json @@ -11,7 +11,7 @@ "//": "NEXT_LINE_PLATFORM template", "private": true, - "version": "2.8.65", + "version": "2.8.66", "repository": "https://github.com/stack-auth/stack-auth", "sideEffects": false, "main": "./dist/index.js", diff --git a/packages/template/package.json b/packages/template/package.json index fa3cd4a026..8ec4c47933 100644 --- a/packages/template/package.json +++ b/packages/template/package.json @@ -2,7 +2,7 @@ "//": "THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY, INSTEAD EDIT THE CORRESPONDING FILE IN packages/template (FOR package.json FILES, PLEASE EDIT package-template.json)", "name": "@stackframe/template", "private": true, - "version": "2.8.65", + "version": "2.8.66", "repository": "https://github.com/stack-auth/stack-auth", "sideEffects": false, "main": "./dist/index.js", diff --git a/sdks/implementations/swift/package.json b/sdks/implementations/swift/package.json index d74f9e7b3e..12f77b0cb7 100644 --- a/sdks/implementations/swift/package.json +++ b/sdks/implementations/swift/package.json @@ -1,6 +1,6 @@ { "name": "@stackframe/swift-sdk", - "version": "2.8.65", + "version": "2.8.66", "private": true, "description": "Stack Auth Swift SDK", "scripts": { diff --git a/sdks/spec/package.json b/sdks/spec/package.json index bdb633341f..aad82557c9 100644 --- a/sdks/spec/package.json +++ b/sdks/spec/package.json @@ -1,6 +1,6 @@ { "name": "@stackframe/sdk-spec", - "version": "2.8.65", + "version": "2.8.66", "private": true, "description": "Stack Auth SDK specification files", "scripts": {} From b0559bce551c14d048938a49717457d7c84cb8d9 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Wed, 11 Feb 2026 11:47:10 -0800 Subject: [PATCH 15/65] Increase transaction retry count --- apps/backend/src/prisma-client.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/src/prisma-client.tsx b/apps/backend/src/prisma-client.tsx index 5a3486ef2b..c06fe3a21a 100644 --- a/apps/backend/src/prisma-client.tsx +++ b/apps/backend/src/prisma-client.tsx @@ -494,7 +494,7 @@ export async function retryTransaction(client: Omit, fn: } return attemptRes; }); - }, 3, { + }, 4, { exponentialDelayBase: getNodeEnvironment() === 'development' || getNodeEnvironment() === 'test' ? 3 : 1000, }); From f493b27c7b7ad4229cfe5b9bc017932ca1c6a026 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Wed, 11 Feb 2026 13:12:38 -0800 Subject: [PATCH 16/65] Trust own domain --- .../lib/stack-app/apps/implementations/client-app-impl.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts index 301bbd391e..cf6e703cff 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts @@ -16,8 +16,8 @@ import { TeamMemberProfilesCrud } from "@stackframe/stack-shared/dist/interface/ import { TeamPermissionsCrud } from "@stackframe/stack-shared/dist/interface/crud/team-permissions"; import { TeamsCrud } from "@stackframe/stack-shared/dist/interface/crud/teams"; import { UsersCrud } from "@stackframe/stack-shared/dist/interface/crud/users"; -import { InternalSession } from "@stackframe/stack-shared/dist/sessions"; import type { RestrictedReason } from "@stackframe/stack-shared/dist/schema-fields"; +import { InternalSession } from "@stackframe/stack-shared/dist/sessions"; import { encodeBase32 } from "@stackframe/stack-shared/dist/utils/bytes"; import { scrambleDuringCompileTime } from "@stackframe/stack-shared/dist/utils/compile-time"; import { isBrowserLike } from "@stackframe/stack-shared/dist/utils/env"; @@ -1865,7 +1865,9 @@ export class _StackClientAppImplIncomplete { - return isRelative(url); + // TODO: At some point, we should use the project's trusted domains for this instead of just requiring the URL to be relative + // (note that when we do this, that should be on-top of the relativity check, not replacing it) + return isRelative(url) || (typeof window !== "undefined" && window.location.origin === new URL(url).origin); } get urls(): Readonly { From e621510c620b278b2cae0fb8a78b331ac9c08f13 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Wed, 11 Feb 2026 13:14:00 -0800 Subject: [PATCH 17/65] Update AGENTS.md --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index d05907b13b..dbcd210420 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,6 +98,7 @@ To see all development ports, refer to the index.html of `apps/dev-launchpad/pub - Fail early, fail loud. Fail fast with an error instead of silently continuing. - Do NOT use `as`/`any`/type casts or anything else like that to bypass the type system unless you specifically asked the user about it. Most of the time a place where you would use type casts is not one where you actually need them. Avoid wherever possible. - When writing database migration files, assume that we have >1,000,000 rows in every table (unless otherwise specified). This means you may have to use CONDITIONALLY_REPEAT_MIGRATION_SENTINEL to avoid running the migration and things like concurrent index builds; see the existing migrations for examples. +- **When building frontend code, always carefully deal with loading and error states.** Be very explicit with these; some components make this easy, eg. the button onClick already takes an async callback for loading state, but make sure this is done everywhere, and make sure errors are NEVER just silently swallowed. ### Code-related - Use ES6 maps instead of records wherever you can. From ee9912fafb998aebd5562408a4932579189e8be2 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Wed, 11 Feb 2026 18:55:58 -0800 Subject: [PATCH 18/65] Decrease Nodemailer limits --- apps/backend/src/lib/email-queue-step.tsx | 1 - apps/backend/src/lib/emails-low-level.tsx | 18 +++++++++++++----- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/apps/backend/src/lib/email-queue-step.tsx b/apps/backend/src/lib/email-queue-step.tsx index 61fbd735ee..0d72ea2e1f 100644 --- a/apps/backend/src/lib/email-queue-step.tsx +++ b/apps/backend/src/lib/email-queue-step.tsx @@ -82,7 +82,6 @@ async function verifyEmailDeliverability( } const json = await emailableResponse.json() as Record; - console.log("emailableResponse", json); if (json.state === "undeliverable" || json.disposable) { console.log("email not deliverable", email, json); diff --git a/apps/backend/src/lib/emails-low-level.tsx b/apps/backend/src/lib/emails-low-level.tsx index e8d1571368..21f31a7fe8 100644 --- a/apps/backend/src/lib/emails-low-level.tsx +++ b/apps/backend/src/lib/emails-low-level.tsx @@ -74,17 +74,25 @@ async function _lowLevelSendEmailWithoutRetries(options: LowLevelSendEmailOption host: options.emailConfig.host, port: options.emailConfig.port, secure: options.emailConfig.secure, + connectionTimeout: 15000, + greetingTimeout: 10000, + socketTimeout: 20000, + dnsTimeout: 7000, auth: { user: options.emailConfig.username, pass: options.emailConfig.password, }, }); - await transporter.sendMail({ - from: `"${options.emailConfig.senderName}" <${options.emailConfig.senderEmail}>`, - ...options, - to: toArray, - }); + try { + await transporter.sendMail({ + from: `"${options.emailConfig.senderName}" <${options.emailConfig.senderEmail}>`, + ...options, + to: toArray, + }); + } finally { + transporter.close(); + } return Result.ok(undefined); } catch (error) { From 92087c8a5c5f3b04f46241f52783ae1bd132b314 Mon Sep 17 00:00:00 2001 From: aadesh18 <110230993+aadesh18@users.noreply.github.com> Date: Wed, 11 Feb 2026 19:06:57 -0800 Subject: [PATCH 19/65] Refactor admin/client interfaces (edited projectOwnerSession) (#1190) --- .../src/interface/admin-interface.ts | 2 +- .../src/interface/client-interface.ts | 23 ++++++++++++++++--- .../src/interface/server-interface.ts | 2 +- .../stack-app/apps/interfaces/admin-app.ts | 2 +- 4 files changed, 23 insertions(+), 6 deletions(-) diff --git a/packages/stack-shared/src/interface/admin-interface.ts b/packages/stack-shared/src/interface/admin-interface.ts index 8d23652d6e..711e7dddc5 100644 --- a/packages/stack-shared/src/interface/admin-interface.ts +++ b/packages/stack-shared/src/interface/admin-interface.ts @@ -28,7 +28,7 @@ export type AdminAuthApplicationOptions = ServerAuthApplicationOptions &( superSecretAdminKey: string, } | { - projectOwnerSession: InternalSession, + projectOwnerSession: InternalSession | (() => Promise), } ); diff --git a/packages/stack-shared/src/interface/client-interface.ts b/packages/stack-shared/src/interface/client-interface.ts index 7c6a4c327e..eb21d585eb 100644 --- a/packages/stack-shared/src/interface/client-interface.ts +++ b/packages/stack-shared/src/interface/client-interface.ts @@ -42,7 +42,7 @@ export type ClientInterfaceOptions = { } & ({ publishableClientKey: string, } | { - projectOwnerSession: InternalSession, + projectOwnerSession: InternalSession | (() => Promise), }); export class StackClientInterface { @@ -286,8 +286,25 @@ export class StackClientInterface { */ let tokenObj = await session.getOrFetchLikelyValidTokens(20_000, null); - let adminSession = "projectOwnerSession" in this.options ? this.options.projectOwnerSession : null; - let adminTokenObj = adminSession ? await adminSession.getOrFetchLikelyValidTokens(20_000, null) : null; + let adminSession: InternalSession | null = null; + let adminTokenObj: { accessToken: AccessToken, refreshToken: RefreshToken | null } | null = null; + + if ("projectOwnerSession" in this.options) { + const projectOwnerSession = this.options.projectOwnerSession; + + if (typeof projectOwnerSession === 'function') { + const accessTokenString = await projectOwnerSession(); + if (accessTokenString) { + const accessToken = AccessToken.createIfValid(accessTokenString); + if (accessToken) { + adminTokenObj = { accessToken, refreshToken: null }; + } + } + } else { + adminSession = projectOwnerSession; + adminTokenObj = await projectOwnerSession.getOrFetchLikelyValidTokens(20_000, null); + } + } // all requests should be dynamic to prevent Next.js caching await this.options.prepareRequest?.(); diff --git a/packages/stack-shared/src/interface/server-interface.ts b/packages/stack-shared/src/interface/server-interface.ts index 962c3d0b9a..87db7202f9 100644 --- a/packages/stack-shared/src/interface/server-interface.ts +++ b/packages/stack-shared/src/interface/server-interface.ts @@ -33,7 +33,7 @@ export type ServerAuthApplicationOptions = ( readonly secretServerKey: string, } | { - readonly projectOwnerSession: InternalSession, + readonly projectOwnerSession: InternalSession | (() => Promise), } ) ); diff --git a/packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts b/packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts index 9e14a084ce..8960e70515 100644 --- a/packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts +++ b/packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts @@ -35,7 +35,7 @@ export type StackAdminAppConstructorOptions & { superSecretAdminKey?: string, - projectOwnerSession?: InternalSession, + projectOwnerSession?: InternalSession | (() => Promise), } ); From 95d383951abd5442a7973c12d24f5c1050f649e8 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Wed, 11 Feb 2026 19:12:03 -0800 Subject: [PATCH 20/65] More email retries --- apps/backend/src/lib/emails-low-level.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/src/lib/emails-low-level.tsx b/apps/backend/src/lib/emails-low-level.tsx index 21f31a7fe8..fc64a9f045 100644 --- a/apps/backend/src/lib/emails-low-level.tsx +++ b/apps/backend/src/lib/emails-low-level.tsx @@ -300,7 +300,7 @@ export async function lowLevelSendEmailDirectViaProvider(options: LowLevelSendEm } return result; - }, 3, { exponentialDelayBase: 2000 }); + }, 5, { exponentialDelayBase: 1000 }); } catch (error) { if (error instanceof DoNotRetryError) { return Result.error(error.errorObj); From 6107c67335663de6856b77d371deecc54d30ff56 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Wed, 11 Feb 2026 19:19:06 -0800 Subject: [PATCH 21/65] chore: update package versions --- apps/backend/package.json | 2 +- apps/dashboard/package.json | 2 +- apps/dev-launchpad/package.json | 2 +- apps/e2e/package.json | 2 +- apps/mock-oauth-server/package.json | 2 +- docs/package.json | 2 +- examples/cjs-test/package.json | 2 +- examples/convex/package.json | 2 +- examples/demo/package.json | 2 +- examples/docs-examples/package.json | 2 +- examples/e-commerce/package.json | 2 +- examples/js-example/package.json | 2 +- examples/lovable-react-18-example/package.json | 2 +- examples/middleware/package.json | 2 +- examples/react-example/package.json | 2 +- examples/supabase/package.json | 2 +- packages/init-stack/package.json | 2 +- packages/js/package.json | 2 +- packages/react/package.json | 2 +- packages/stack-sc/package.json | 2 +- packages/stack-shared/package.json | 2 +- packages/stack-ui/package.json | 2 +- packages/stack/package.json | 2 +- packages/template/package-template.json | 2 +- packages/template/package.json | 2 +- sdks/implementations/swift/package.json | 2 +- sdks/spec/package.json | 2 +- 27 files changed, 27 insertions(+), 27 deletions(-) diff --git a/apps/backend/package.json b/apps/backend/package.json index 8e1e4410fc..23d5d58e21 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -1,6 +1,6 @@ { "name": "@stackframe/stack-backend", - "version": "2.8.66", + "version": "2.8.67", "repository": "https://github.com/stack-auth/stack-auth", "private": true, "type": "module", diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 90e644b6c5..1dc38bea28 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -1,6 +1,6 @@ { "name": "@stackframe/stack-dashboard", - "version": "2.8.66", + "version": "2.8.67", "repository": "https://github.com/stack-auth/stack-auth", "private": true, "scripts": { diff --git a/apps/dev-launchpad/package.json b/apps/dev-launchpad/package.json index 6283690dc4..5a5c760016 100644 --- a/apps/dev-launchpad/package.json +++ b/apps/dev-launchpad/package.json @@ -1,6 +1,6 @@ { "name": "@stackframe/dev-launchpad", - "version": "2.8.66", + "version": "2.8.67", "repository": "https://github.com/stack-auth/stack-auth", "private": true, "scripts": { diff --git a/apps/e2e/package.json b/apps/e2e/package.json index bc6e136cc2..ca22f1af4c 100644 --- a/apps/e2e/package.json +++ b/apps/e2e/package.json @@ -1,6 +1,6 @@ { "name": "@stackframe/e2e-tests", - "version": "2.8.66", + "version": "2.8.67", "repository": "https://github.com/stack-auth/stack-auth", "private": true, "type": "module", diff --git a/apps/mock-oauth-server/package.json b/apps/mock-oauth-server/package.json index f764538bcd..bb278375f0 100644 --- a/apps/mock-oauth-server/package.json +++ b/apps/mock-oauth-server/package.json @@ -1,6 +1,6 @@ { "name": "@stackframe/mock-oauth-server", - "version": "2.8.66", + "version": "2.8.67", "repository": "https://github.com/stack-auth/stack-auth", "private": true, "main": "index.js", diff --git a/docs/package.json b/docs/package.json index a4c0522b85..b1a9ee35a2 100644 --- a/docs/package.json +++ b/docs/package.json @@ -1,6 +1,6 @@ { "name": "@stackframe/stack-docs", - "version": "2.8.66", + "version": "2.8.67", "repository": "https://github.com/stack-auth/stack-auth", "description": "", "main": "index.js", diff --git a/examples/cjs-test/package.json b/examples/cjs-test/package.json index f22883d82c..262cea9ce5 100644 --- a/examples/cjs-test/package.json +++ b/examples/cjs-test/package.json @@ -1,6 +1,6 @@ { "name": "@stackframe/example-cjs-test", - "version": "2.8.66", + "version": "2.8.67", "repository": "https://github.com/stack-auth/stack-auth", "private": true, "scripts": { diff --git a/examples/convex/package.json b/examples/convex/package.json index 8e983efe8a..7c17a6fa06 100644 --- a/examples/convex/package.json +++ b/examples/convex/package.json @@ -1,6 +1,6 @@ { "name": "@stackframe/convex-example", - "version": "2.8.66", + "version": "2.8.67", "repository": "https://github.com/stack-auth/stack-auth", "private": true, "scripts": { diff --git a/examples/demo/package.json b/examples/demo/package.json index f25a47ff4f..f77bcfa7f3 100644 --- a/examples/demo/package.json +++ b/examples/demo/package.json @@ -1,6 +1,6 @@ { "name": "@stackframe/example-demo-app", - "version": "2.8.66", + "version": "2.8.67", "repository": "https://github.com/stack-auth/stack-auth", "description": "", "private": true, diff --git a/examples/docs-examples/package.json b/examples/docs-examples/package.json index 70f281bea1..35ff478710 100644 --- a/examples/docs-examples/package.json +++ b/examples/docs-examples/package.json @@ -1,6 +1,6 @@ { "name": "@stackframe/docs-examples", - "version": "2.8.66", + "version": "2.8.67", "repository": "https://github.com/stack-auth/stack-auth", "description": "", "private": true, diff --git a/examples/e-commerce/package.json b/examples/e-commerce/package.json index bb12675059..c201c90604 100644 --- a/examples/e-commerce/package.json +++ b/examples/e-commerce/package.json @@ -1,6 +1,6 @@ { "name": "@stackframe/e-commerce-demo", - "version": "2.8.66", + "version": "2.8.67", "repository": "https://github.com/stack-auth/stack-auth", "private": true, "scripts": { diff --git a/examples/js-example/package.json b/examples/js-example/package.json index b9033a3301..42b54b0973 100644 --- a/examples/js-example/package.json +++ b/examples/js-example/package.json @@ -1,6 +1,6 @@ { "name": "@stackframe/js-example", - "version": "2.8.66", + "version": "2.8.67", "repository": "https://github.com/stack-auth/stack-auth", "private": true, "description": "", diff --git a/examples/lovable-react-18-example/package.json b/examples/lovable-react-18-example/package.json index 6eed8aaf09..c5b4c9529a 100644 --- a/examples/lovable-react-18-example/package.json +++ b/examples/lovable-react-18-example/package.json @@ -1,7 +1,7 @@ { "name": "@stackframe/lovable-react-18-example", "private": true, - "version": "2.8.66", + "version": "2.8.67", "repository": "https://github.com/stack-auth/stack-auth", "type": "module", "scripts": { diff --git a/examples/middleware/package.json b/examples/middleware/package.json index edb59c741a..419fd8ee2c 100644 --- a/examples/middleware/package.json +++ b/examples/middleware/package.json @@ -1,6 +1,6 @@ { "name": "@stackframe/example-middleware-demo", - "version": "2.8.66", + "version": "2.8.67", "repository": "https://github.com/stack-auth/stack-auth", "private": true, "scripts": { diff --git a/examples/react-example/package.json b/examples/react-example/package.json index 026b750d20..10c31aba2a 100644 --- a/examples/react-example/package.json +++ b/examples/react-example/package.json @@ -1,7 +1,7 @@ { "name": "react-example", "private": true, - "version": "2.8.66", + "version": "2.8.67", "repository": "https://github.com/stack-auth/stack-auth", "type": "module", "scripts": { diff --git a/examples/supabase/package.json b/examples/supabase/package.json index d9189bd54b..9d3a0a29a6 100644 --- a/examples/supabase/package.json +++ b/examples/supabase/package.json @@ -1,6 +1,6 @@ { "name": "@stackframe/example-supabase", - "version": "2.8.66", + "version": "2.8.67", "repository": "https://github.com/stack-auth/stack-auth", "private": true, "scripts": { diff --git a/packages/init-stack/package.json b/packages/init-stack/package.json index 7bbd8abe34..4d01906536 100644 --- a/packages/init-stack/package.json +++ b/packages/init-stack/package.json @@ -1,6 +1,6 @@ { "name": "@stackframe/init-stack", - "version": "2.8.66", + "version": "2.8.67", "repository": "https://github.com/stack-auth/stack-auth", "description": "The setup wizard for Stack. https://stack-auth.com", "main": "dist/index.js", diff --git a/packages/js/package.json b/packages/js/package.json index 7ef9e72ac5..0603dcc4f6 100644 --- a/packages/js/package.json +++ b/packages/js/package.json @@ -1,7 +1,7 @@ { "//": "THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY, INSTEAD EDIT THE CORRESPONDING FILE IN packages/template (FOR package.json FILES, PLEASE EDIT package-template.json)", "name": "@stackframe/js", - "version": "2.8.66", + "version": "2.8.67", "repository": "https://github.com/stack-auth/stack-auth", "sideEffects": false, "main": "./dist/index.js", diff --git a/packages/react/package.json b/packages/react/package.json index e1bbc7a9a7..435c2d89d8 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,7 +1,7 @@ { "//": "THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY, INSTEAD EDIT THE CORRESPONDING FILE IN packages/template (FOR package.json FILES, PLEASE EDIT package-template.json)", "name": "@stackframe/react", - "version": "2.8.66", + "version": "2.8.67", "repository": "https://github.com/stack-auth/stack-auth", "sideEffects": false, "main": "./dist/index.js", diff --git a/packages/stack-sc/package.json b/packages/stack-sc/package.json index e65187e27c..9747ed3e25 100644 --- a/packages/stack-sc/package.json +++ b/packages/stack-sc/package.json @@ -1,6 +1,6 @@ { "name": "@stackframe/stack-sc", - "version": "2.8.66", + "version": "2.8.67", "repository": "https://github.com/stack-auth/stack-auth", "exports": { "./force-react-server": { diff --git a/packages/stack-shared/package.json b/packages/stack-shared/package.json index 4820b64bf0..140bbdf0e3 100644 --- a/packages/stack-shared/package.json +++ b/packages/stack-shared/package.json @@ -1,6 +1,6 @@ { "name": "@stackframe/stack-shared", - "version": "2.8.66", + "version": "2.8.67", "repository": "https://github.com/stack-auth/stack-auth", "scripts": { "build": "rimraf dist && tsup-node", diff --git a/packages/stack-ui/package.json b/packages/stack-ui/package.json index f283e90a88..06c024be77 100644 --- a/packages/stack-ui/package.json +++ b/packages/stack-ui/package.json @@ -1,6 +1,6 @@ { "name": "@stackframe/stack-ui", - "version": "2.8.66", + "version": "2.8.67", "repository": "https://github.com/stack-auth/stack-auth", "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/packages/stack/package.json b/packages/stack/package.json index cf661fdae2..a0dc0456da 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -1,7 +1,7 @@ { "//": "THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY, INSTEAD EDIT THE CORRESPONDING FILE IN packages/template (FOR package.json FILES, PLEASE EDIT package-template.json)", "name": "@stackframe/stack", - "version": "2.8.66", + "version": "2.8.67", "repository": "https://github.com/stack-auth/stack-auth", "sideEffects": false, "main": "./dist/index.js", diff --git a/packages/template/package-template.json b/packages/template/package-template.json index 28b8807a47..02916da768 100644 --- a/packages/template/package-template.json +++ b/packages/template/package-template.json @@ -11,7 +11,7 @@ "//": "NEXT_LINE_PLATFORM template", "private": true, - "version": "2.8.66", + "version": "2.8.67", "repository": "https://github.com/stack-auth/stack-auth", "sideEffects": false, "main": "./dist/index.js", diff --git a/packages/template/package.json b/packages/template/package.json index 8ec4c47933..def4bcbfc5 100644 --- a/packages/template/package.json +++ b/packages/template/package.json @@ -2,7 +2,7 @@ "//": "THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY, INSTEAD EDIT THE CORRESPONDING FILE IN packages/template (FOR package.json FILES, PLEASE EDIT package-template.json)", "name": "@stackframe/template", "private": true, - "version": "2.8.66", + "version": "2.8.67", "repository": "https://github.com/stack-auth/stack-auth", "sideEffects": false, "main": "./dist/index.js", diff --git a/sdks/implementations/swift/package.json b/sdks/implementations/swift/package.json index 12f77b0cb7..c59fbc251e 100644 --- a/sdks/implementations/swift/package.json +++ b/sdks/implementations/swift/package.json @@ -1,6 +1,6 @@ { "name": "@stackframe/swift-sdk", - "version": "2.8.66", + "version": "2.8.67", "private": true, "description": "Stack Auth Swift SDK", "scripts": { diff --git a/sdks/spec/package.json b/sdks/spec/package.json index aad82557c9..244f718e8c 100644 --- a/sdks/spec/package.json +++ b/sdks/spec/package.json @@ -1,6 +1,6 @@ { "name": "@stackframe/sdk-spec", - "version": "2.8.66", + "version": "2.8.67", "private": true, "description": "Stack Auth SDK specification files", "scripts": {} From 23b2078d9edd49450160f97537f1666834ef773e Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Wed, 11 Feb 2026 20:42:51 -0800 Subject: [PATCH 22/65] Increase email retries further --- apps/backend/src/lib/emails-low-level.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/src/lib/emails-low-level.tsx b/apps/backend/src/lib/emails-low-level.tsx index fc64a9f045..fc2264e803 100644 --- a/apps/backend/src/lib/emails-low-level.tsx +++ b/apps/backend/src/lib/emails-low-level.tsx @@ -300,7 +300,7 @@ export async function lowLevelSendEmailDirectViaProvider(options: LowLevelSendEm } return result; - }, 5, { exponentialDelayBase: 1000 }); + }, 7, { exponentialDelayBase: 125 }); } catch (error) { if (error instanceof DoNotRetryError) { return Result.error(error.errorObj); From f656ee7a0e50b02cf6168e6227d96ae1aa4cb9c7 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Wed, 11 Feb 2026 20:51:34 -0800 Subject: [PATCH 23/65] Increase email retry count to 9 --- apps/backend/src/lib/emails-low-level.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/src/lib/emails-low-level.tsx b/apps/backend/src/lib/emails-low-level.tsx index fc2264e803..fb4f5885cf 100644 --- a/apps/backend/src/lib/emails-low-level.tsx +++ b/apps/backend/src/lib/emails-low-level.tsx @@ -300,7 +300,7 @@ export async function lowLevelSendEmailDirectViaProvider(options: LowLevelSendEm } return result; - }, 7, { exponentialDelayBase: 125 }); + }, 9, { exponentialDelayBase: 125 }); } catch (error) { if (error instanceof DoNotRetryError) { return Result.error(error.errorObj); From 621ada20a025760530338b1c76b8456ed0c7b0d5 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Thu, 12 Feb 2026 12:29:11 -0800 Subject: [PATCH 24/65] Better error message for expired access tokens --- .../api/latest/auth/sessions/current/route.tsx | 2 +- apps/backend/src/lib/tokens.tsx | 7 ++++++- .../backend/endpoints/api/v1/users.test.ts | 2 +- packages/stack-shared/src/known-errors.tsx | 18 ++++++++++++++---- 4 files changed, 22 insertions(+), 7 deletions(-) diff --git a/apps/backend/src/app/api/latest/auth/sessions/current/route.tsx b/apps/backend/src/app/api/latest/auth/sessions/current/route.tsx index fbaf24dfd2..6df9ab3a5f 100644 --- a/apps/backend/src/app/api/latest/auth/sessions/current/route.tsx +++ b/apps/backend/src/app/api/latest/auth/sessions/current/route.tsx @@ -27,7 +27,7 @@ export const DELETE = createSmartRouteHandler({ if (!refreshTokenId) { // Only here for transition period, remove this once all access tokens are updated // TODO next-release - throw new KnownErrors.AccessTokenExpired(new Date()); + throw new KnownErrors.AccessTokenExpired(new Date(), undefined, undefined, undefined); } try { diff --git a/apps/backend/src/lib/tokens.tsx b/apps/backend/src/lib/tokens.tsx index 33418cf056..7d29b3fac1 100644 --- a/apps/backend/src/lib/tokens.tsx +++ b/apps/backend/src/lib/tokens.tsx @@ -103,7 +103,12 @@ export async function decodeAccessToken(accessToken: string, { allowAnonymous, a }); } catch (error) { if (error instanceof JWTExpired) { - return Result.error(new KnownErrors.AccessTokenExpired(decoded?.exp ? new Date(decoded.exp * 1000) : undefined)); + return Result.error(new KnownErrors.AccessTokenExpired( + decoded?.exp ? new Date(decoded.exp * 1000) : undefined, + decoded?.aud?.toString().split(":")[0], + decoded?.sub ?? undefined, + (decoded?.refresh_token_id ?? decoded?.refreshTokenId) as string | undefined, + )); } else if (error instanceof JOSEError) { console.warn("Unparsable access token. This might be a user error, but if it happens frequently, it's a sign of a misconfiguration.", { accessToken, error }); return Result.error(new KnownErrors.UnparsableAccessToken()); diff --git a/apps/e2e/tests/backend/endpoints/api/v1/users.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/users.test.ts index 8d95ecdf97..91800b883a 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/users.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/users.test.ts @@ -113,7 +113,7 @@ describe("with client access", () => { "code": "ACCESS_TOKEN_EXPIRED", "details": { "expired_at_millis": 1738374988000 }, "error": deindent\` - Access token has expired. Please refresh it and try again. (The access token expired at 2025-02-01T01:56:28.000Z.) + Access token has expired. Please refresh it and try again. (The access token expired at 2025-02-01T01:56:28.000Z.) Project ID: 1234567890. User ID: 1234567890. Refresh token ID: 1234567890. Debug info: Most likely, you fetched the access token before it expired (for example, in a server component, pre-rendered page, or on page load), but then didn't refresh it before it expired. If this is the case, and you're using the SDK, make sure you call getAccessToken() every time you need to use the access token. If you're not using the SDK, make sure you refresh the access token with the refresh endpoint. \`, diff --git a/packages/stack-shared/src/known-errors.tsx b/packages/stack-shared/src/known-errors.tsx index 524d6b7560..d4358b9954 100644 --- a/packages/stack-shared/src/known-errors.tsx +++ b/packages/stack-shared/src/known-errors.tsx @@ -528,16 +528,26 @@ const UnparsableAccessToken = createKnownErrorConstructor( const AccessTokenExpired = createKnownErrorConstructor( InvalidAccessToken, "ACCESS_TOKEN_EXPIRED", - (expiredAt: Date | undefined) => [ + (expiredAt: Date | undefined, projectId: string | undefined, userId: string | undefined, refreshTokenId: string | undefined) => [ 401, deindent` - Access token has expired. Please refresh it and try again.${expiredAt ? ` (The access token expired at ${expiredAt.toISOString()}.)` : ""} + Access token has expired. Please refresh it and try again.${expiredAt ? ` (The access token expired at ${expiredAt.toISOString()}.)` : ""}${projectId ? ` Project ID: ${projectId}.` : ""}${userId ? ` User ID: ${userId}.` : ""}${refreshTokenId ? ` Refresh token ID: ${refreshTokenId}.` : ""} Debug info: Most likely, you fetched the access token before it expired (for example, in a server component, pre-rendered page, or on page load), but then didn't refresh it before it expired. If this is the case, and you're using the SDK, make sure you call getAccessToken() every time you need to use the access token. If you're not using the SDK, make sure you refresh the access token with the refresh endpoint. `, - { expired_at_millis: expiredAt?.getTime() ?? null }, + { + expired_at_millis: expiredAt?.getTime() ?? null, + project_id: projectId ?? null, + user_id: userId ?? null, + refresh_token_id: refreshTokenId ?? null, + }, + ] as const, + (json: any) => [ + json.expired_at_millis ? new Date(json.expired_at_millis) : undefined, + json.project_id ?? undefined, + json.user_id ?? undefined, + json.refresh_token_id ?? undefined, ] as const, - (json: any) => [json.expired_at_millis ? new Date(json.expired_at_millis) : undefined] as const, ); const InvalidProjectForAccessToken = createKnownErrorConstructor( From 6673e63ee768106994d75b361fe9c662790b30a4 Mon Sep 17 00:00:00 2001 From: BilalG1 Date: Thu, 12 Feb 2026 16:52:07 -0800 Subject: [PATCH 25/65] fix payment rounding error (#1193) ## Summary by CodeRabbit * **Bug Fixes** * Improved pricing accuracy by implementing proper rounding in unit price calculations during checkout. This ensures correct cent-level precision in purchase computations, preventing potential rounding errors in transaction totals. --- apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx b/apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx index 1660e7492d..c01feb43dd 100644 --- a/apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx +++ b/apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx @@ -48,7 +48,7 @@ export default function PageClient({ code }: { code: string }) { if (!selectedPriceId || !data?.product?.prices) { return 0; } - return Number(data.product.prices[selectedPriceId].USD) * 100; + return Math.round(Number(data.product.prices[selectedPriceId].USD) * 100); }, [data, selectedPriceId]); const MAX_STRIPE_AMOUNT_CENTS = 999_999 * 100; From d09a180dfe800a471ca1fb8ea101fd0cdeef0d4d Mon Sep 17 00:00:00 2001 From: BilalG1 Date: Thu, 12 Feb 2026 16:52:20 -0800 Subject: [PATCH 26/65] clickhouse user sync (#1159) ## Summary by CodeRabbit * **New Features** * Real-time AI search with project-scoped analytics and dynamic query execution; streaming AI responses replace the placeholder flow. * External DB sync adds ClickHouse support: users sync, sync metadata tracking, tenancy-aware status, and per-mapping throttling. * AI assistant UI shows expandable tool-invocation results and streams via the real AI pipeline. * **Chores** * Dashboard dependencies and workspace exclusions updated; development OpenAI env var added; editor config flag toggled. * **Tests** * E2E coverage extended to validate ClickHouse user sync and analytics queries. --------- Co-authored-by: aadesh18 <110230993+aadesh18@users.noreply.github.com> Co-authored-by: Konsti Wohlwend --- .../e2e-source-of-truth-api-tests.yaml | 178 -------- .vscode/settings.json | 3 +- apps/backend/scripts/clickhouse-migrations.ts | 103 +++++ .../clickhouse/migrate-events/route.tsx | 227 ---------- .../internal/external-db-sync/status/route.ts | 225 +++++++++- .../app/api/latest/internal/metrics/route.tsx | 194 ++++----- apps/backend/src/lib/events.tsx | 86 +++- apps/backend/src/lib/external-db-sync.ts | 397 +++++++++++++++--- apps/backend/src/lib/tokens.tsx | 4 +- apps/dashboard/.env.development | 2 + apps/dashboard/package.json | 8 +- .../clickhouse-migration/page-client.tsx | 246 ----------- .../[projectId]/clickhouse-migration/page.tsx | 9 - apps/dashboard/src/app/api/ai-search/route.ts | 135 ++++-- .../src/components/commands/ask-ai.tsx | 274 ++++++++++-- .../endpoints/api/v1/analytics-events.test.ts | 73 ++++ .../endpoints/api/v1/analytics-query.test.ts | 21 +- .../api/v1/external-db-sync-basics.test.ts | 130 +++++- .../endpoints/api/v1/internal-metrics.test.ts | 16 +- .../tests/backend/performance/mock-users.sql | 78 +++- .../src/config/db-sync-mappings.ts | 113 +++++ packages/stack-shared/src/config/schema.ts | 11 +- .../src/interface/admin-interface.ts | 32 -- pnpm-lock.yaml | 151 +++++-- pnpm-workspace.yaml | 6 + 25 files changed, 1750 insertions(+), 972 deletions(-) delete mode 100644 .github/workflows/e2e-source-of-truth-api-tests.yaml delete mode 100644 apps/backend/src/app/api/latest/internal/clickhouse/migrate-events/route.tsx delete mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/clickhouse-migration/page-client.tsx delete mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/clickhouse-migration/page.tsx diff --git a/.github/workflows/e2e-source-of-truth-api-tests.yaml b/.github/workflows/e2e-source-of-truth-api-tests.yaml deleted file mode 100644 index 46f5cebfc5..0000000000 --- a/.github/workflows/e2e-source-of-truth-api-tests.yaml +++ /dev/null @@ -1,178 +0,0 @@ -name: Runs E2E API Tests with external source of truth - -on: - push: - branches: - - main - - dev - pull_request: - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }} - -jobs: - build: - runs-on: ubicloud-standard-8 - env: - NODE_ENV: test - STACK_ENABLE_HARDCODED_PASSKEY_CHALLENGE_FOR_TESTING: yes - STACK_ACCESS_TOKEN_EXPIRATION_TIME: 30m - STACK_OVERRIDE_SOURCE_OF_TRUTH: '{"type": "postgres", "connectionString": "postgres://postgres:PASSWORD-PLACEHOLDER--uqfEC1hmmv@localhost:8128/source-of-truth-db?schema=sot-schema"}' - STACK_TEST_SOURCE_OF_TRUTH: true - STACK_DATABASE_CONNECTION_STRING: "postgres://postgres:PASSWORD-PLACEHOLDER--uqfEC1hmmv@localhost:8128/stackframe" - STACK_EXTERNAL_DB_SYNC_MAX_DURATION_MS: "20000" - STACK_EXTERNAL_DB_SYNC_DIRECT: "false" - - strategy: - matrix: - node-version: [22.x] - - steps: - - uses: actions/checkout@v6 - - - name: Setup Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v6 - with: - node-version: ${{ matrix.node-version }} - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - - # Even just starting the Docker Compose as a daemon is slow because we have to download and build the images - # so, we run it in the background - - name: Start Docker Compose in background - uses: JarvusInnovations/background-action@v1.0.7 - with: - run: docker compose -f docker/dependencies/docker.compose.yaml up -d & - # we don't need to wait on anything, just need to start the daemon - wait-on: /dev/null - tail: true - wait-for: 3s - log-output-if: true - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Create .env.test.local file for apps/backend - run: cp apps/backend/.env.development apps/backend/.env.test.local - - - name: Create .env.test.local file for apps/dashboard - run: cp apps/dashboard/.env.development apps/dashboard/.env.test.local - - - name: Create .env.test.local file for apps/e2e - run: cp apps/e2e/.env.development apps/e2e/.env.test.local - - - name: Create .env.test.local file for docs - run: cp docs/.env.development docs/.env.test.local - - - name: Create .env.test.local file for examples/cjs-test - run: cp examples/cjs-test/.env.development examples/cjs-test/.env.test.local - - - name: Create .env.test.local file for examples/demo - run: cp examples/demo/.env.development examples/demo/.env.test.local - - - name: Create .env.test.local file for examples/docs-examples - run: cp examples/docs-examples/.env.development examples/docs-examples/.env.test.local - - - name: Create .env.test.local file for examples/e-commerce - run: cp examples/e-commerce/.env.development examples/e-commerce/.env.test.local - - - name: Create .env.test.local file for examples/middleware - run: cp examples/middleware/.env.development examples/middleware/.env.test.local - - - name: Create .env.test.local file for examples/supabase - run: cp examples/supabase/.env.development examples/supabase/.env.test.local - - - name: Create .env.test.local file for examples/convex - run: cp examples/convex/.env.development examples/convex/.env.test.local - - - name: Build - run: pnpm build - - - name: Wait on Postgres - run: pnpm run wait-until-postgres-is-ready:pg_isready - - - name: Wait on Inbucket - run: pnpx wait-on tcp:localhost:8129 - - - name: Wait on Svix - run: pnpx wait-on tcp:localhost:8113 - - - name: Wait on QStash - run: pnpx wait-on tcp:localhost:8125 - - - name: Create source-of-truth database and schema - run: | - psql postgres://postgres:PASSWORD-PLACEHOLDER--uqfEC1hmmv@localhost:8128/postgres -c "CREATE DATABASE \"source-of-truth-db\";" - psql postgres://postgres:PASSWORD-PLACEHOLDER--uqfEC1hmmv@localhost:8128/source-of-truth-db -c "CREATE SCHEMA \"sot-schema\";" - - - name: Initialize database - run: pnpm run db:init - - - name: Start stack-backend in background - uses: JarvusInnovations/background-action@v1.0.7 - with: - run: pnpm run start:backend --log-order=stream & - wait-on: | - http://localhost:8102 - tail: true - wait-for: 30s - log-output-if: true - - name: Start stack-dashboard in background - uses: JarvusInnovations/background-action@v1.0.7 - with: - run: pnpm run start:dashboard --log-order=stream & - wait-on: | - http://localhost:8101 - tail: true - wait-for: 30s - log-output-if: true - - name: Start mock-oauth-server in background - uses: JarvusInnovations/background-action@v1.0.7 - with: - run: pnpm run start:mock-oauth-server --log-order=stream & - wait-on: | - http://localhost:8102 - tail: true - wait-for: 30s - log-output-if: true - - name: Start run-email-queue in background - uses: JarvusInnovations/background-action@v1.0.7 - with: - run: pnpm -C apps/backend run run-email-queue --log-order=stream & - wait-on: | - http://localhost:8102 - tail: true - wait-for: 30s - log-output-if: true - - name: Start run-cron-jobs in background - uses: JarvusInnovations/background-action@v1.0.7 - with: - run: pnpm -C apps/backend run run-cron-jobs --log-order=stream & - wait-on: | - http://localhost:8102 - tail: true - wait-for: 30s - log-output-if: true - - - name: Wait 10 seconds - run: sleep 10 - - - name: Run tests - run: pnpm test run --exclude "**/external-db-sync*.test.ts" # external-db-sync does not support external sot - - - name: Run tests again (attempt 1) - if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' - run: pnpm test run --exclude "**/external-db-sync*.test.ts" - - - name: Run tests again (attempt 2) - if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' - run: pnpm test run --exclude "**/external-db-sync*.test.ts" - - - name: Verify data integrity - run: pnpm run verify-data-integrity --no-bail - - - name: Print Docker Compose logs - if: always() - run: docker compose -f docker/dependencies/docker.compose.yaml logs diff --git a/.vscode/settings.json b/.vscode/settings.json index 1fda6ee8b4..34a264f5da 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -174,5 +174,6 @@ "((?:| *\\*/| *!}| *--}}| *}}|(?= *(?:[^:]//|/\\*+| --- apps/backend/scripts/clickhouse-migrations.ts | 28 +++++++- .../app/api/latest/internal/metrics/route.tsx | 9 ++- apps/backend/src/lib/external-db-sync.ts | 3 + .../api/v1/external-db-sync-advanced.test.ts | 71 ++++++++++++++++++- 4 files changed, 105 insertions(+), 6 deletions(-) diff --git a/apps/backend/scripts/clickhouse-migrations.ts b/apps/backend/scripts/clickhouse-migrations.ts index 318c7d4e92..b83185d68b 100644 --- a/apps/backend/scripts/clickhouse-migrations.ts +++ b/apps/backend/scripts/clickhouse-migrations.ts @@ -17,6 +17,7 @@ export async function runClickhouseMigrations() { await client.exec({ query: USERS_TABLE_BASE_SQL }); await client.exec({ query: USERS_VIEW_SQL }); await client.exec({ query: TOKEN_REFRESH_EVENT_ROW_FORMAT_MUTATION_SQL }); + await client.exec({ query: SIGN_UP_RULE_TRIGGER_EVENT_ROW_FORMAT_MUTATION_SQL }); const queries = [ "REVOKE ALL PRIVILEGES ON *.* FROM limited_user;", "REVOKE ALL FROM limited_user;", @@ -93,6 +94,27 @@ WHERE event_type = '$token-refresh' AND JSONHas(toJSONString(data), 'refreshTokenId'); `; +// Normalizes legacy $sign-up-rule-trigger rows (camelCase JSON) to the new format: +// - Row identity stays in columns (project_id/branch_id) +// - data JSON becomes { project_id, branch_id, rule_id, action, email, auth_method, oauth_provider } (snake_case) +const SIGN_UP_RULE_TRIGGER_EVENT_ROW_FORMAT_MUTATION_SQL = ` +ALTER TABLE analytics_internal.events +UPDATE + data = CAST(concat( + '{', + '"project_id":', toJSONString(JSONExtractString(toJSONString(data), 'projectId')), ',', + '"branch_id":', toJSONString(JSONExtractString(toJSONString(data), 'branchId')), ',', + '"rule_id":', toJSONString(JSONExtractString(toJSONString(data), 'ruleId')), ',', + '"action":', toJSONString(JSONExtractString(toJSONString(data), 'action')), ',', + '"email":', toJSONString(JSONExtract(toJSONString(data), 'email', 'Nullable(String)')), ',', + '"auth_method":', toJSONString(JSONExtract(toJSONString(data), 'authMethod', 'Nullable(String)')), ',', + '"oauth_provider":', toJSONString(JSONExtract(toJSONString(data), 'oauthProvider', 'Nullable(String)')), + '}' + ) AS JSON) +WHERE event_type = '$sign-up-rule-trigger' + AND JSONHas(toJSONString(data), 'ruleId'); +`; + const USERS_TABLE_BASE_SQL = ` CREATE TABLE IF NOT EXISTS analytics_internal.users ( project_id String, @@ -103,9 +125,9 @@ CREATE TABLE IF NOT EXISTS analytics_internal.users ( primary_email Nullable(String), primary_email_verified UInt8, signed_up_at DateTime64(3, 'UTC'), - client_metadata JSON, - client_read_only_metadata JSON, - server_metadata JSON, + client_metadata String, + client_read_only_metadata String, + server_metadata String, is_anonymous UInt8, restricted_by_admin UInt8, restricted_by_admin_reason Nullable(String), diff --git a/apps/backend/src/app/api/latest/internal/metrics/route.tsx b/apps/backend/src/app/api/latest/internal/metrics/route.tsx index ccb1e90c9d..5c10e3a057 100644 --- a/apps/backend/src/app/api/latest/internal/metrics/route.tsx +++ b/apps/backend/src/app/api/latest/internal/metrics/route.tsx @@ -46,6 +46,12 @@ async function loadUsersByCountry(tenancy: Tenancy, prisma: PrismaClientTransact const userIds = users.map((user) => user.projectUserId); const scalingFactor = totalUsers > users.length ? totalUsers / users.length : 1; + // Build ClickHouse array literal inline in the query body (sent via POST) instead of + // passing as query_params (sent as URL params) to avoid the HTTP form field size limit + // when there are many user IDs. UUIDs contain only hex chars and dashes, but we escape + // single quotes for safety. + const userIdsArrayLiteral = `[${userIds.map(id => `'${id.replace(/'/g, "''")}'`).join(',')}]`; + const clickhouseClient = getClickhouseAdminClient(); const res = await clickhouseClient.query({ query: ` @@ -67,7 +73,7 @@ async function loadUsersByCountry(tenancy: Tenancy, prisma: PrismaClientTransact AND project_id = {projectId:String} AND branch_id = {branchId:String} AND user_id IS NOT NULL - AND has({userIds:Array(String)}, assumeNotNull(user_id)) + AND has(${userIdsArrayLiteral}, assumeNotNull(user_id)) ) WHERE cc IS NOT NULL AND ({includeAnonymous:UInt8} = 1 OR is_anonymous = 0) @@ -80,7 +86,6 @@ async function loadUsersByCountry(tenancy: Tenancy, prisma: PrismaClientTransact query_params: { projectId: tenancy.project.id, branchId: tenancy.branchId, - userIds, includeAnonymous: includeAnonymous ? 1 : 0, }, format: "JSONEachRow", diff --git a/apps/backend/src/lib/external-db-sync.ts b/apps/backend/src/lib/external-db-sync.ts index a9b57db045..7a78dfbd0a 100644 --- a/apps/backend/src/lib/external-db-sync.ts +++ b/apps/backend/src/lib/external-db-sync.ts @@ -430,6 +430,9 @@ async function pushRowsToClickhouse( return { ...rest, sync_sequence_id: sequenceId, + client_metadata: JSON.stringify(rest.client_metadata), + client_read_only_metadata: JSON.stringify(rest.client_read_only_metadata), + server_metadata: JSON.stringify(rest.server_metadata), primary_email_verified: normalizeClickhouseBoolean(rest.primary_email_verified, "primary_email_verified"), is_anonymous: normalizeClickhouseBoolean(rest.is_anonymous, "is_anonymous"), restricted_by_admin: normalizeClickhouseBoolean(rest.restricted_by_admin, "restricted_by_admin"), diff --git a/apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-advanced.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-advanced.test.ts index ebedb7b846..7b78360990 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-advanced.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-advanced.test.ts @@ -1,7 +1,9 @@ +import { wait } from "@stackframe/stack-shared/dist/utils/promises"; +import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; import { Client } from 'pg'; import { afterAll, beforeAll, describe, expect } from 'vitest'; import { test } from '../../../../helpers'; -import { InternalApiKey, User, backendContext, niceBackendFetch } from '../../../backend-helpers'; +import { InternalApiKey, Project, User, backendContext, niceBackendFetch } from '../../../backend-helpers'; import { HIGH_VOLUME_TIMEOUT, POSTGRES_HOST, @@ -19,6 +21,38 @@ import { const COMPLEX_SEQUENCE_TIMEOUT = TEST_TIMEOUT * 2 + 30_000; +async function runQueryForCurrentProject(body: { query: string, params?: Record, timeout_ms?: number }) { + return await niceBackendFetch("/api/v1/internal/analytics/query", { + method: "POST", + accessType: "admin", + body, + }); +} + +async function waitForClickhouseUser(email: string, expectedDisplayName: string) { + const timeoutMs = 180_000; + const intervalMs = 2_000; + const start = performance.now(); + + while (performance.now() - start < timeoutMs) { + const response = await runQueryForCurrentProject({ + query: "SELECT primary_email, display_name FROM users WHERE primary_email = {email:String}", + params: { email }, + }); + if ( + response.status === 200 + && Array.isArray(response.body?.result) + && response.body.result.length === 1 + && response.body.result[0]?.display_name === expectedDisplayName + ) { + return response; + } + await wait(intervalMs); + } + + throw new StackAssertionError(`Timed out waiting for ClickHouse user ${email} to sync.`); +} + describe.sequential('External DB Sync - Advanced Tests', () => { let dbManager: TestDbManager; const createProjectWithExternalDb = ( @@ -1126,4 +1160,39 @@ $$;`); await internalClient.end(); } }, HIGH_VOLUME_TIMEOUT); + + /** + * What it does: + * - Configures a project with a bad postgres connection string (simulating postgres being down). + * - Creates a user and verifies it still syncs to ClickHouse despite the postgres failure. + * - Then configures a separate project with a valid postgres DB and verifies postgres sync works + * even though ClickHouse sync runs independently in the same cycle. + * + * Why it matters: + * - Proves that ClickHouse and postgres sync targets are independent: a failure in one + * does not block the other from completing successfully. + */ + test('Cross-DB resilience: postgres down does not block ClickHouse sync', async () => { + const badConnectionString = 'postgresql://invalid:invalid@invalid:5432/invalid'; + + // Create a project with only a bad postgres DB — ClickHouse syncs automatically via env var + await createProjectWithExternalDb({ + bad_pg: { + type: 'postgres', + connectionString: badConnectionString, + }, + }); + + const email = 'cross-db-resilience@example.com'; + const user = await User.create({ primary_email: email }); + await niceBackendFetch(`/api/v1/users/${user.userId}`, { + accessType: 'admin', + method: 'PATCH', + body: { display_name: 'Cross DB User' }, + }); + + // ClickHouse should still receive the data even though postgres sync fails + await waitForClickhouseUser(email, 'Cross DB User'); + + }, TEST_TIMEOUT); }); From 8d02c042cefac05abea69b302700b4da96c1ae4d Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Fri, 13 Feb 2026 13:13:08 -0800 Subject: [PATCH 32/65] Reduce number of console messages from sequencer --- .../app/api/latest/internal/external-db-sync/sequencer/route.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/backend/src/app/api/latest/internal/external-db-sync/sequencer/route.ts b/apps/backend/src/app/api/latest/internal/external-db-sync/sequencer/route.ts index 9b5be72ecf..c7808fb53b 100644 --- a/apps/backend/src/app/api/latest/internal/external-db-sync/sequencer/route.ts +++ b/apps/backend/src/app/api/latest/internal/external-db-sync/sequencer/route.ts @@ -214,7 +214,6 @@ export const GET = createSmartRouteHandler({ try { const didUpdate = await backfillSequenceIds(batchSize); iterationSpan.setAttribute("stack.external-db-sync.did-update", didUpdate); - console.log(`[Sequencer] Backfilled ${didUpdate ? "some" : "no"} sequence IDs`); } catch (error) { iterationSpan.setAttribute("stack.external-db-sync.iteration-error", true); captureError( From d3f736fc41c661f905fc44d8189490c77792e371 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Fri, 13 Feb 2026 14:14:37 -0800 Subject: [PATCH 33/65] Add console warning for expired access token --- apps/backend/src/lib/tokens.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/backend/src/lib/tokens.tsx b/apps/backend/src/lib/tokens.tsx index 2e55ae3e3d..71b201f673 100644 --- a/apps/backend/src/lib/tokens.tsx +++ b/apps/backend/src/lib/tokens.tsx @@ -103,12 +103,14 @@ export async function decodeAccessToken(accessToken: string, { allowAnonymous, a }); } catch (error) { if (error instanceof JWTExpired) { - return Result.error(new KnownErrors.AccessTokenExpired( + const error = new KnownErrors.AccessTokenExpired( decoded?.exp ? new Date(decoded.exp * 1000) : undefined, decoded?.aud?.toString().split(":")[0], decoded?.sub ?? undefined, (decoded?.refresh_token_id ?? decoded?.refreshTokenId) as string | undefined, - )); + ); + console.warn(`[Token decode] Access token expired for project ${decoded?.aud?.toString().split(":")[0]}, user ${decoded?.sub}. This is most likely not an issue, but if it happens frequently, it may be a sign of a misconfiguration.`, error); + return Result.error(error); } else if (error instanceof JOSEError) { console.warn("Unparsable access token. This might be a user error, but if it happens frequently, it's a sign of a misconfiguration.", { accessToken, error }); return Result.error(new KnownErrors.UnparsableAccessToken()); From 5b9cef6b13846634edf4aff34d1ddd916a9dd8b9 Mon Sep 17 00:00:00 2001 From: BilalG1 Date: Fri, 13 Feb 2026 14:25:07 -0800 Subject: [PATCH 34/65] Internal metrics fix (#1197) --- .../app/api/latest/internal/metrics/route.tsx | 33 +------------------ 1 file changed, 1 insertion(+), 32 deletions(-) diff --git a/apps/backend/src/app/api/latest/internal/metrics/route.tsx b/apps/backend/src/app/api/latest/internal/metrics/route.tsx index 5c10e3a057..85fb905140 100644 --- a/apps/backend/src/app/api/latest/internal/metrics/route.tsx +++ b/apps/backend/src/app/api/latest/internal/metrics/route.tsx @@ -23,35 +23,6 @@ function formatClickhouseDateTimeParam(date: Date): string { } async function loadUsersByCountry(tenancy: Tenancy, prisma: PrismaClientTransaction, includeAnonymous: boolean = false): Promise> { - const totalUsers = await prisma.projectUser.count({ - where: { - tenancyId: tenancy.id, - ...(includeAnonymous ? {} : { isAnonymous: false }), - }, - }); - const users = await prisma.projectUser.findMany({ - where: { - tenancyId: tenancy.id, - ...(includeAnonymous ? {} : { isAnonymous: false }), - }, - select: { projectUserId: true }, - orderBy: { projectUserId: "asc" }, - take: Math.min(totalUsers, MAX_USERS_FOR_COUNTRY_SAMPLE), - }); - - if (users.length === 0) { - return {}; - } - - const userIds = users.map((user) => user.projectUserId); - const scalingFactor = totalUsers > users.length ? totalUsers / users.length : 1; - - // Build ClickHouse array literal inline in the query body (sent via POST) instead of - // passing as query_params (sent as URL params) to avoid the HTTP form field size limit - // when there are many user IDs. UUIDs contain only hex chars and dashes, but we escape - // single quotes for safety. - const userIdsArrayLiteral = `[${userIds.map(id => `'${id.replace(/'/g, "''")}'`).join(',')}]`; - const clickhouseClient = getClickhouseAdminClient(); const res = await clickhouseClient.query({ query: ` @@ -73,7 +44,6 @@ async function loadUsersByCountry(tenancy: Tenancy, prisma: PrismaClientTransact AND project_id = {projectId:String} AND branch_id = {branchId:String} AND user_id IS NOT NULL - AND has(${userIdsArrayLiteral}, assumeNotNull(user_id)) ) WHERE cc IS NOT NULL AND ({includeAnonymous:UInt8} = 1 OR is_anonymous = 0) @@ -98,8 +68,7 @@ async function loadUsersByCountry(tenancy: Tenancy, prisma: PrismaClientTransact return null; } const count = Number(userCount); - const estimatedCount = scalingFactor === 1 ? count : Math.round(count * scalingFactor); - return [country_code, estimatedCount] as [string, number]; + return [country_code, count] as [string, number]; }) .filter((entry): entry is [string, number] => entry !== null) ); From 4c6a89f7749a2198da7c6e2bae33eeb7acfebcdf Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Fri, 13 Feb 2026 14:47:27 -0800 Subject: [PATCH 35/65] qstash dedup key --- .../migration.sql | 11 ++++++++++ apps/backend/prisma/schema.prisma | 3 ++- .../internal/external-db-sync/poller/route.ts | 22 ++++++++++++++++++- .../backend/src/lib/external-db-sync-queue.ts | 5 +++-- 4 files changed, 37 insertions(+), 4 deletions(-) create mode 100644 apps/backend/prisma/migrations/20260213000000_outgoing_request_partial_dedup_index/migration.sql diff --git a/apps/backend/prisma/migrations/20260213000000_outgoing_request_partial_dedup_index/migration.sql b/apps/backend/prisma/migrations/20260213000000_outgoing_request_partial_dedup_index/migration.sql new file mode 100644 index 0000000000..a0eb5d5051 --- /dev/null +++ b/apps/backend/prisma/migrations/20260213000000_outgoing_request_partial_dedup_index/migration.sql @@ -0,0 +1,11 @@ +-- Drop the existing full unique constraint on deduplicationKey +ALTER TABLE "OutgoingRequest" DROP CONSTRAINT "OutgoingRequest_deduplicationKey_key"; + +-- SPLIT_STATEMENT_SENTINEL +-- Create a partial unique index that only enforces uniqueness for rows +-- where startedFulfillingAt IS NULL (i.e. pending/unclaimed requests). +-- This allows duplicate deduplicationKey values for rows that have already +-- been claimed for processing. +CREATE UNIQUE INDEX "OutgoingRequest_deduplicationKey_pending_key" + ON "OutgoingRequest" ("deduplicationKey") + WHERE "startedFulfillingAt" IS NULL; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 46e39119eb..21b781e81a 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -1090,7 +1090,8 @@ model OutgoingRequest { startedFulfillingAt DateTime? deduplicationKey String? - @@unique([deduplicationKey]) + // Partial unique index on deduplicationKey WHERE startedFulfillingAt IS NULL + // is created in a custom migration (not expressible in Prisma schema) @@index([startedFulfillingAt, createdAt]) @@index([startedFulfillingAt, deduplicationKey]) } diff --git a/apps/backend/src/app/api/latest/internal/external-db-sync/poller/route.ts b/apps/backend/src/app/api/latest/internal/external-db-sync/poller/route.ts index 071ad4c671..1b94af32b5 100644 --- a/apps/backend/src/app/api/latest/internal/external-db-sync/poller/route.ts +++ b/apps/backend/src/app/api/latest/internal/external-db-sync/poller/route.ts @@ -20,6 +20,7 @@ const DEFAULT_MAX_DURATION_MS = 3 * 60 * 1000; const DIRECT_SYNC_ENV = "STACK_EXTERNAL_DB_SYNC_DIRECT"; const POLLER_CLAIM_LIMIT_ENV = "STACK_EXTERNAL_DB_SYNC_POLL_CLAIM_LIMIT"; const DEFAULT_POLL_CLAIM_LIMIT = 1000; +const STALE_REQUEST_THRESHOLD_MS = 60 * 1000; function parseMaxDurationMs(value: string | undefined): number { if (!value) return DEFAULT_MAX_DURATION_MS; @@ -90,7 +91,6 @@ export const GET = createSmartRouteHandler({ span.setAttribute("stack.external-db-sync.max-duration-ms", maxDurationMs); span.setAttribute("stack.external-db-sync.poll-interval-ms", pollIntervalMs); span.setAttribute("stack.external-db-sync.poller-claim-limit", pollerClaimLimit); - span.setAttribute("stack.external-db-sync.direct-sync", directSyncEnabled()); span.setAttribute("stack.external-db-sync.stale-claim-minutes", staleClaimIntervalMinutes); let totalRequestsProcessed = 0; @@ -172,11 +172,13 @@ export const GET = createSmartRouteHandler({ } const flowControl = options.flowControl as UpstashRequest["flowControl"]; + const deduplicationId = options.deduplicationId as UpstashRequest["deduplicationId"]; return { url: fullUrl, body: options.body, ...(flowControl ? { flowControl } : {}), + ...(deduplicationId ? { deduplicationId } : {}) }; } @@ -243,6 +245,24 @@ export const GET = createSmartRouteHandler({ return { stopReason: "disabled", processed: 0 }; } + const staleRequests = await globalPrismaClient.$queryRaw<{ id: string, startedFulfillingAt: Date }[]>` + SELECT "id", "startedFulfillingAt" + FROM "OutgoingRequest" + WHERE "startedFulfillingAt" IS NOT NULL + AND "startedFulfillingAt" < NOW() - ${STALE_REQUEST_THRESHOLD_MS} * INTERVAL '1 millisecond' + LIMIT 10 + `; + iterationSpan.setAttribute("stack.external-db-sync.stale-count", staleRequests.length); + if (staleRequests.length > 0) { + captureError( + "poller-stale-outgoing-requests", + new StackAssertionError( + `Found ${staleRequests.length} outgoing request(s) with startedFulfillingAt older than ${STALE_REQUEST_THRESHOLD_MS}ms`, + { staleRequestIds: staleRequests.map(r => r.id) }, + ), + ); + } + const pendingRequests = await claimPendingRequests(); iterationSpan.setAttribute("stack.external-db-sync.pending-count", pendingRequests.length); diff --git a/apps/backend/src/lib/external-db-sync-queue.ts b/apps/backend/src/lib/external-db-sync-queue.ts index ab666893eb..047eaa9d14 100644 --- a/apps/backend/src/lib/external-db-sync-queue.ts +++ b/apps/backend/src/lib/external-db-sync-queue.ts @@ -33,11 +33,12 @@ export async function enqueueExternalDbSyncBatch(tenancyIds: string[]): Promise< json_build_object( 'url', '/api/latest/internal/external-db-sync/sync-engine', 'body', json_build_object('tenancyId', t.tenancy_id), - 'flowControl', json_build_object('key', 'sentinel-sync-key', 'parallelism', 20) + 'flowControl', json_build_object('key', 'sentinel-sync-key', 'parallelism', 20), + 'deduplicationId', t.tenancy_id ), NULL, 'sentinel-sync-key-' || t.tenancy_id FROM unnest(${tenancyIds}::uuid[]) AS t(tenancy_id) - ON CONFLICT ("deduplicationKey") DO NOTHING + ON CONFLICT ("deduplicationKey") WHERE "startedFulfillingAt" IS NULL DO NOTHING `; } From f2bf1d6113942f3b503ba718ae37e9a7331b1881 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Fri, 13 Feb 2026 18:09:18 -0800 Subject: [PATCH 36/65] Better null checks in token fetching logic --- packages/stack-shared/src/sessions.ts | 34 +++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/packages/stack-shared/src/sessions.ts b/packages/stack-shared/src/sessions.ts index a07ef5cc8b..dd235c3fe9 100644 --- a/packages/stack-shared/src/sessions.ts +++ b/packages/stack-shared/src/sessions.ts @@ -185,10 +185,10 @@ export class InternalSession { const newTokens = await this.fetchNewTokens(); const expiresInMillis = newTokens?.accessToken.expiresInMillis; const issuedMillisAgo = newTokens?.accessToken.issuedMillisAgo; - if (expiresInMillis && expiresInMillis < minMillisUntilExpiration) { + if (expiresInMillis !== undefined && expiresInMillis < minMillisUntilExpiration) { throw new StackAssertionError(`Required access token expiry ${minMillisUntilExpiration}ms is too long; access tokens are too short when they're generated (${expiresInMillis}ms)`); } - if (maxMillisSinceIssued !== null && issuedMillisAgo && issuedMillisAgo > maxMillisSinceIssued) { + if (maxMillisSinceIssued !== null && issuedMillisAgo !== undefined && issuedMillisAgo > maxMillisSinceIssued) { throw new StackAssertionError(`Required access token issuance ${maxMillisSinceIssued}ms is too short; access token issuance is too slow (${issuedMillisAgo}ms)`); } return newTokens; @@ -309,3 +309,33 @@ export class InternalSession { this._refreshPromise = refreshPromise; } } + +import.meta.vitest?.test("getOrFetchLikelyValidTokens throws when freshly fetched token is already expired", async ({ expect }) => { + const nowSeconds = Math.floor(Date.now() / 1000); + const token = await new jose.SignJWT({ + sub: "test-user-id", + iat: nowSeconds - 60 * 60, + exp: nowSeconds - 30 * 60, + iss: "https://issuer.example", + aud: "project-id", + project_id: "project-id", + branch_id: "main", + refresh_token_id: "refresh-token-id", + role: "authenticated", + name: "Test User", + email: "test@example.com", + email_verified: true, + selected_team_id: null, + is_anonymous: false, + is_restricted: false, + restricted_reason: null, + }).setProtectedHeader({ alg: "HS256" }).sign(new TextEncoder().encode("secret")); + + const session = new InternalSession({ + refreshAccessTokenCallback: async () => AccessToken.createIfValid(token), + refreshToken: "refresh-token", + accessToken: null, + }); + + await expect(session.getOrFetchLikelyValidTokens(20_000, 75_000)).rejects.toThrow(StackAssertionError); +}); From 7fc1107304ac0f3b208c354b3783d497021f9eda Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Fri, 13 Feb 2026 18:34:23 -0800 Subject: [PATCH 37/65] Update AGENTS.md --- AGENTS.md | 1 + packages/stack-shared/src/sessions.ts | 30 --------------------------- 2 files changed, 1 insertion(+), 30 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index dbcd210420..e102ab9df0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -99,6 +99,7 @@ To see all development ports, refer to the index.html of `apps/dev-launchpad/pub - Do NOT use `as`/`any`/type casts or anything else like that to bypass the type system unless you specifically asked the user about it. Most of the time a place where you would use type casts is not one where you actually need them. Avoid wherever possible. - When writing database migration files, assume that we have >1,000,000 rows in every table (unless otherwise specified). This means you may have to use CONDITIONALLY_REPEAT_MIGRATION_SENTINEL to avoid running the migration and things like concurrent index builds; see the existing migrations for examples. - **When building frontend code, always carefully deal with loading and error states.** Be very explicit with these; some components make this easy, eg. the button onClick already takes an async callback for loading state, but make sure this is done everywhere, and make sure errors are NEVER just silently swallowed. +- Unless very clearly equivalent from types, prefer explicit null/undefinedness checks over boolean checks, eg. `foo == null` instead of `!foo`. ### Code-related - Use ES6 maps instead of records wherever you can. diff --git a/packages/stack-shared/src/sessions.ts b/packages/stack-shared/src/sessions.ts index dd235c3fe9..0793d684fa 100644 --- a/packages/stack-shared/src/sessions.ts +++ b/packages/stack-shared/src/sessions.ts @@ -309,33 +309,3 @@ export class InternalSession { this._refreshPromise = refreshPromise; } } - -import.meta.vitest?.test("getOrFetchLikelyValidTokens throws when freshly fetched token is already expired", async ({ expect }) => { - const nowSeconds = Math.floor(Date.now() / 1000); - const token = await new jose.SignJWT({ - sub: "test-user-id", - iat: nowSeconds - 60 * 60, - exp: nowSeconds - 30 * 60, - iss: "https://issuer.example", - aud: "project-id", - project_id: "project-id", - branch_id: "main", - refresh_token_id: "refresh-token-id", - role: "authenticated", - name: "Test User", - email: "test@example.com", - email_verified: true, - selected_team_id: null, - is_anonymous: false, - is_restricted: false, - restricted_reason: null, - }).setProtectedHeader({ alg: "HS256" }).sign(new TextEncoder().encode("secret")); - - const session = new InternalSession({ - refreshAccessTokenCallback: async () => AccessToken.createIfValid(token), - refreshToken: "refresh-token", - accessToken: null, - }); - - await expect(session.getOrFetchLikelyValidTokens(20_000, 75_000)).rejects.toThrow(StackAssertionError); -}); From 6dd8fac410909b1534a48d5eb5d8e2f5d9acb671 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Sun, 15 Feb 2026 17:21:46 -0800 Subject: [PATCH 38/65] Improve sign-up rule error descriptions --- packages/stack-shared/src/known-errors.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/stack-shared/src/known-errors.tsx b/packages/stack-shared/src/known-errors.tsx index d4358b9954..0118ea748b 100644 --- a/packages/stack-shared/src/known-errors.tsx +++ b/packages/stack-shared/src/known-errors.tsx @@ -84,7 +84,7 @@ export abstract class KnownError extends StatusError { } } - throw new Error(`Unknown KnownError code. You may need to update your version of Stack to see more detailed information. ${json.code}: ${json.message}`); + throw new Error(`An error occurred. Please update your version of the Stack Auth SDK. ${json.code}: ${json.message}`); } } @@ -731,9 +731,9 @@ const SignUpRejected = createKnownErrorConstructor( "SIGN_UP_REJECTED", (message?: string) => [ 403, - message ?? "Your sign up was rejected. Please contact us for more information.", + message ?? "Your sign up was rejected by an administrator's sign-up rule.", { - message: message ?? "Your sign up was rejected. Please contact us for more information.", + message: message ?? "Your sign up was rejected by an administrator's sign-up rule.", }, ] as const, (json: any) => [json.message] as const, From 907a98320a6e6d5fe73eb4d5b3da28db198f4b66 Mon Sep 17 00:00:00 2001 From: BilalG1 Date: Mon, 16 Feb 2026 11:30:38 -0800 Subject: [PATCH 39/65] Clickhouse sync fixing (#1198) --- .../db-migration-backwards-compatibility.yaml | 3 +++ .github/workflows/e2e-api-tests.yaml | 3 +++ .../e2e-custom-base-port-api-tests.yaml | 3 +++ apps/backend/scripts/clickhouse-migrations.ts | 26 +++++++++---------- .../internal/external-db-sync/poller/route.ts | 2 -- .../backend/src/lib/external-db-sync-queue.ts | 3 +-- apps/backend/src/lib/external-db-sync.ts | 6 +++++ .../api/v1/auth/sign-up-rules.test.ts | 4 +-- package.json | 3 ++- .../src/config/db-sync-mappings.ts | 6 ++--- 10 files changed, 36 insertions(+), 23 deletions(-) diff --git a/.github/workflows/db-migration-backwards-compatibility.yaml b/.github/workflows/db-migration-backwards-compatibility.yaml index c09ce95553..e9700c67f1 100644 --- a/.github/workflows/db-migration-backwards-compatibility.yaml +++ b/.github/workflows/db-migration-backwards-compatibility.yaml @@ -150,6 +150,9 @@ jobs: - name: Wait on Svix run: pnpx wait-on tcp:localhost:8113 + - name: Wait on ClickHouse + run: pnpx wait-on http://localhost:8136/ping + - name: Initialize database run: pnpm run db:init diff --git a/.github/workflows/e2e-api-tests.yaml b/.github/workflows/e2e-api-tests.yaml index 3d33c11066..7f3cfd4c12 100644 --- a/.github/workflows/e2e-api-tests.yaml +++ b/.github/workflows/e2e-api-tests.yaml @@ -106,6 +106,9 @@ jobs: - name: Wait on QStash run: pnpx wait-on tcp:localhost:8125 + - name: Wait on ClickHouse + run: pnpx wait-on http://localhost:8136/ping + - name: Initialize database run: pnpm run db:init diff --git a/.github/workflows/e2e-custom-base-port-api-tests.yaml b/.github/workflows/e2e-custom-base-port-api-tests.yaml index d458b0653d..4e3a0acaa6 100644 --- a/.github/workflows/e2e-custom-base-port-api-tests.yaml +++ b/.github/workflows/e2e-custom-base-port-api-tests.yaml @@ -100,6 +100,9 @@ jobs: - name: Wait on QStash run: pnpx wait-on tcp:localhost:6725 + - name: Wait on ClickHouse + run: pnpx wait-on http://localhost:6736/ping + - name: Initialize database run: pnpm run db:init diff --git a/apps/backend/scripts/clickhouse-migrations.ts b/apps/backend/scripts/clickhouse-migrations.ts index b83185d68b..29f5918498 100644 --- a/apps/backend/scripts/clickhouse-migrations.ts +++ b/apps/backend/scripts/clickhouse-migrations.ts @@ -70,28 +70,28 @@ ALTER TABLE analytics_internal.events UPDATE data = CAST(concat( '{', - '\"refresh_token_id\":', toJSONString(JSONExtractString(toJSONString(data), 'refreshTokenId')), ',', - '\"is_anonymous\":', toJSONString(JSONExtract(toJSONString(data), 'isAnonymous', 'Bool')), ',', - '\"ip_info\":', if( - JSONExtractString(toJSONString(data), 'ipInfo.ip') = '', + '"refresh_token_id":', toJSONString(data.refreshTokenId::String), ',', + '"is_anonymous":', if(ifNull(data.isAnonymous::Nullable(Bool), false), 'true', 'false'), ',', + '"ip_info":', if( + isNull(data.ipInfo.ip::Nullable(String)), 'null', concat( '{', - '\"ip\":', toJSONString(JSONExtractString(toJSONString(data), 'ipInfo.ip')), ',', - '\"is_trusted\":', toJSONString(JSONExtract(toJSONString(data), 'ipInfo.isTrusted', 'Bool')), ',', - '\"country_code\":', toJSONString(JSONExtract(toJSONString(data), 'ipInfo.countryCode', 'Nullable(String)')), ',', - '\"region_code\":', toJSONString(JSONExtract(toJSONString(data), 'ipInfo.regionCode', 'Nullable(String)')), ',', - '\"city_name\":', toJSONString(JSONExtract(toJSONString(data), 'ipInfo.cityName', 'Nullable(String)')), ',', - '\"latitude\":', toJSONString(JSONExtract(toJSONString(data), 'ipInfo.latitude', 'Nullable(Float64)')), ',', - '\"longitude\":', toJSONString(JSONExtract(toJSONString(data), 'ipInfo.longitude', 'Nullable(Float64)')), ',', - '\"tz_identifier\":', toJSONString(JSONExtract(toJSONString(data), 'ipInfo.tzIdentifier', 'Nullable(String)')), + '"ip":', toJSONString(data.ipInfo.ip::String), ',', + '"is_trusted":', if(ifNull(data.ipInfo.isTrusted::Nullable(Bool), false), 'true', 'false'), ',', + '"country_code":', if(isNull(data.ipInfo.countryCode::Nullable(String)), 'null', toJSONString(data.ipInfo.countryCode::String)), ',', + '"region_code":', if(isNull(data.ipInfo.regionCode::Nullable(String)), 'null', toJSONString(data.ipInfo.regionCode::String)), ',', + '"city_name":', if(isNull(data.ipInfo.cityName::Nullable(String)), 'null', toJSONString(data.ipInfo.cityName::String)), ',', + '"latitude":', if(isNull(data.ipInfo.latitude::Nullable(Float64)), 'null', toString(data.ipInfo.latitude::Float64)), ',', + '"longitude":', if(isNull(data.ipInfo.longitude::Nullable(Float64)), 'null', toString(data.ipInfo.longitude::Float64)), ',', + '"tz_identifier":', if(isNull(data.ipInfo.tzIdentifier::Nullable(String)), 'null', toJSONString(data.ipInfo.tzIdentifier::String)), '}' ) ), '}' ) AS JSON) WHERE event_type = '$token-refresh' - AND JSONHas(toJSONString(data), 'refreshTokenId'); + AND data.refreshTokenId::Nullable(String) IS NOT NULL; `; // Normalizes legacy $sign-up-rule-trigger rows (camelCase JSON) to the new format: diff --git a/apps/backend/src/app/api/latest/internal/external-db-sync/poller/route.ts b/apps/backend/src/app/api/latest/internal/external-db-sync/poller/route.ts index 1b94af32b5..d37ef118d2 100644 --- a/apps/backend/src/app/api/latest/internal/external-db-sync/poller/route.ts +++ b/apps/backend/src/app/api/latest/internal/external-db-sync/poller/route.ts @@ -172,13 +172,11 @@ export const GET = createSmartRouteHandler({ } const flowControl = options.flowControl as UpstashRequest["flowControl"]; - const deduplicationId = options.deduplicationId as UpstashRequest["deduplicationId"]; return { url: fullUrl, body: options.body, ...(flowControl ? { flowControl } : {}), - ...(deduplicationId ? { deduplicationId } : {}) }; } diff --git a/apps/backend/src/lib/external-db-sync-queue.ts b/apps/backend/src/lib/external-db-sync-queue.ts index 047eaa9d14..93593acedf 100644 --- a/apps/backend/src/lib/external-db-sync-queue.ts +++ b/apps/backend/src/lib/external-db-sync-queue.ts @@ -33,8 +33,7 @@ export async function enqueueExternalDbSyncBatch(tenancyIds: string[]): Promise< json_build_object( 'url', '/api/latest/internal/external-db-sync/sync-engine', 'body', json_build_object('tenancyId', t.tenancy_id), - 'flowControl', json_build_object('key', 'sentinel-sync-key', 'parallelism', 20), - 'deduplicationId', t.tenancy_id + 'flowControl', json_build_object('key', 'sentinel-sync-key', 'parallelism', 20) ), NULL, 'sentinel-sync-key-' || t.tenancy_id diff --git a/apps/backend/src/lib/external-db-sync.ts b/apps/backend/src/lib/external-db-sync.ts index 7a78dfbd0a..62ac6536bd 100644 --- a/apps/backend/src/lib/external-db-sync.ts +++ b/apps/backend/src/lib/external-db-sync.ts @@ -566,6 +566,9 @@ async function syncPostgresMapping( if (rows.length === 0) { break; } + if (rows.length > 1) { + console.log("db-sync-postgres: more than 1 row returned from source db fetch", { tenancyId, numRows: rows.length }); + } await pushRowsToExternalDb( externalClient, @@ -644,6 +647,9 @@ async function syncClickhouseMapping( if (rows.length === 0) { break; } + if (rows.length > 1) { + console.log("db-sync-clickhouse: more than 1 row returned from source db fetch", { tenancyId, numRows: rows.length }); + } await pushRowsToClickhouse( client, diff --git a/apps/e2e/tests/backend/endpoints/api/v1/auth/sign-up-rules.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/auth/sign-up-rules.test.ts index 9d22c004d6..0c8c1cb5db 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/auth/sign-up-rules.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/auth/sign-up-rules.test.ts @@ -266,8 +266,8 @@ describe("sign-up rules", () => { "status": 403, "body": { "code": "SIGN_UP_REJECTED", - "details": { "message": "Your sign up was rejected. Please contact us for more information." }, - "error": "Your sign up was rejected. Please contact us for more information.", + "details": { "message": "Your sign up was rejected by an administrator's sign-up rule." }, + "error": "Your sign up was rejected by an administrator's sign-up rule.", }, "headers": Headers { "x-stack-known-error": "SIGN_UP_REJECTED", diff --git a/package.json b/package.json index 3c625515d5..6ed82b4f40 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,8 @@ "stop-deps": "POSTGRES_DELAY_MS=0 pnpm run deps-compose kill && POSTGRES_DELAY_MS=0 pnpm run deps-compose down -v", "wait-until-postgres-is-ready:pg_isready": "until pg_isready -h localhost -p ${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}28 && pg_isready -h localhost -p ${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}34; do sleep 1; done", "wait-until-postgres-is-ready": "command -v pg_isready >/dev/null 2>&1 && pnpm run wait-until-postgres-is-ready:pg_isready || sleep 10 # not everyone has pg_isready installed, so we fallback to sleeping", - "start-deps:no-delay": "pnpm pre && pnpm run deps-compose up --detach --build && pnpm run wait-until-postgres-is-ready && pnpm run db:init && echo \"\\nDependencies started in the background as Docker containers. 'pnpm run stop-deps' to stop them\"n", + "wait-until-clickhouse-is-ready": "pnpx wait-on http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}36/ping", + "start-deps:no-delay": "pnpm pre && pnpm run deps-compose up --detach --build && pnpm run wait-until-postgres-is-ready && pnpm run wait-until-clickhouse-is-ready && pnpm run db:init && echo \"\\nDependencies started in the background as Docker containers. 'pnpm run stop-deps' to stop them\"n", "start-deps": "POSTGRES_DELAY_MS=${POSTGRES_DELAY_MS:-0} pnpm run start-deps:no-delay", "restart-deps": "pnpm pre && pnpm run stop-deps && pnpm run start-deps", "restart-deps:no-delay": "pnpm pre && pnpm run stop-deps && pnpm run start-deps:no-delay", diff --git a/packages/stack-shared/src/config/db-sync-mappings.ts b/packages/stack-shared/src/config/db-sync-mappings.ts index 2a6d2e1abd..65e839446c 100644 --- a/packages/stack-shared/src/config/db-sync-mappings.ts +++ b/packages/stack-shared/src/config/db-sync-mappings.ts @@ -35,9 +35,9 @@ export const DEFAULT_DB_SYNC_MAPPINGS = { primary_email Nullable(String), primary_email_verified UInt8, signed_up_at DateTime64(3, 'UTC'), - client_metadata JSON, - client_read_only_metadata JSON, - server_metadata JSON, + client_metadata String, + client_read_only_metadata String, + server_metadata String, is_anonymous UInt8, restricted_by_admin UInt8, restricted_by_admin_reason Nullable(String), From d3192854039bcb7dd812336bef925e6062c71c2f Mon Sep 17 00:00:00 2001 From: Konsti Wohlwend Date: Mon, 16 Feb 2026 11:39:21 -0800 Subject: [PATCH 40/65] Queries view (#1145) --- .cursor/commands/pr-comments-review.md | 1 + AGENTS.md | 5 +- .../migration.sql | 123 +++ apps/backend/scripts/db-migrations.ts | 5 +- .../override/[level]/reset-keys/route.tsx | 55 ++ .../config/override/[level]/route.tsx | 41 +- apps/backend/src/auto-migrations/index.tsx | 2 +- apps/backend/src/lib/config.tsx | 177 +++- apps/backend/src/lib/emails-low-level.tsx | 4 +- .../analytics/queries/page-client.tsx | 845 ++++++++++++++++++ .../[projectId]/analytics/queries/page.tsx | 5 + .../projects/[projectId]/analytics/shared.tsx | 318 +++++++ .../analytics/tables/page-client.tsx | 82 +- .../[projectId]/auth-methods/page-client.tsx | 5 +- .../[projectId]/expert-mode/page-client.tsx | 226 +++-- .../integrations/transfer-confirm-page.tsx | 2 +- .../src/components/commands/run-query.tsx | 473 +++++----- .../src/components/data-table/team-table.tsx | 2 +- apps/dashboard/src/lib/apps-frontend.tsx | 1 + apps/dashboard/src/lib/config-update.tsx | 42 +- apps/e2e/tests/backend/backend-helpers.ts | 14 + .../endpoints/api/v1/analytics-config.test.ts | 582 ++++++++++++ .../api/v1/external-db-sync-advanced.test.ts | 22 +- .../api/v1/external-db-sync-basics.test.ts | 40 +- .../endpoints/api/v1/internal/config.test.ts | 287 +++++- apps/e2e/tests/js/config.test.ts | 85 ++ claude/CLAUDE-KNOWLEDGE.md | 3 + packages/stack-shared/src/config/format.ts | 77 ++ .../src/config/schema-fuzzer.test.ts | 15 + packages/stack-shared/src/config/schema.ts | 194 +++- .../src/interface/admin-interface.ts | 14 + packages/stack-shared/src/known-errors.tsx | 3 +- .../apps/implementations/admin-app-impl.ts | 19 +- .../src/lib/stack-app/projects/index.ts | 33 +- 34 files changed, 3334 insertions(+), 468 deletions(-) create mode 100644 .cursor/commands/pr-comments-review.md create mode 100644 apps/backend/prisma/migrations/20260202000000_fix_trusted_domains_config/migration.sql create mode 100644 apps/backend/src/app/api/latest/internal/config/override/[level]/reset-keys/route.tsx create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/queries/page-client.tsx create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/queries/page.tsx create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/shared.tsx create mode 100644 apps/e2e/tests/backend/endpoints/api/v1/analytics-config.test.ts diff --git a/.cursor/commands/pr-comments-review.md b/.cursor/commands/pr-comments-review.md new file mode 100644 index 0000000000..f4eb09a6ea --- /dev/null +++ b/.cursor/commands/pr-comments-review.md @@ -0,0 +1 @@ +Please review the PR comments with `gh pr status` and fix & resolve those issues that are valid and relevant. Leave those comments that are mostly bullshit unresolved. Report the result to me in detail. Do NOT automatically commit or stage the changes back to the PR! diff --git a/AGENTS.md b/AGENTS.md index e102ab9df0..c25b62c3ef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ This file provides guidance to coding agents when working with code in this repo #### Extra commands These commands are usually already called by the user, but you can remind them to run it for you if they forgot to. -- **Build packages**: `pnpm build:packages` +- **Build packages**: `pnpm build:packages` (you should never call this yourself) - **Start dependencies**: `pnpm restart-deps` (resets & restarts Docker containers for DB, Inbucket, etc. Usually already called by the user) - **Run development**: Already called by the user in the background. You don't need to do this. This will also watch for changes and rebuild packages, codegen, etc. Do NOT call build:packages, dev, codegen, or anything like that yourself, as the dev is already running it. - **Run minimal dev**: `pnpm dev:basic` (only backend and dashboard for resource-limited systems) @@ -93,6 +93,9 @@ To see all development ports, refer to the index.html of `apps/dev-launchpad/pub - If there is an external browser tool connected, use it to test changes you make to the frontend when possible. - Whenever you update an SDK implementation in `sdks/implementations`, make sure to update the specs accordingly in `sdks/specs` such that if you reimplemented the entire SDK from the specs again, you would get the same implementation. (For example, if the specs are not precise enough to describe a change you made, make the specs more precise.) - When building internal tools for Stack Auth developers (eg. internal interfaces like the WAL info log etc.): Make the interfaces look very concise, assume the user is a pro-user. This only applies to internal tools that are used primarily by Stack Auth developers. +- The dev server already builds the packages in the background whenever you update a file. If you run into issues with typechecking or linting in a dependency after updating something in a package, just wait a few seconds, and then try again, and they will likely be resolved. +- When asked to review PR comments, you can use `gh pr status` to get the current pull request you're working on. +- NEVER EVER AUTOMATICALLY COMMIT OR STAGE ANY CHANGES — DON'T MODIFY GIT WITHOUT USER CONSENT! - When building frontend or React code for the dashboard, refer to DESIGN-GUIDE.md. - NEVER implement a hacky solution without EXPLICIT approval from the user. Always go the extra mile to make sure the solution is clean, maintainable, and robust. - Fail early, fail loud. Fail fast with an error instead of silently continuing. diff --git a/apps/backend/prisma/migrations/20260202000000_fix_trusted_domains_config/migration.sql b/apps/backend/prisma/migrations/20260202000000_fix_trusted_domains_config/migration.sql new file mode 100644 index 0000000000..7df55629ed --- /dev/null +++ b/apps/backend/prisma/migrations/20260202000000_fix_trusted_domains_config/migration.sql @@ -0,0 +1,123 @@ +-- Migration to fix incorrectly formatted trusted domain entries in EnvironmentConfigOverride. +-- +-- A previous migration sometimes generated entries like: +-- "domains.trustedDomains..": value1, +-- "domains.trustedDomains..": value2 +-- +-- Without the parent key: +-- "domains.trustedDomains.": { ... } +-- +-- This migration adds an empty object at the level for any missing parent keys: +-- "domains.trustedDomains.": {}, +-- "domains.trustedDomains..": value1, +-- "domains.trustedDomains..": value2 + +-- Add temporary column to track processed rows (outside transaction so it's visible immediately) +-- SPLIT_STATEMENT_SENTINEL +-- SINGLE_STATEMENT_SENTINEL +-- RUN_OUTSIDE_TRANSACTION_SENTINEL +ALTER TABLE /* SCHEMA_NAME_SENTINEL */."EnvironmentConfigOverride" ADD COLUMN IF NOT EXISTS "temp_trusted_domains_checked" BOOLEAN DEFAULT FALSE; +-- SPLIT_STATEMENT_SENTINEL + +-- Create index on the temporary column for efficient querying +-- SPLIT_STATEMENT_SENTINEL +-- SINGLE_STATEMENT_SENTINEL +-- RUN_OUTSIDE_TRANSACTION_SENTINEL +CREATE INDEX CONCURRENTLY IF NOT EXISTS "temp_eco_trusted_domains_checked_idx" +ON /* SCHEMA_NAME_SENTINEL */."EnvironmentConfigOverride" ("temp_trusted_domains_checked") +WHERE "temp_trusted_domains_checked" IS NOT TRUE; +-- SPLIT_STATEMENT_SENTINEL + +-- Process rows in batches (outside transaction so each batch commits independently) +-- SPLIT_STATEMENT_SENTINEL +-- SINGLE_STATEMENT_SENTINEL +-- RUN_OUTSIDE_TRANSACTION_SENTINEL +-- CONDITIONALLY_REPEAT_MIGRATION_SENTINEL +WITH rows_to_check AS ( + -- Get unchecked rows + SELECT "projectId", "branchId", "config" + FROM /* SCHEMA_NAME_SENTINEL */."EnvironmentConfigOverride" + WHERE "temp_trusted_domains_checked" IS NOT TRUE + -- Keep batch size small for consistent performance + LIMIT 1000 +), +matching_keys AS ( + -- Find all keys that look like "domains.trustedDomains.." + -- (4 or more dot-separated parts starting with domains.trustedDomains) + SELECT + rtc."projectId", + rtc."branchId", + key, + -- Extract the parent key: domains.trustedDomains. + (string_to_array(key, '.'))[1] || '.' || + (string_to_array(key, '.'))[2] || '.' || + (string_to_array(key, '.'))[3] AS parent_key + FROM rows_to_check rtc, + jsonb_object_keys(rtc."config") AS key + WHERE key ~ '^domains\.trustedDomains\.[^.]+\..+' + -- Pattern matches: domains.trustedDomains.. + -- e.g. "domains.trustedDomains.abc123.baseUrl" +), +missing_parents AS ( + -- Find parent keys that don't exist in the config + SELECT DISTINCT + mk."projectId", + mk."branchId", + mk.parent_key + FROM matching_keys mk + JOIN rows_to_check rtc + ON rtc."projectId" = mk."projectId" + AND rtc."branchId" = mk."branchId" + WHERE NOT (rtc."config" ? mk.parent_key) +), +parents_to_add AS ( + -- Aggregate all missing parent keys per row into a single jsonb object + SELECT + mp."projectId", + mp."branchId", + jsonb_object_agg(mp.parent_key, '{}'::jsonb) AS new_keys + FROM missing_parents mp + GROUP BY mp."projectId", mp."branchId" +), +updated_with_keys AS ( + -- Update rows that need new parent keys + UPDATE /* SCHEMA_NAME_SENTINEL */."EnvironmentConfigOverride" eco + SET + "config" = eco."config" || pta.new_keys, + "updatedAt" = NOW(), + "temp_trusted_domains_checked" = TRUE + FROM parents_to_add pta + WHERE eco."projectId" = pta."projectId" + AND eco."branchId" = pta."branchId" + RETURNING eco."projectId", eco."branchId" +), +marked_as_checked AS ( + -- Mark all checked rows (including ones that didn't need fixing) + UPDATE /* SCHEMA_NAME_SENTINEL */."EnvironmentConfigOverride" eco + SET "temp_trusted_domains_checked" = TRUE + FROM rows_to_check rtc + WHERE eco."projectId" = rtc."projectId" + AND eco."branchId" = rtc."branchId" + AND NOT EXISTS ( + SELECT 1 FROM updated_with_keys uwk + WHERE uwk."projectId" = eco."projectId" + AND uwk."branchId" = eco."branchId" + ) + RETURNING eco."projectId" +) +SELECT COUNT(*) > 0 AS should_repeat_migration +FROM rows_to_check; +-- SPLIT_STATEMENT_SENTINEL + +-- Clean up: drop temporary index (outside transaction since CREATE was also outside) +-- SPLIT_STATEMENT_SENTINEL +-- SINGLE_STATEMENT_SENTINEL +-- RUN_OUTSIDE_TRANSACTION_SENTINEL +DROP INDEX IF EXISTS /* SCHEMA_NAME_SENTINEL */."temp_eco_trusted_domains_checked_idx"; +-- SPLIT_STATEMENT_SENTINEL + +-- Clean up: drop temporary column (outside transaction) +-- SPLIT_STATEMENT_SENTINEL +-- SINGLE_STATEMENT_SENTINEL +-- RUN_OUTSIDE_TRANSACTION_SENTINEL +ALTER TABLE /* SCHEMA_NAME_SENTINEL */."EnvironmentConfigOverride" DROP COLUMN IF EXISTS "temp_trusted_domains_checked"; diff --git a/apps/backend/scripts/db-migrations.ts b/apps/backend/scripts/db-migrations.ts index 4f997ec7c8..33bd4280d9 100644 --- a/apps/backend/scripts/db-migrations.ts +++ b/apps/backend/scripts/db-migrations.ts @@ -1,15 +1,14 @@ import { applyMigrations } from "@/auto-migrations"; import { MIGRATION_FILES_DIR, getMigrationFiles } from "@/auto-migrations/utils"; import { Prisma } from "@/generated/prisma/client"; +import { getClickhouseAdminClient } from "@/lib/clickhouse"; import { globalPrismaClient, globalPrismaSchema, sqlQuoteIdent } from "@/prisma-client"; import { spawnSync } from "child_process"; import fs from "fs"; import path from "path"; import * as readline from "readline"; import { seed } from "../prisma/seed"; -import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; import { runClickhouseMigrations } from "./clickhouse-migrations"; -import { getClickhouseAdminClient } from "@/lib/clickhouse"; const getClickhouseClient = () => getClickhouseAdminClient(); @@ -81,7 +80,6 @@ const generateMigrationFile = async () => { const folderName = `${timestampPrefix()}_${migrationName}`; const migrationDir = path.join(MIGRATION_FILES_DIR, folderName); const migrationSqlPath = path.join(migrationDir, 'migration.sql'); - const diffUrl = getEnvVariable('STACK_DATABASE_CONNECTION_STRING'); console.log(`Generating migration ${folderName}...`); const diffResult = spawnSync( @@ -92,7 +90,6 @@ const generateMigrationFile = async () => { 'migrate', 'diff', '--from-config-datasource', - diffUrl, '--to-schema', 'prisma/schema.prisma', '--script', diff --git a/apps/backend/src/app/api/latest/internal/config/override/[level]/reset-keys/route.tsx b/apps/backend/src/app/api/latest/internal/config/override/[level]/reset-keys/route.tsx new file mode 100644 index 0000000000..93a364df4e --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/config/override/[level]/reset-keys/route.tsx @@ -0,0 +1,55 @@ +import { resetBranchConfigOverrideKeys, resetEnvironmentConfigOverrideKeys } from "@/lib/config"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { adaptSchema, adminAuthTypeSchema, yupArray, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; + +const levelSchema = yupString().oneOf(["branch", "environment"]).defined(); + +const levelConfigs = { + branch: { + reset: (options: { projectId: string, branchId: string, keysToReset: string[] }) => + resetBranchConfigOverrideKeys(options), + }, + environment: { + reset: (options: { projectId: string, branchId: string, keysToReset: string[] }) => + resetEnvironmentConfigOverrideKeys(options), + }, +}; + +export const POST = createSmartRouteHandler({ + metadata: { + hidden: true, + summary: 'Reset config override keys', + description: 'Remove specific keys (and their nested descendants) from the config override at a given level. Uses the same nested key logic as the override algorithm.', + tags: ['Config'], + }, + request: yupObject({ + auth: yupObject({ + type: adminAuthTypeSchema, + tenancy: adaptSchema, + }).defined(), + params: yupObject({ + level: levelSchema, + }).defined(), + body: yupObject({ + keys: yupArray(yupString().defined()).defined(), + }).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["success"]).defined(), + }), + handler: async (req) => { + const levelConfig = levelConfigs[req.params.level]; + + await levelConfig.reset({ + projectId: req.auth.tenancy.project.id, + branchId: req.auth.tenancy.branchId, + keysToReset: req.body.keys, + }); + + return { + statusCode: 200 as const, + bodyType: "success" as const, + }; + }, +}); diff --git a/apps/backend/src/app/api/latest/internal/config/override/[level]/route.tsx b/apps/backend/src/app/api/latest/internal/config/override/[level]/route.tsx index b9e0af9ef9..5fee1b9db6 100644 --- a/apps/backend/src/app/api/latest/internal/config/override/[level]/route.tsx +++ b/apps/backend/src/app/api/latest/internal/config/override/[level]/route.tsx @@ -1,10 +1,10 @@ -import { getBranchConfigOverrideQuery, getEnvironmentConfigOverrideQuery, overrideBranchConfigOverride, overrideEnvironmentConfigOverride, setBranchConfigOverride, setBranchConfigOverrideSource, setEnvironmentConfigOverride } from "@/lib/config"; +import { getBranchConfigOverrideQuery, getEnvironmentConfigOverrideQuery, overrideBranchConfigOverride, overrideEnvironmentConfigOverride, setBranchConfigOverride, setBranchConfigOverrideSource, setEnvironmentConfigOverride, validateBranchConfigOverride, validateEnvironmentConfigOverride } from "@/lib/config"; import { enqueueExternalDbSync } from "@/lib/external-db-sync-queue"; import { globalPrismaClient, rawQuery } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { branchConfigSchema, environmentConfigSchema, getConfigOverrideErrors, migrateConfigOverride } from "@stackframe/stack-shared/dist/config/schema"; import { adaptSchema, adminAuthTypeSchema, branchConfigSourceSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; -import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; +import { StatusError, captureError } from "@stackframe/stack-shared/dist/utils/errors"; import * as yup from "yup"; type BranchConfigSourceApi = yup.InferType; @@ -50,6 +50,11 @@ const levelConfigs = { branchId: options.branchId, branchConfigOverrideOverride: options.config, }), + validate: (options: { projectId: string, branchId: string, config: any }) => + validateBranchConfigOverride({ + projectId: options.projectId, + branchConfigOverride: options.config, + }), requiresSource: true, }, environment: { @@ -69,6 +74,12 @@ const levelConfigs = { branchId: options.branchId, environmentConfigOverrideOverride: options.config, }), + validate: (options: { projectId: string, branchId: string, config: any }) => + validateEnvironmentConfigOverride({ + projectId: options.projectId, + branchId: options.branchId, + environmentConfigOverride: options.config, + }), requiresSource: false, }, }; @@ -141,6 +152,20 @@ async function parseAndValidateConfig( return migratedConfig; } +async function warnOnValidationFailure( + levelConfig: typeof levelConfigs["branch" | "environment"], + options: { projectId: string, branchId: string, config: any }, +) { + try { + const validationResult = await levelConfig.validate(options); + if (validationResult.status === "error") { + captureError("config-override-validation-warning", `Config override validation warning for project ${options.projectId} (this may not be a logic error, but rather a client/implementation issue — e.g. dot notation into non-existent record entries): ${validationResult.error}`); + } + } catch (e) { + captureError("config-override-validation-check-failed", e); + } +} + export const PUT = createSmartRouteHandler({ metadata: { hidden: true, @@ -179,6 +204,12 @@ export const PUT = createSmartRouteHandler({ source: req.body.source as BranchConfigSourceApi, }); + await warnOnValidationFailure(levelConfig, { + projectId: req.auth.tenancy.project.id, + branchId: req.auth.tenancy.branchId, + config: parsedConfig, + }); + if (req.params.level === "environment" && shouldEnqueueExternalDbSync(parsedConfig)) { await enqueueExternalDbSync(req.auth.tenancy.id); } @@ -220,6 +251,12 @@ export const PATCH = createSmartRouteHandler({ config: parsedConfig, }); + await warnOnValidationFailure(levelConfig, { + projectId: req.auth.tenancy.project.id, + branchId: req.auth.tenancy.branchId, + config: parsedConfig, + }); + if (req.params.level === "environment" && shouldEnqueueExternalDbSync(parsedConfig)) { await enqueueExternalDbSync(req.auth.tenancy.id); } diff --git a/apps/backend/src/auto-migrations/index.tsx b/apps/backend/src/auto-migrations/index.tsx index 5648561e6c..40ce1bb7f0 100644 --- a/apps/backend/src/auto-migrations/index.tsx +++ b/apps/backend/src/auto-migrations/index.tsx @@ -132,7 +132,7 @@ export async function applyMigrations(options: { } for (const statementRaw of migration.sql.split('SPLIT_STATEMENT_SENTINEL')) { - const statement = statementRaw.replace('/* SCHEMA_NAME_SENTINEL */', sqlQuoteIdentToString(options.schema)); + const statement = statementRaw.replaceAll('/* SCHEMA_NAME_SENTINEL */', sqlQuoteIdentToString(options.schema)); const runOutside = statement.includes('RUN_OUTSIDE_TRANSACTION_SENTINEL'); const isSingleStatement = statement.includes('SINGLE_STATEMENT_SENTINEL'); const isConditionallyRepeatMigration = statement.includes('CONDITIONALLY_REPEAT_MIGRATION_SENTINEL'); diff --git a/apps/backend/src/lib/config.tsx b/apps/backend/src/lib/config.tsx index 496147a847..886ebca658 100644 --- a/apps/backend/src/lib/config.tsx +++ b/apps/backend/src/lib/config.tsx @@ -1,5 +1,5 @@ import { Prisma } from "@/generated/prisma/client"; -import { Config, getInvalidConfigReason, normalize, override } from "@stackframe/stack-shared/dist/config/format"; +import { Config, getInvalidConfigReason, normalize, override, removeKeysFromConfig } from "@stackframe/stack-shared/dist/config/format"; import { BranchConfigOverride, BranchConfigOverrideOverride, BranchIncompleteConfig, BranchRenderedConfig, CompleteConfig, EnvironmentConfigOverride, EnvironmentConfigOverrideOverride, EnvironmentIncompleteConfig, EnvironmentRenderedConfig, OrganizationConfigOverride, OrganizationConfigOverrideOverride, OrganizationIncompleteConfig, ProjectConfigOverride, ProjectConfigOverrideOverride, ProjectIncompleteConfig, ProjectRenderedConfig, applyBranchDefaults, applyEnvironmentDefaults, applyOrganizationDefaults, applyProjectDefaults, assertNoConfigOverrideErrors, branchConfigSchema, environmentConfigSchema, getConfigOverrideErrors, getIncompleteConfigWarnings, migrateConfigOverride, organizationConfigSchema, projectConfigSchema, sanitizeBranchConfig, sanitizeEnvironmentConfig, sanitizeOrganizationConfig, sanitizeProjectConfig } from "@stackframe/stack-shared/dist/config/schema"; import { ProjectsCrud } from "@stackframe/stack-shared/dist/interface/crud/projects"; import { branchConfigSourceSchema, yupBoolean, yupMixed, yupObject, yupRecord, yupString, yupUnion } from "@stackframe/stack-shared/dist/schema-fields"; @@ -457,6 +457,75 @@ export function overrideOrganizationConfigOverride(options: { } +// --------------------------------------------------------------------------------------------------------------------- +// reset functions (remove specific keys from config override) +// --------------------------------------------------------------------------------------------------------------------- +// Uses the same nested key logic as the `override` function: resetting key "a.b" also resets "a.b.c". + +export async function resetProjectConfigOverrideKeys(options: { + projectId: string, + keysToReset: string[], +}): Promise { + // TODO put this in a serializable transaction (or a single SQL query) to prevent race conditions + const oldConfig = await rawQuery(globalPrismaClient, getProjectConfigOverrideQuery(options)); + const newConfig = removeKeysFromConfig(oldConfig, options.keysToReset); + + await setProjectConfigOverride({ + projectId: options.projectId, + projectConfigOverride: newConfig as ProjectConfigOverride, + }); +} + +export async function resetBranchConfigOverrideKeys(options: { + projectId: string, + branchId: string, + keysToReset: string[], +}): Promise { + // TODO put this in a serializable transaction (or a single SQL query) to prevent race conditions + const oldConfig = await rawQuery(globalPrismaClient, getBranchConfigOverrideQuery(options)); + const newConfig = removeKeysFromConfig(oldConfig, options.keysToReset); + + await setBranchConfigOverride({ + projectId: options.projectId, + branchId: options.branchId, + branchConfigOverride: newConfig as BranchConfigOverride, + }); +} + +export async function resetEnvironmentConfigOverrideKeys(options: { + projectId: string, + branchId: string, + keysToReset: string[], +}): Promise { + // TODO put this in a serializable transaction (or a single SQL query) to prevent race conditions + const oldConfig = await rawQuery(globalPrismaClient, getEnvironmentConfigOverrideQuery(options)); + const newConfig = removeKeysFromConfig(oldConfig, options.keysToReset); + + await setEnvironmentConfigOverride({ + projectId: options.projectId, + branchId: options.branchId, + environmentConfigOverride: newConfig as EnvironmentConfigOverride, + }); +} + +export async function resetOrganizationConfigOverrideKeys(options: { + projectId: string, + branchId: string, + organizationId: string | null, + keysToReset: string[], +}): Promise { + // TODO put this in a serializable transaction (or a single SQL query) to prevent race conditions + const oldConfig = await rawQuery(globalPrismaClient, getOrganizationConfigOverrideQuery(options)); + const newConfig = removeKeysFromConfig(oldConfig, options.keysToReset); + + await setOrganizationConfigOverride({ + projectId: options.projectId, + branchId: options.branchId, + organizationId: options.organizationId, + organizationConfigOverride: newConfig as OrganizationConfigOverride, + }); +} + // --------------------------------------------------------------------------------------------------------------------- // internal functions // --------------------------------------------------------------------------------------------------------------------- @@ -658,6 +727,112 @@ import.meta.vitest?.test('_validateConfigOverrideSchemaImpl(...)', async ({ expe Schema 2: sourceOfTruth.connectionString must be defined `)); + + // Dot-notation into record entries — silently dropped cases + const objectRecordSchema = yupObject({ a: yupRecord(yupString().defined(), yupObject({ x: yupString().optional(), y: yupString().optional() })) }).defined(); + + // Dot notation into a record entry that doesn't exist should warn + expect(await validateConfigOverrideSchema(objectRecordSchema, {}, { "a.mykey.x": "val" })).toMatchInlineSnapshot(` + { + "error": "[WARNING] Dot-notation keys set fields inside non-existent record entries and will be silently ignored during rendering: "a.mykey.x". Use nested object notation to create new record entries instead of dot notation.", + "status": "error", + } + `); + + // Setting the record entry itself (not dotting into it) should NOT warn + expect(await validateConfigOverrideSchema(objectRecordSchema, {}, { "a.mykey": { x: "val" } })).toMatchInlineSnapshot(` + { + "data": null, + "status": "ok", + } + `); + + // When the record entry exists in the base, dot notation into it should work fine + expect(await validateConfigOverrideSchema(objectRecordSchema, { a: { mykey: { x: "old" } } }, { "a.mykey.x": "new" })).toMatchInlineSnapshot(` + { + "data": null, + "status": "ok", + } + `); + + // When the record entry exists as a flat key in the same override, dot notation should work fine + expect(await validateConfigOverrideSchema(objectRecordSchema, {}, { "a.mykey": { x: "old" }, "a.mykey.y": "new" })).toMatchInlineSnapshot(` + { + "data": null, + "status": "ok", + } + `); + + // Dot-notation into non-existent record entry in actual schemas (trustedDomains) + expect(await validateConfigOverrideSchema(environmentConfigSchema, {}, { + 'domains.trustedDomains.my-domain.baseUrl': 'https://example.com', + })).toMatchInlineSnapshot(` + { + "error": "[WARNING] Dot-notation keys set fields inside non-existent record entries and will be silently ignored during rendering: "domains.trustedDomains.my-domain.baseUrl". Use nested object notation to create new record entries instead of dot notation.", + "status": "error", + } + `); + + // Nested object notation should work fine (no warning) + expect(await validateConfigOverrideSchema(environmentConfigSchema, {}, { + 'domains.trustedDomains.my-domain': { + baseUrl: 'https://example.com', + handlerPath: '/handler', + }, + })).toMatchInlineSnapshot(` + { + "data": null, + "status": "ok", + } + `); + + // Dot notation for static object fields should NOT warn + expect(await validateConfigOverrideSchema(environmentConfigSchema, {}, { + 'teams.allowClientTeamCreation': true, + })).toMatchInlineSnapshot(` + { + "data": null, + "status": "ok", + } + `); + expect(await validateConfigOverrideSchema(environmentConfigSchema, {}, { + 'auth.password.allowSignIn': true, + })).toMatchInlineSnapshot(` + { + "data": null, + "status": "ok", + } + `); + expect(await validateConfigOverrideSchema(environmentConfigSchema, {}, { + 'domains.allowLocalhost': true, + })).toMatchInlineSnapshot(` + { + "data": null, + "status": "ok", + } + `); + + // Dot notation into an oauth provider that doesn't exist should warn + expect(await validateConfigOverrideSchema(environmentConfigSchema, {}, { + 'auth.oauth.providers.google.clientId': 'test-id', + })).toMatchInlineSnapshot(` + { + "error": "[WARNING] Dot-notation keys set fields inside non-existent record entries and will be silently ignored during rendering: "auth.oauth.providers.google.clientId". Use nested object notation to create new record entries instead of dot notation.", + "status": "error", + } + `); + + // Dot notation into an oauth provider that exists in the base should NOT warn + expect(await validateConfigOverrideSchema(environmentConfigSchema, { + auth: { oauth: { providers: { google: { type: 'google', allowSignIn: true } } } }, + }, { + 'auth.oauth.providers.google.clientId': 'test-id', + })).toMatchInlineSnapshot(` + { + "data": null, + "status": "ok", + } + `); }); // --------------------------------------------------------------------------------------------------------------------- diff --git a/apps/backend/src/lib/emails-low-level.tsx b/apps/backend/src/lib/emails-low-level.tsx index fb4f5885cf..a91e31744c 100644 --- a/apps/backend/src/lib/emails-low-level.tsx +++ b/apps/backend/src/lib/emails-low-level.tsx @@ -48,9 +48,9 @@ async function _lowLevelSendEmailWithoutRetries(options: LowLevelSendEmailOption }>> { let finished = false; runAsynchronously(async () => { - await wait(10000); + await wait(15_000); if (!finished) { - captureError("email-send-timeout", new StackAssertionError("Email send took longer than 10s; maybe the email service is too slow?", { + captureError("email-send-timeout", new StackAssertionError("Email send took longer than 15s; maybe the email service is too slow?", { config: options.emailConfig.type === 'shared' ? "shared" : pick(options.emailConfig, ['host', 'port', 'username', 'senderEmail', 'senderName']), to: options.to, subject: options.subject, diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/queries/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/queries/page-client.tsx new file mode 100644 index 0000000000..25f516b6e5 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/queries/page-client.tsx @@ -0,0 +1,845 @@ +"use client"; + +import { Button } from "@/components/ui"; +import { + Dialog, + DialogBody, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { SimpleTooltip } from "@/components/ui/simple-tooltip"; +import { Textarea } from "@/components/ui/textarea"; +import { useUpdateConfig } from "@/lib/config-update"; +import { cn } from "@/lib/utils"; +import { + CaretDownIcon, + CaretRightIcon, + CheckCircleIcon, + FilePlusIcon, + FloppyDiskIcon, + FolderIcon, + FolderOpenIcon, + PlayIcon, + PlusIcon, + SpinnerGapIcon, + TrashIcon, +} from "@phosphor-icons/react"; +import { generateSecureRandomString } from "@stackframe/stack-shared/dist/utils/crypto"; +import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises"; +import { useCallback, useMemo, useState } from "react"; +import { AppEnabledGuard } from "../../app-enabled-guard"; +import { PageLayout } from "../../page-layout"; +import { useAdminApp } from "../../use-admin-app"; +import { + ErrorDisplay, + FolderWithId, + RowData, + RowDetailDialog, + VirtualizedFlatTable +} from "../shared"; + +// Delete icon button for sidebar items +function DeleteIconButton({ onClick }: { onClick: () => void }) { + return ( + + ); +} + +// Empty state component +function EmptyQueryState() { + return ( +
+
+ +
+
+

Run Query

+

+ Enter a ClickHouse SQL query above and click Run to see results. +

+
+
+

+ SELECT * FROM default.events
+ ORDER BY event_at DESC
+ LIMIT 100 +

+
+
+ ); +} + +// No results state +function NoResultsState() { + return ( +
+
+ +
+
+

No Results

+

+ Query executed successfully but returned no rows. +

+
+
+ ); +} + +// Loading state component +function LoadingState() { + return ( +
+ +

Running query...

+
+ ); +} + +// Create folder dialog +function CreateFolderDialog({ + open, + onOpenChange, + onCreate, +}: { + open: boolean, + onOpenChange: (open: boolean) => void, + onCreate: (displayName: string) => Promise, +}) { + const [displayName, setDisplayName] = useState(""); + const [loading, setLoading] = useState(false); + + const handleCreate = async () => { + if (!displayName.trim()) return; + setLoading(true); + try { + await onCreate(displayName.trim()); + setDisplayName(""); + onOpenChange(false); + } finally { + setLoading(false); + } + }; + + return ( + + + + Create Folder + + +
+
+ + setDisplayName(e.target.value)} + placeholder="My Queries" + onKeyDown={(e) => { + if (e.key === "Enter") { + runAsynchronouslyWithAlert(handleCreate); + } + }} + /> +
+
+
+ + + + +
+
+ ); +} + +// Save query dialog +function SaveQueryDialog({ + open, + onOpenChange, + folders, + sqlQuery, + onSave, + onCreateFolder, +}: { + open: boolean, + onOpenChange: (open: boolean) => void, + folders: FolderWithId[], + sqlQuery: string, + onSave: (displayName: string, folderId: string, description: string | null) => Promise, + onCreateFolder: () => void, +}) { + const [displayName, setDisplayName] = useState(""); + const [description, setDescription] = useState(""); + const [selectedFolderId, setSelectedFolderId] = useState(""); + const [loading, setLoading] = useState(false); + + const handleSave = async () => { + if (!displayName.trim() || !sqlQuery.trim() || !selectedFolderId) return; + setLoading(true); + try { + await onSave(displayName.trim(), selectedFolderId, description.trim() || null); + setDisplayName(""); + setDescription(""); + setSelectedFolderId(""); + onOpenChange(false); + } finally { + setLoading(false); + } + }; + + const canSave = displayName.trim() && selectedFolderId && sqlQuery.trim(); + + return ( + + + + Save Query + + +
+
+ + setDisplayName(e.target.value)} + placeholder="My Query" + /> +
+
+ + +
+
+ +