diff --git a/apps/backend/.env b/apps/backend/.env index 94b908d19..c8cebc65c 100644 --- a/apps/backend/.env +++ b/apps/backend/.env @@ -67,6 +67,7 @@ STACK_S3_REGION= STACK_S3_ACCESS_KEY_ID= STACK_S3_SECRET_ACCESS_KEY= STACK_S3_BUCKET= +STACK_S3_PRIVATE_BUCKET= # AWS configuration STACK_AWS_REGION= diff --git a/apps/backend/.env.development b/apps/backend/.env.development index 55d58dda4..73593972f 100644 --- a/apps/backend/.env.development +++ b/apps/backend/.env.development @@ -74,6 +74,7 @@ STACK_S3_REGION=us-east-1 STACK_S3_ACCESS_KEY_ID=s3mockroot STACK_S3_SECRET_ACCESS_KEY=s3mockroot STACK_S3_BUCKET=stack-storage +STACK_S3_PRIVATE_BUCKET=stack-storage-private # AWS region defaults to LocalStack STACK_AWS_REGION=us-east-1 diff --git a/apps/backend/prisma/migrations/20260210120000_session_recordings_mvp/migration.sql b/apps/backend/prisma/migrations/20260210120000_session_recordings_mvp/migration.sql index 3479fbe9d..c3133af16 100644 --- a/apps/backend/prisma/migrations/20260210120000_session_recordings_mvp/migration.sql +++ b/apps/backend/prisma/migrations/20260210120000_session_recordings_mvp/migration.sql @@ -43,6 +43,9 @@ ALTER TABLE "SessionRecordingChunk" ADD CONSTRAINT "SessionRecordingChunk_sessionRecordingId_fkey" FOREIGN KEY ("tenancyId","sessionRecordingId") REFERENCES "SessionRecording"("tenancyId","id") ON DELETE CASCADE ON UPDATE CASCADE; +CREATE UNIQUE INDEX "SessionRecording_tenancyId_refreshTokenId_key" + ON "SessionRecording"("tenancyId", "refreshTokenId"); + CREATE INDEX "SessionRecording_tenancyId_projectUserId_startedAt_idx" ON "SessionRecording"("tenancyId", "projectUserId", "startedAt"); diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 879b0dc53..d2ef02235 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -300,6 +300,7 @@ model SessionRecording { chunks SessionRecordingChunk[] @@id([tenancyId, id]) + @@unique([tenancyId, refreshTokenId]) @@index([tenancyId, projectUserId, startedAt]) @@index([tenancyId, lastEventAt]) } diff --git a/apps/backend/src/app/api/latest/session-recordings/batch/route.tsx b/apps/backend/src/app/api/latest/session-recordings/batch/route.tsx index 0aff7708c..779854539 100644 --- a/apps/backend/src/app/api/latest/session-recordings/batch/route.tsx +++ b/apps/backend/src/app/api/latest/session-recordings/batch/route.tsx @@ -102,13 +102,13 @@ export const POST = createSmartRouteHandler({ // Ensure the session row exists and is up-to-date. const existingSession = await prisma.sessionRecording.findUnique({ - where: { tenancyId_id: { tenancyId, id: sessionId } }, + where: { tenancyId_refreshTokenId: { tenancyId, refreshTokenId } }, select: { startedAt: true, lastEventAt: true }, }); const newStartedAtMs = Math.min(existingSession?.startedAt.getTime() ?? Number.POSITIVE_INFINITY, firstMs); const newLastEventAtMs = Math.max(existingSession?.lastEventAt.getTime() ?? 0, lastMs); await prisma.sessionRecording.upsert({ - where: { tenancyId_id: { tenancyId, id: sessionId } }, + where: { tenancyId_refreshTokenId: { tenancyId, refreshTokenId } }, create: { id: sessionId, tenancyId, @@ -120,7 +120,6 @@ export const POST = createSmartRouteHandler({ lastEventAt: new Date(newLastEventAtMs), }, update: { - refreshTokenId, startedAt: new Date(newStartedAtMs), lastEventAt: new Date(newLastEventAtMs), }, @@ -161,6 +160,7 @@ export const POST = createSmartRouteHandler({ body: gzipped, contentType: "application/json", contentEncoding: "gzip", + private: true, }); try { diff --git a/apps/backend/src/lib/email-queue-step.tsx b/apps/backend/src/lib/email-queue-step.tsx index 61fbd735e..0d72ea2e1 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 e8d157136..fc64a9f04 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) { @@ -292,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); diff --git a/apps/backend/src/s3.tsx b/apps/backend/src/s3.tsx index 1f7e8f3a5..1a2968761 100644 --- a/apps/backend/src/s3.tsx +++ b/apps/backend/src/s3.tsx @@ -7,6 +7,7 @@ const S3_REGION = getEnvVariable("STACK_S3_REGION", ""); const S3_ENDPOINT = getEnvVariable("STACK_S3_ENDPOINT", ""); const S3_PUBLIC_ENDPOINT = getEnvVariable("STACK_S3_PUBLIC_ENDPOINT", ""); const S3_BUCKET = getEnvVariable("STACK_S3_BUCKET", ""); +const S3_PRIVATE_BUCKET = getEnvVariable("STACK_S3_PRIVATE_BUCKET", ""); const S3_ACCESS_KEY_ID = getEnvVariable("STACK_S3_ACCESS_KEY_ID", ""); const S3_SECRET_ACCESS_KEY = getEnvVariable("STACK_S3_SECRET_ACCESS_KEY", ""); @@ -16,6 +17,10 @@ if (!HAS_S3) { console.warn("S3 bucket is not configured. File upload features will not be available."); } +if (HAS_S3 && !S3_PRIVATE_BUCKET) { + console.warn("S3 private bucket is not configured (STACK_S3_PRIVATE_BUCKET). Session recordings will not be available."); +} + const s3Client = HAS_S3 ? new S3Client({ region: S3_REGION, endpoint: S3_ENDPOINT, @@ -39,13 +44,19 @@ export async function uploadBytes(options: { body: Uint8Array, contentType?: string, contentEncoding?: string, + private?: boolean, }) { if (!s3Client) { throw new StackAssertionError("S3 is not configured"); } + const bucket = options.private ? S3_PRIVATE_BUCKET : S3_BUCKET; + if (!bucket) { + throw new StackAssertionError(options.private ? "S3 private bucket is not configured" : "S3 bucket is not configured"); + } + const command = new PutObjectCommand({ - Bucket: S3_BUCKET, + Bucket: bucket, Key: options.key, Body: options.body, ...(options.contentType ? { ContentType: options.contentType } : {}), @@ -56,7 +67,6 @@ export async function uploadBytes(options: { return { key: options.key, - url: getS3PublicUrl(options.key), }; } @@ -87,13 +97,18 @@ async function readBodyToBytes(body: unknown): Promise { throw new StackAssertionError("Unexpected S3 body type"); } -export async function downloadBytes(options: { key: string }): Promise { +export async function downloadBytes(options: { key: string, private?: boolean }): Promise { if (!s3Client) { throw new StackAssertionError("S3 is not configured"); } + const bucket = options.private ? S3_PRIVATE_BUCKET : S3_BUCKET; + if (!bucket) { + throw new StackAssertionError(options.private ? "S3 private bucket is not configured" : "S3 bucket is not configured"); + } + const command = new GetObjectCommand({ - Bucket: S3_BUCKET, + Bucket: bucket, Key: options.key, }); diff --git a/docker/dependencies/docker.compose.yaml b/docker/dependencies/docker.compose.yaml index 3cebc981a..e94ae1182 100644 --- a/docker/dependencies/docker.compose.yaml +++ b/docker/dependencies/docker.compose.yaml @@ -193,7 +193,7 @@ services: ports: - "${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}21:9090" environment: - - initialBuckets=stack-storage + - initialBuckets=stack-storage,stack-storage-private - root=s3mockroot - debug=false volumes: diff --git a/docker/emulator/docker.compose.yaml b/docker/emulator/docker.compose.yaml index a4172420a..6172a65de 100644 --- a/docker/emulator/docker.compose.yaml +++ b/docker/emulator/docker.compose.yaml @@ -52,6 +52,7 @@ services: STACK_SPOTIFY_CLIENT_SECRET: "MOCK" STACK_S3_ENDPOINT: "http://localhost:32205" STACK_S3_BUCKET: "stack-storage" + STACK_S3_PRIVATE_BUCKET: "stack-storage-private" STACK_S3_REGION: "us-east-1" STACK_S3_ACCESS_KEY_ID: "S3RVER" STACK_S3_SECRET_ACCESS_KEY: "S3RVER" @@ -145,7 +146,7 @@ services: ports: - 32205:9090 environment: - - initialBuckets=stack-storage + - initialBuckets=stack-storage,stack-storage-private - root=s3mockroot - debug=false volumes: diff --git a/docker/server/.env b/docker/server/.env index 2b523e001..0de86ff75 100644 --- a/docker/server/.env +++ b/docker/server/.env @@ -37,5 +37,6 @@ STACK_S3_REGION= STACK_S3_ACCESS_KEY_ID= STACK_S3_SECRET_ACCESS_KEY= STACK_S3_BUCKET= +STACK_S3_PRIVATE_BUCKET= STACK_FREESTYLE_API_KEY=# enter your freestyle.sh api key diff --git a/packages/stack-shared/src/interface/admin-interface.ts b/packages/stack-shared/src/interface/admin-interface.ts index d0e3cde9b..8b06858b1 100644 --- a/packages/stack-shared/src/interface/admin-interface.ts +++ b/packages/stack-shared/src/interface/admin-interface.ts @@ -35,7 +35,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 7c6a4c327..319824ad9 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 { @@ -244,6 +244,28 @@ export class StackClientInterface { return session; } + async sendSessionRecordingBatch( + body: string, + session: InternalSession | null, + options: { keepalive: boolean }, + ): Promise> { + try { + const response = await this.sendClientRequest( + "/session-recordings/batch", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + keepalive: options.keepalive, + }, + session, + ); + return Result.ok(response); + } catch (e) { + return Result.error(e instanceof Error ? e : new Error(String(e))); + } + } + protected async sendClientRequestAndCatchKnownError( path: string, requestOptions: RequestInit, @@ -286,8 +308,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 962c3d0b9..87db7202f 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/components/stack-analytics.tsx b/packages/template/src/components/stack-analytics.tsx index 00d6189c8..44c97962c 100644 --- a/packages/template/src/components/stack-analytics.tsx +++ b/packages/template/src/components/stack-analytics.tsx @@ -1,11 +1,10 @@ "use client"; -import { Result } from "@stackframe/stack-shared/dist/utils/results"; import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises"; +import { Result } from "@stackframe/stack-shared/dist/utils/results"; import React, { useEffect, useMemo, useRef } from "react"; import { useStackApp } from "../lib/hooks"; import { stackAppInternalsSymbol } from "../lib/stack-app/common"; -import { clientVersion, getBaseUrl, getDefaultExtraRequestHeaders, getDefaultPublishableClientKey } from "../lib/stack-app/apps/implementations/common"; export type AnalyticsReplayOptions = { enabled?: boolean, @@ -85,14 +84,11 @@ export function StackAnalyticsInternal(props: { replayOptions?: AnalyticsReplayO // calls getUser() -> /users/me on every invocation (bypassing the cache). // These hooks subscribe to the cache and only trigger network requests when needed. const accessToken = app.useAccessToken(); - const refreshToken = app.useRefreshToken(); - // Refs so the effect closure always has the latest token values - // without needing tokens in the dependency array (which would restart recording). + // Ref so the effect closure always has the latest token value + // without needing it in the dependency array (which would restart recording). const accessTokenRef = useRef(accessToken); - const refreshTokenRef = useRef(refreshToken); accessTokenRef.current = accessToken; - refreshTokenRef.current = refreshToken; useEffect(() => { let cancelled = false; @@ -116,9 +112,7 @@ export function StackAnalyticsInternal(props: { replayOptions?: AnalyticsReplayO }; const flush = async (options: { keepalive: boolean }) => { - const currentAccessToken = accessTokenRef.current; - const currentRefreshToken = refreshTokenRef.current; - if (!currentAccessToken) return; + if (!accessTokenRef.current) return; if (events.length === 0) return; const nowMs = Date.now(); @@ -137,30 +131,10 @@ export function StackAnalyticsInternal(props: { replayOptions?: AnalyticsReplayO events = []; approxBytes = 0; - const constructorOptions = app[stackAppInternalsSymbol].getConstructorOptions(); - const baseUrl = getBaseUrl(constructorOptions.baseUrl); - const publishableClientKey = constructorOptions.publishableClientKey ?? getDefaultPublishableClientKey(); - const extraRequestHeaders = constructorOptions.extraRequestHeaders ?? getDefaultExtraRequestHeaders(); - - const res = await Result.fromThrowingAsync(async () => { - return await fetch(new URL("/api/v1/session-recordings/batch", baseUrl), { - method: "POST", - credentials: "omit", - keepalive: options.keepalive, - headers: { - "content-type": "application/json", - "x-stack-project-id": app.projectId, - "x-stack-access-type": "client", - "x-stack-client-version": clientVersion, - "x-stack-access-token": currentAccessToken, - ...(currentRefreshToken ? { "x-stack-refresh-token": currentRefreshToken } : {}), - "x-stack-publishable-client-key": publishableClientKey, - "x-stack-allow-anonymous-user": "true", - ...extraRequestHeaders, - }, - body: JSON.stringify(payload), - }); - }); + const res = await app[stackAppInternalsSymbol].sendSessionRecordingBatch( + JSON.stringify(payload), + { keepalive: options.keepalive }, + ); if (res.status === "error") { // This is best-effort telemetry. Don't throw and break the app. 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 cf6e703cf..2e8c0429d 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 @@ -2830,6 +2830,9 @@ export class _StackClientAppImplIncomplete this._options, + sendSessionRecordingBatch: async (body: string, options: { keepalive: boolean }) => { + return await this._interface.sendSessionRecordingBatch(body, await this._getSession(), options); + }, sendRequest: async ( path: string, requestOptions: RequestInit, 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 c557cc302..73fe10c05 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 @@ -80,7 +80,7 @@ export type StackAdminAppConstructorOptions & { superSecretAdminKey?: string, - projectOwnerSession?: InternalSession, + projectOwnerSession?: InternalSession | (() => Promise), } ); diff --git a/packages/template/src/lib/stack-app/apps/interfaces/client-app.ts b/packages/template/src/lib/stack-app/apps/interfaces/client-app.ts index 4f89ae200..c425a088f 100644 --- a/packages/template/src/lib/stack-app/apps/interfaces/client-app.ts +++ b/packages/template/src/lib/stack-app/apps/interfaces/client-app.ts @@ -93,6 +93,7 @@ export type StackClientApp, setCurrentUser(userJsonPromise: Promise): void, getConstructorOptions(): StackClientAppConstructorOptions & { inheritsFrom?: undefined }, + sendSessionRecordingBatch(body: string, options: { keepalive: boolean }): Promise>, }, } & AsyncStoreProperty<"project", [], Project, false>