mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
Merge branch 'dev' into promptless/document-managed-email-provider
This commit is contained in:
commit
3e81f6555f
1843
apps/backend/scripts/benchmark-internal-metrics.ts
Normal file
1843
apps/backend/scripts/benchmark-internal-metrics.ts
Normal file
File diff suppressed because it is too large
Load Diff
@ -11,6 +11,7 @@ import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
|
||||
import { runAsynchronouslyAndWaitUntil } from "@/utils/background-tasks";
|
||||
import { validateImageAttachments } from "@stackframe/stack-shared/dist/ai/image-limits";
|
||||
import { yupMixed, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
|
||||
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
|
||||
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
|
||||
import { Json } from "@stackframe/stack-shared/dist/utils/json";
|
||||
import { generateText, ModelMessage, stepCountIs, streamText } from "ai";
|
||||
@ -55,7 +56,10 @@ export const POST = createSmartRouteHandler({
|
||||
throw new StatusError(StatusError.BadRequest, imageValidationResult.reason);
|
||||
}
|
||||
|
||||
const model = selectModel(quality, speed, isAuthenticated);
|
||||
const authenticatedApiKey = isAuthenticated
|
||||
? getEnvVariable("STACK_OPENROUTER_AUTHENTICATED_API_KEY", "")
|
||||
: "";
|
||||
const model = selectModel(quality, speed, isAuthenticated, authenticatedApiKey || undefined);
|
||||
const isDocsOrSearch = systemPromptId === "docs-ask-ai" || systemPromptId === "command-center-ask-ai";
|
||||
let systemPrompt = getFullSystemPrompt(systemPromptId);
|
||||
if (isDocsOrSearch) {
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { ALLOWED_MODEL_IDS } from "@/lib/ai/models";
|
||||
import { preprocessProxyBody } from "@/private";
|
||||
import { handleApiRequest } from "@/route-handlers/smart-route-handler";
|
||||
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
|
||||
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
|
||||
@ -30,6 +31,10 @@ function sanitizeBody(raw: ArrayBuffer): Uint8Array {
|
||||
parsed.metadata.user_id = parsed.metadata.user_id.slice(0, 128);
|
||||
}
|
||||
|
||||
parsed = preprocessProxyBody({
|
||||
parsedBody: parsed,
|
||||
});
|
||||
|
||||
return new TextEncoder().encode(JSON.stringify(parsed));
|
||||
}
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@ import { Prisma } from "@/generated/prisma/client";
|
||||
import { EmailOutboxSimpleStatus } from "@/generated/prisma/enums";
|
||||
import { getClickhouseAdminClient } from "@/lib/clickhouse";
|
||||
import { ClickHouseError } from "@clickhouse/client";
|
||||
import { ActivitySplit, buildSplitFromDailyEntitySets } from "@/lib/metrics-activity-split";
|
||||
import { ActivitySplit } from "@/lib/metrics-activity-split";
|
||||
import { Tenancy } from "@/lib/tenancies";
|
||||
import { getPrismaClientForTenancy, getPrismaSchemaForTenancy, sqlQuoteIdent } from "@/prisma-client";
|
||||
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
|
||||
@ -19,7 +19,6 @@ import {
|
||||
MetricsRecentUserSchema,
|
||||
} from "@stackframe/stack-shared/dist/interface/admin-metrics";
|
||||
import { captureError, StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
|
||||
import { isUuid } from "@stackframe/stack-shared/dist/utils/uuids";
|
||||
import { adaptSchema, adminAuthTypeSchema, yupArray, yupMixed, yupNumber, yupObject, yupRecord, yupString } from "@stackframe/stack-shared/dist/schema-fields";
|
||||
import { userFullInclude, userPrismaToCrud, usersCrudHandlers } from "../../users/crud";
|
||||
|
||||
@ -58,11 +57,6 @@ function formatClickhouseDateTimeParam(date: Date): string {
|
||||
return date.toISOString().slice(0, 19);
|
||||
}
|
||||
|
||||
function normalizeUuidFromEvent(value: string): string | null {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return isUuid(normalized) ? normalized : null;
|
||||
}
|
||||
|
||||
async function loadUsersByCountry(tenancy: Tenancy, includeAnonymous: boolean = false): Promise<Record<string, number>> {
|
||||
const clickhouseClient = getClickhouseAdminClient();
|
||||
const res = await clickhouseClient.query({
|
||||
@ -163,7 +157,7 @@ async function loadDailyActiveUsers(tenancy: Tenancy, now: Date, includeAnonymou
|
||||
AND user_id IS NOT NULL
|
||||
AND event_at >= {since:DateTime}
|
||||
AND event_at < {untilExclusive:DateTime}
|
||||
AND ({includeAnonymous:UInt8} = 1 OR JSONExtract(toJSONString(data), 'is_anonymous', 'UInt8') = 0)
|
||||
AND ({includeAnonymous:UInt8} = 1 OR coalesce(CAST(data.is_anonymous, 'Nullable(UInt8)'), 0) = 0)
|
||||
GROUP BY day
|
||||
ORDER BY day ASC
|
||||
`,
|
||||
@ -197,29 +191,71 @@ async function loadDailyActiveUsers(tenancy: Tenancy, now: Date, includeAnonymou
|
||||
return out;
|
||||
}
|
||||
|
||||
async function loadDailyActiveUsersSplit(tenancy: Tenancy, now: Date, includeAnonymous: boolean): Promise<ActivitySplit> {
|
||||
async function loadDailyActiveSplitFromClickhouse(options: {
|
||||
tenancy: Tenancy,
|
||||
now: Date,
|
||||
entity: "user" | "team",
|
||||
includeAnonymous: boolean,
|
||||
}): Promise<ActivitySplit> {
|
||||
const { tenancy, now, entity, includeAnonymous } = options;
|
||||
const todayUtc = new Date(now);
|
||||
todayUtc.setUTCHours(0, 0, 0, 0);
|
||||
const since = new Date(todayUtc.getTime() - METRICS_WINDOW_MS);
|
||||
const untilExclusive = new Date(todayUtc.getTime() + ONE_DAY_MS);
|
||||
const clickhouseClient = getClickhouseAdminClient();
|
||||
const schema = await getPrismaSchemaForTenancy(tenancy);
|
||||
const prisma = await getPrismaClientForTenancy(tenancy);
|
||||
|
||||
const userRows = await clickhouseClient.query({
|
||||
const idCol = entity === "user" ? "user_id" : "team_id";
|
||||
// Teams don't have an is_anonymous concept, so that filter is users-only.
|
||||
const anonFilter = entity === "user"
|
||||
? "AND ({includeAnonymous:UInt8} = 1 OR coalesce(CAST(data.is_anonymous, 'Nullable(UInt8)'), 0) = 0)"
|
||||
: "";
|
||||
|
||||
const clickhouseClient = getClickhouseAdminClient();
|
||||
// Note: the inner `assumeNotNull(${idCol}) AS entity_id` must not reuse the
|
||||
// column name, or ClickHouse re-resolves `WHERE ${idCol} IS NOT NULL`
|
||||
// against the alias (assumeNotNull returns '' for NULLs, which passes the
|
||||
// not-null test) and phantom rows slip through.
|
||||
const result = await clickhouseClient.query({
|
||||
query: `
|
||||
SELECT
|
||||
toDate(event_at) AS day,
|
||||
assumeNotNull(user_id) AS user_id
|
||||
FROM analytics_internal.events
|
||||
WHERE event_type = '$token-refresh'
|
||||
AND project_id = {projectId:String}
|
||||
AND branch_id = {branchId:String}
|
||||
AND user_id IS NOT NULL
|
||||
AND event_at >= {since:DateTime}
|
||||
AND event_at < {untilExclusive:DateTime}
|
||||
AND ({includeAnonymous:UInt8} = 1 OR JSONExtract(toJSONString(data), 'is_anonymous', 'UInt8') = 0)
|
||||
GROUP BY day, user_id
|
||||
toString(w.day) AS day,
|
||||
count() AS total_count,
|
||||
countIf(f.first_date = w.day) AS new_count,
|
||||
countIf(f.first_date < w.day AND w.prev_day = addDays(w.day, -1)) AS retained_count,
|
||||
countIf(f.first_date < w.day AND (isNull(w.prev_day) OR w.prev_day < addDays(w.day, -1))) AS reactivated_count
|
||||
FROM (
|
||||
SELECT
|
||||
day,
|
||||
entity_id,
|
||||
lagInFrame(day, 1) OVER (PARTITION BY entity_id ORDER BY day) AS prev_day
|
||||
FROM (
|
||||
SELECT DISTINCT
|
||||
toDate(event_at) AS day,
|
||||
assumeNotNull(${idCol}) AS entity_id
|
||||
FROM analytics_internal.events
|
||||
WHERE event_type = '$token-refresh'
|
||||
AND project_id = {projectId:String}
|
||||
AND branch_id = {branchId:String}
|
||||
AND ${idCol} IS NOT NULL
|
||||
AND event_at >= {since:DateTime}
|
||||
AND event_at < {untilExclusive:DateTime}
|
||||
${anonFilter}
|
||||
)
|
||||
) AS w
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
assumeNotNull(${idCol}) AS entity_id,
|
||||
toDate(min(event_at)) AS first_date
|
||||
FROM analytics_internal.events
|
||||
WHERE event_type = '$token-refresh'
|
||||
AND project_id = {projectId:String}
|
||||
AND branch_id = {branchId:String}
|
||||
AND ${idCol} IS NOT NULL
|
||||
AND event_at < {untilExclusive:DateTime}
|
||||
${anonFilter}
|
||||
GROUP BY entity_id
|
||||
) AS f USING (entity_id)
|
||||
GROUP BY w.day
|
||||
ORDER BY w.day ASC
|
||||
`,
|
||||
query_params: {
|
||||
projectId: tenancy.project.id,
|
||||
@ -229,129 +265,35 @@ async function loadDailyActiveUsersSplit(tenancy: Tenancy, now: Date, includeAno
|
||||
includeAnonymous: includeAnonymous ? 1 : 0,
|
||||
},
|
||||
format: "JSONEachRow",
|
||||
}).then((result) => result.json() as Promise<{ day: string, user_id: string }[]>);
|
||||
|
||||
const sanitizedUserRows = userRows.flatMap((row) => {
|
||||
const userId = normalizeUuidFromEvent(row.user_id);
|
||||
if (userId == null) {
|
||||
return [];
|
||||
}
|
||||
return [{ ...row, user_id: userId }];
|
||||
});
|
||||
const rows = (await result.json()) as {
|
||||
day: string,
|
||||
total_count: string,
|
||||
new_count: string,
|
||||
retained_count: string,
|
||||
reactivated_count: string,
|
||||
}[];
|
||||
|
||||
const activeUserIds = [...new Set(sanitizedUserRows.map((row) => row.user_id))];
|
||||
const users: { projectUserId: string, signedUpAtOrCreatedAt: Date }[] = activeUserIds.length === 0
|
||||
? []
|
||||
: await prisma.$replica().$queryRaw<{ projectUserId: string, signedUpAtOrCreatedAt: Date }[]>`
|
||||
SELECT
|
||||
"projectUserId"::text AS "projectUserId",
|
||||
COALESCE("signedUpAt", "createdAt") AS "signedUpAtOrCreatedAt"
|
||||
FROM ${sqlQuoteIdent(schema)}."ProjectUser"
|
||||
WHERE "tenancyId" = ${tenancy.id}::UUID
|
||||
AND "projectUserId" IN (${Prisma.join(activeUserIds.map((id) => Prisma.sql`${id}::UUID`))})
|
||||
${includeAnonymous ? Prisma.empty : Prisma.sql`AND "isAnonymous" = false`}
|
||||
`;
|
||||
|
||||
const byDay = new Map(rows.map((r) => [r.day.split('T')[0], r]));
|
||||
const orderedDays: string[] = [];
|
||||
const idsByDay = new Map<string, Set<string>>();
|
||||
for (let i = 0; i <= METRICS_WINDOW_DAYS; i += 1) {
|
||||
const date = new Date(since.getTime() + i * ONE_DAY_MS).toISOString().split('T')[0];
|
||||
orderedDays.push(date);
|
||||
idsByDay.set(date, new Set<string>());
|
||||
}
|
||||
for (const row of sanitizedUserRows) {
|
||||
const day = row.day.split('T')[0];
|
||||
const daySet = idsByDay.get(day);
|
||||
if (daySet) {
|
||||
daySet.add(row.user_id);
|
||||
}
|
||||
orderedDays.push(new Date(since.getTime() + i * ONE_DAY_MS).toISOString().split('T')[0]);
|
||||
}
|
||||
const split: ActivitySplit = {
|
||||
total: orderedDays.map((date) => ({ date, activity: Number(byDay.get(date)?.total_count ?? 0) })),
|
||||
new: orderedDays.map((date) => ({ date, activity: Number(byDay.get(date)?.new_count ?? 0) })),
|
||||
retained: orderedDays.map((date) => ({ date, activity: Number(byDay.get(date)?.retained_count ?? 0) })),
|
||||
reactivated: orderedDays.map((date) => ({ date, activity: Number(byDay.get(date)?.reactivated_count ?? 0) })),
|
||||
};
|
||||
return split;
|
||||
}
|
||||
|
||||
const createdDayByUserId = new Map<string, string>(
|
||||
users.map((user) => [user.projectUserId, user.signedUpAtOrCreatedAt.toISOString().split('T')[0]])
|
||||
);
|
||||
|
||||
return buildSplitFromDailyEntitySets({
|
||||
orderedDays,
|
||||
entityIdsByDay: idsByDay,
|
||||
createdDayByEntityId: createdDayByUserId,
|
||||
});
|
||||
async function loadDailyActiveUsersSplit(tenancy: Tenancy, now: Date, includeAnonymous: boolean): Promise<ActivitySplit> {
|
||||
return await loadDailyActiveSplitFromClickhouse({ tenancy, now, entity: "user", includeAnonymous });
|
||||
}
|
||||
|
||||
async function loadDailyActiveTeamsSplit(tenancy: Tenancy, now: Date): Promise<ActivitySplit> {
|
||||
const todayUtc = new Date(now);
|
||||
todayUtc.setUTCHours(0, 0, 0, 0);
|
||||
const since = new Date(todayUtc.getTime() - METRICS_WINDOW_MS);
|
||||
const untilExclusive = new Date(todayUtc.getTime() + ONE_DAY_MS);
|
||||
const clickhouseClient = getClickhouseAdminClient();
|
||||
const schema = await getPrismaSchemaForTenancy(tenancy);
|
||||
const prisma = await getPrismaClientForTenancy(tenancy);
|
||||
|
||||
const teamRows = await clickhouseClient.query({
|
||||
query: `
|
||||
SELECT
|
||||
toDate(event_at) AS day,
|
||||
assumeNotNull(team_id) AS team_id
|
||||
FROM analytics_internal.events
|
||||
WHERE event_type = '$token-refresh'
|
||||
AND project_id = {projectId:String}
|
||||
AND branch_id = {branchId:String}
|
||||
AND team_id IS NOT NULL
|
||||
AND event_at >= {since:DateTime}
|
||||
AND event_at < {untilExclusive:DateTime}
|
||||
GROUP BY day, team_id
|
||||
`,
|
||||
query_params: {
|
||||
projectId: tenancy.project.id,
|
||||
branchId: tenancy.branchId,
|
||||
since: formatClickhouseDateTimeParam(since),
|
||||
untilExclusive: formatClickhouseDateTimeParam(untilExclusive),
|
||||
},
|
||||
format: "JSONEachRow",
|
||||
}).then((result) => result.json() as Promise<{ day: string, team_id: string }[]>);
|
||||
|
||||
const sanitizedTeamRows = teamRows.flatMap((row) => {
|
||||
const teamId = normalizeUuidFromEvent(row.team_id);
|
||||
if (teamId == null) {
|
||||
return [];
|
||||
}
|
||||
return [{ ...row, team_id: teamId }];
|
||||
});
|
||||
|
||||
const activeTeamIds = [...new Set(sanitizedTeamRows.map((row) => row.team_id))];
|
||||
const teams: { teamId: string, createdAt: Date }[] = activeTeamIds.length === 0
|
||||
? []
|
||||
: await prisma.$replica().$queryRaw<{ teamId: string, createdAt: Date }[]>`
|
||||
SELECT "teamId"::text AS "teamId", "createdAt"
|
||||
FROM ${sqlQuoteIdent(schema)}."Team"
|
||||
WHERE "tenancyId" = ${tenancy.id}::UUID
|
||||
AND "teamId" IN (${Prisma.join(activeTeamIds.map((id) => Prisma.sql`${id}::UUID`))})
|
||||
`;
|
||||
|
||||
const orderedDays: string[] = [];
|
||||
const idsByDay = new Map<string, Set<string>>();
|
||||
for (let i = 0; i <= METRICS_WINDOW_DAYS; i += 1) {
|
||||
const date = new Date(since.getTime() + i * ONE_DAY_MS).toISOString().split('T')[0];
|
||||
orderedDays.push(date);
|
||||
idsByDay.set(date, new Set<string>());
|
||||
}
|
||||
for (const row of sanitizedTeamRows) {
|
||||
const day = row.day.split('T')[0];
|
||||
const daySet = idsByDay.get(day);
|
||||
if (daySet) {
|
||||
daySet.add(row.team_id);
|
||||
}
|
||||
}
|
||||
|
||||
const createdDayByTeamId = new Map<string, string>(
|
||||
teams.map((team) => [team.teamId, team.createdAt.toISOString().split('T')[0]])
|
||||
);
|
||||
|
||||
return buildSplitFromDailyEntitySets({
|
||||
orderedDays,
|
||||
entityIdsByDay: idsByDay,
|
||||
createdDayByEntityId: createdDayByTeamId,
|
||||
});
|
||||
return await loadDailyActiveSplitFromClickhouse({ tenancy, now, entity: "team", includeAnonymous: false });
|
||||
}
|
||||
|
||||
async function loadLoginMethods(tenancy: Tenancy): Promise<{ method: string, count: number }[]> {
|
||||
@ -397,6 +339,9 @@ async function loadRecentlyActiveUsers(tenancy: Tenancy, includeAnonymous: boole
|
||||
return dbUsers.map((user) => userPrismaToCrud(user, tenancy.config));
|
||||
}
|
||||
|
||||
// UUID v4 regex identical to isUuid() in stack-shared, ported to ClickHouse re2 syntax.
|
||||
const MAU_UUID_V4_REGEX = "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$";
|
||||
|
||||
async function loadMonthlyActiveUsers(tenancy: Tenancy, now: Date, includeAnonymous: boolean = false): Promise<number> {
|
||||
const { since, untilExclusive } = getMetricsWindowBounds(now);
|
||||
|
||||
@ -404,17 +349,19 @@ async function loadMonthlyActiveUsers(tenancy: Tenancy, now: Date, includeAnonym
|
||||
try {
|
||||
const result = await clickhouseClient.query({
|
||||
query: `
|
||||
SELECT
|
||||
assumeNotNull(user_id) AS user_id
|
||||
FROM analytics_internal.events
|
||||
WHERE event_type = '$token-refresh'
|
||||
AND project_id = {projectId:String}
|
||||
AND branch_id = {branchId:String}
|
||||
AND user_id IS NOT NULL
|
||||
AND event_at >= {since:DateTime}
|
||||
AND event_at < {untilExclusive:DateTime}
|
||||
AND ({includeAnonymous:UInt8} = 1 OR JSONExtract(toJSONString(data), 'is_anonymous', 'UInt8') = 0)
|
||||
GROUP BY user_id
|
||||
SELECT uniqExact(sipHash64(normalized_user_id)) AS mau
|
||||
FROM (
|
||||
SELECT lower(trim(assumeNotNull(user_id))) AS normalized_user_id
|
||||
FROM analytics_internal.events
|
||||
WHERE event_type = '$token-refresh'
|
||||
AND project_id = {projectId:String}
|
||||
AND branch_id = {branchId:String}
|
||||
AND user_id IS NOT NULL
|
||||
AND event_at >= {since:DateTime}
|
||||
AND event_at < {untilExclusive:DateTime}
|
||||
AND ({includeAnonymous:UInt8} = 1 OR coalesce(CAST(data.is_anonymous, 'Nullable(UInt8)'), 0) = 0)
|
||||
)
|
||||
WHERE match(normalized_user_id, {uuidRe:String})
|
||||
`,
|
||||
query_params: {
|
||||
projectId: tenancy.project.id,
|
||||
@ -422,18 +369,12 @@ async function loadMonthlyActiveUsers(tenancy: Tenancy, now: Date, includeAnonym
|
||||
since: formatClickhouseDateTimeParam(since),
|
||||
untilExclusive: formatClickhouseDateTimeParam(untilExclusive),
|
||||
includeAnonymous: includeAnonymous ? 1 : 0,
|
||||
uuidRe: MAU_UUID_V4_REGEX,
|
||||
},
|
||||
format: "JSONEachRow",
|
||||
});
|
||||
const rows: { user_id: string }[] = await result.json();
|
||||
const uniqueUserIds = new Set<string>();
|
||||
for (const row of rows) {
|
||||
const normalizedUserId = normalizeUuidFromEvent(row.user_id);
|
||||
if (normalizedUserId != null) {
|
||||
uniqueUserIds.add(normalizedUserId);
|
||||
}
|
||||
}
|
||||
return uniqueUserIds.size;
|
||||
const rows: { mau: string | number }[] = await result.json();
|
||||
return Number(rows[0]?.mau ?? 0);
|
||||
} catch (error) {
|
||||
// Only swallow real ClickHouse errors (e.g. project hasn't enabled
|
||||
// analytics yet, transient query failure). Anything else is a programming
|
||||
@ -835,7 +776,7 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date, includeAnonymo
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
user_id,
|
||||
argMax(JSONExtract(toJSONString(data), 'is_anonymous', 'UInt8'), event_at) AS latest_is_anonymous
|
||||
argMax(coalesce(CAST(data.is_anonymous, 'Nullable(UInt8)'), 0), event_at) AS latest_is_anonymous
|
||||
FROM analytics_internal.events
|
||||
WHERE event_type = '$token-refresh'
|
||||
AND project_id = {projectId:String}
|
||||
@ -846,7 +787,7 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date, includeAnonymo
|
||||
) AS token_refresh_users
|
||||
ON e.user_id = token_refresh_users.user_id
|
||||
`;
|
||||
const nonAnonymousAnalyticsUserFilter = "({includeAnonymous:UInt8} = 1 OR coalesce(JSONExtract(toJSONString(e.data), 'is_anonymous', 'Nullable(UInt8)'), token_refresh_users.latest_is_anonymous, 0) = 0)";
|
||||
const nonAnonymousAnalyticsUserFilter = "({includeAnonymous:UInt8} = 1 OR coalesce(CAST(e.data.is_anonymous, 'Nullable(UInt8)'), token_refresh_users.latest_is_anonymous, 0) = 0)";
|
||||
const [dailyEventResult, totalVisitorResult, referrerResult, topRegionResult, onlineResult] = await Promise.all([
|
||||
// Combined daily aggregates: page-view count, click count, and unique
|
||||
// visitors per day — one scan over the page-view/click event types.
|
||||
@ -953,7 +894,7 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date, includeAnonymo
|
||||
uniqExactIf(
|
||||
assumeNotNull(user_id),
|
||||
user_id IS NOT NULL
|
||||
AND ({includeAnonymous:UInt8} = 1 OR JSONExtract(toJSONString(data), 'is_anonymous', 'UInt8') = 0)
|
||||
AND ({includeAnonymous:UInt8} = 1 OR coalesce(CAST(data.is_anonymous, 'Nullable(UInt8)'), 0) = 0)
|
||||
) AS visitors
|
||||
FROM analytics_internal.events
|
||||
WHERE event_type = '$token-refresh'
|
||||
@ -987,7 +928,7 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date, includeAnonymo
|
||||
AND user_id IS NOT NULL
|
||||
AND event_at >= {onlineSince:DateTime}
|
||||
AND event_at < {untilExclusive:DateTime}
|
||||
AND ({includeAnonymous:UInt8} = 1 OR JSONExtract(toJSONString(data), 'is_anonymous', 'UInt8') = 0)
|
||||
AND ({includeAnonymous:UInt8} = 1 OR coalesce(CAST(data.is_anonymous, 'Nullable(UInt8)'), 0) = 0)
|
||||
`,
|
||||
query_params: {
|
||||
onlineSince: formatClickhouseDateTimeParam(new Date(now.getTime() - 5 * 60 * 1000)),
|
||||
|
||||
@ -69,15 +69,22 @@ export function createOpenRouterProvider() {
|
||||
});
|
||||
}
|
||||
|
||||
export function createDirectOpenRouterProvider(apiKey: string) {
|
||||
return createOpenRouter({ apiKey });
|
||||
}
|
||||
|
||||
export function selectModel(
|
||||
quality: ModelQuality,
|
||||
speed: ModelSpeed,
|
||||
isAuthenticated: boolean
|
||||
isAuthenticated: boolean,
|
||||
directApiKey?: string,
|
||||
) {
|
||||
const config =
|
||||
MODEL_SELECTION_MATRIX[quality][speed][isAuthenticated ? "authenticated" : "unauthenticated"];
|
||||
|
||||
const openrouter = createOpenRouterProvider();
|
||||
const openrouter = directApiKey
|
||||
? createDirectOpenRouterProvider(directApiKey)
|
||||
: createOpenRouterProvider();
|
||||
const model = openrouter(config.modelId);
|
||||
return model;
|
||||
}
|
||||
|
||||
8
apps/backend/src/lib/ai/proxy-preprocessing.ts
Normal file
8
apps/backend/src/lib/ai/proxy-preprocessing.ts
Normal file
@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Opaque preprocessing step applied to every parsed request body that
|
||||
* flows through the AI proxy. The concrete behavior lives in the
|
||||
* private implementation; the fallback is an identity function.
|
||||
*/
|
||||
export type AiProxyBodyProcessor = (input: {
|
||||
parsedBody: Record<string, unknown>,
|
||||
}) => Record<string, unknown>;
|
||||
@ -1 +1 @@
|
||||
Subproject commit 576f383b69a9593a9cff8d755c64c810aeeae239
|
||||
Subproject commit 73d5adbbc1843fae71483b4143d44e97f00fee1b
|
||||
@ -1,3 +1,4 @@
|
||||
import { AiProxyBodyProcessor } from "@/lib/ai/proxy-preprocessing";
|
||||
import { SignUpRiskEngine } from "@/lib/risk-scores";
|
||||
import { createNeutralSignUpHeuristicFacts } from "@/lib/sign-up-heuristics";
|
||||
|
||||
@ -9,3 +10,5 @@ export const signUpRiskEngine: SignUpRiskEngine = {
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const preprocessProxyBody: AiProxyBodyProcessor = ({ parsedBody }) => parsedBody;
|
||||
|
||||
@ -1 +1 @@
|
||||
export { signUpRiskEngine } from "./implementation.generated";
|
||||
export { signUpRiskEngine, preprocessProxyBody } from "./implementation.generated";
|
||||
|
||||
@ -55,7 +55,7 @@ export function DraftProgressBar({ steps, currentStep, onStepClick, disableNavig
|
||||
</button>
|
||||
|
||||
{!isLast && (
|
||||
<div className="w-20 h-1 bg-muted-foreground/15 overflow-hidden">
|
||||
<div className="w-8 sm:w-20 h-1 bg-muted-foreground/15 overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full bg-primary transition-all duration-300",
|
||||
@ -86,7 +86,7 @@ export function DraftProgressBar({ steps, currentStep, onStepClick, disableNavig
|
||||
>
|
||||
{step.label}
|
||||
</span>
|
||||
{!isLast && <div className="w-20" />}
|
||||
{!isLast && <div className="w-8 sm:w-20" />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@ -247,10 +247,20 @@ export function DomainReputationCard() {
|
||||
const capacityLabel = isBoostActive ? (
|
||||
<span>
|
||||
{hourlyUsed} of{" "}
|
||||
<span className="text-red-500 line-through">{Math.round(baseHourlyCapacity)}</span>
|
||||
{" "}
|
||||
<span className="text-blue-500 font-medium">{Math.round(hourlyCapacity)}</span>
|
||||
/h max
|
||||
<span
|
||||
className="text-red-500 line-through"
|
||||
title="Base hourly capacity (replaced by active boost)"
|
||||
>
|
||||
{Math.round(baseHourlyCapacity)}
|
||||
</span>
|
||||
{" \u2192 "}
|
||||
<span
|
||||
className="text-blue-500 font-medium"
|
||||
title="Boosted hourly capacity"
|
||||
>
|
||||
{Math.round(hourlyCapacity)}
|
||||
</span>
|
||||
/h max <span className="text-blue-500 font-medium">(boosted)</span>
|
||||
</span>
|
||||
) : (
|
||||
`${hourlyUsed} of ${Math.round(hourlyCapacity)}/h max`
|
||||
@ -260,7 +270,7 @@ export function DomainReputationCard() {
|
||||
<DesignCard
|
||||
gradient="default"
|
||||
glassmorphic
|
||||
className="w-72"
|
||||
className="w-full lg:w-72"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="p-1 rounded-md bg-foreground/[0.06] dark:bg-foreground/[0.04]">
|
||||
|
||||
@ -134,7 +134,7 @@ export default function PageClient() {
|
||||
title="Sent"
|
||||
description="View email logs and domain reputation"
|
||||
>
|
||||
<div data-walkthrough="emails-sent" className="flex gap-6">
|
||||
<div data-walkthrough="emails-sent" className="flex flex-col lg:flex-row gap-6">
|
||||
{/* Left side: Email Log with toggle inside card */}
|
||||
<div className="flex-1 flex flex-col gap-4">
|
||||
<DesignCard
|
||||
|
||||
@ -40,6 +40,14 @@ const SERVER_TYPE_LABELS: Record<ServerType, string> = {
|
||||
standard: "Custom SMTP",
|
||||
};
|
||||
|
||||
const MANAGED_DOMAIN_STATUS_LABELS: Record<ManagedDomainStatus, string> = {
|
||||
pending_dns: "Pending DNS records",
|
||||
pending_verification: "Pending verification",
|
||||
verified: "Verified",
|
||||
applied: "Applied",
|
||||
failed: "Failed",
|
||||
};
|
||||
|
||||
const VISIBLE_FIELDS: Record<ServerType, ServerFieldConfig[]> = {
|
||||
shared: [],
|
||||
managed: [],
|
||||
@ -317,7 +325,7 @@ function ManagedEmailSetupDialog(props: { trigger: React.ReactNode }) {
|
||||
<Alert key={domain.domainId} className="bg-slate-500/5 border-slate-500/20">
|
||||
<AlertTitle className="font-mono text-xs">{domain.senderLocalPart}@{domain.subdomain}</AlertTitle>
|
||||
<AlertDescription className="mt-1 flex items-center justify-between gap-2">
|
||||
<span className="text-xs">Status: {domain.status}</span>
|
||||
<span className="text-xs">Status: {(MANAGED_DOMAIN_STATUS_LABELS as Record<string, string>)[domain.status] ?? domain.status}</span>
|
||||
<DesignButton
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
|
||||
@ -63,17 +63,17 @@ export default function PageClient() {
|
||||
gradient="default"
|
||||
contentClassName="p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-2.5 rounded-xl bg-foreground/[0.04] ring-1 ring-foreground/[0.06]">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-4 min-w-0 flex-1">
|
||||
<div className="p-2.5 rounded-xl bg-foreground/[0.04] ring-1 ring-foreground/[0.06] shrink-0">
|
||||
<EnvelopeSimpleIcon className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<Typography className="font-semibold text-foreground">
|
||||
<Typography className="font-semibold text-foreground truncate">
|
||||
{template.displayName}
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
||||
@ -193,7 +193,7 @@ export default function PageClient() {
|
||||
onClick: handleSaveTheme
|
||||
}}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{themes.map((theme) => (
|
||||
<ThemeOption
|
||||
key={theme.id}
|
||||
|
||||
@ -3481,7 +3481,7 @@ NiceResponse {
|
||||
"date": <stripped field 'date'>,
|
||||
},
|
||||
{
|
||||
"activity": 9,
|
||||
"activity": 10,
|
||||
"date": <stripped field 'date'>,
|
||||
},
|
||||
],
|
||||
@ -3607,7 +3607,7 @@ NiceResponse {
|
||||
"date": <stripped field 'date'>,
|
||||
},
|
||||
{
|
||||
"activity": 1,
|
||||
"activity": 0,
|
||||
"date": <stripped field 'date'>,
|
||||
},
|
||||
],
|
||||
|
||||
@ -1,126 +1 @@
|
||||
// TODO: Use configPath in the prompt once local emulator is set up:
|
||||
// Add "npx @stackframe/stack-cli emulator run --config-file ${configPath}" to project dev command
|
||||
export const createInitPrompt = (web: boolean, configPath?: string) => `=============================
|
||||
STACK AUTH SETUP INSTRUCTIONS
|
||||
=============================
|
||||
|
||||
These instructions describe how to set up Stack Auth.
|
||||
${web ? `
|
||||
First of all, if you have access to a terminal, it is better to use the \`npx @stackframe/stack-cli init\`` : ""}
|
||||
|
||||
Install mcp server from https://mcp.stack-auth.com if not already installed
|
||||
|
||||
For reference, questions, or information on Stack Auth, you can query the docs on https://docs.stack-auth.com via curl or any tools that you have
|
||||
|
||||
## Setup
|
||||
|
||||
### 1) Install the package
|
||||
|
||||
Run the install command using whatever package manager the project uses (npm, yarn, pnpm, bun):
|
||||
|
||||
| Framework | Package |
|
||||
|-----------|---------|
|
||||
| Next.js | \`@stackframe/stack\` |
|
||||
| React | \`@stackframe/react\` |
|
||||
| Vanilla JS | \`@stackframe/js\` |
|
||||
|
||||
### 2) Create the Stack apps
|
||||
|
||||
Depending on whether you're on a client or a server, you will want to create stackClientApp or stackServerApp. Some environments, like Next.js, have both, so create both files.
|
||||
|
||||
The stack client app has client-level permissions. It contains most of the useful methods and hooks for your client-side code.
|
||||
The stack server app has full read and write access to all users. It requires STACK_SECRET_SERVER_KEY env variable and should only be used in secure context
|
||||
|
||||
In Next.js, env vars are auto-detected (NEXT_PUBLIC_STACK_PROJECT_ID etc.), so the constructor needs no explicit config. For other frameworks, you must pass projectId explicitly using the framework's env var access method. Pass publishableClientKey only if your project is configured to require publishable client keys.
|
||||
|
||||
The tokenStore should be "nextjs-cookie" for Next.js, or "cookie" for all other frameworks.
|
||||
|
||||
Make sure to set redirectMethod on non next.js frameworks. For example for tanstack router import like so:
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
|
||||
\`\`\`ts
|
||||
// src/stack/client.ts
|
||||
import { StackClientApp } from "@stackframe/stack"; // or "@stackframe/react" or "@stackframe/js"
|
||||
|
||||
export const stackClientApp = new StackClientApp({
|
||||
// Next.js: omit projectId/publishableClientKey (auto-detected from NEXT_PUBLIC_ env vars)
|
||||
// Other frameworks: pass projectId explicitly, and publishableClientKey only if required by your project. For Vite:
|
||||
// projectId: import.meta.env.VITE_STACK_PROJECT_ID,
|
||||
// publishableClientKey: import.meta.env.VITE_STACK_PUBLISHABLE_CLIENT_KEY,
|
||||
tokenStore: "nextjs-cookie", // or "cookie" for non-Next.js,
|
||||
// redirectMethod: { useNavigate } // or "window"
|
||||
});
|
||||
\`\`\`
|
||||
|
||||
If the framework has server-side support (e.g. Next.js), also create a server app:
|
||||
|
||||
\`\`\`ts
|
||||
// src/stack/server.ts
|
||||
import "server-only";
|
||||
import { StackServerApp } from "@stackframe/stack";
|
||||
import { stackClientApp } from "./client";
|
||||
|
||||
export const stackServerApp = new StackServerApp({
|
||||
inheritsFrom: stackClientApp,
|
||||
});
|
||||
\`\`\`
|
||||
|
||||
### 3) Create the Stack handler (if available in framework)
|
||||
|
||||
This sets up pages for sign in, sign up, password reset, etc.
|
||||
|
||||
\`\`\`tsx
|
||||
import { StackHandler } from "@stackframe/stack"; // Next.js
|
||||
// import { StackHandler } from "@stackframe/react"; // React
|
||||
|
||||
export default function Handler() {
|
||||
return <StackHandler fullPage />;
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
### 4) Create a Suspense boundary
|
||||
|
||||
Suspense is necessary for many stack auth hooks such as useUser to function. Add a loading component with a custom loading indicator for the current project. Don't add if one already exists
|
||||
|
||||
For example:
|
||||
\`\`\`tsx
|
||||
//src/loading.tsx
|
||||
|
||||
export default function Loading() {
|
||||
return <p>Loading...</p>
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
### 5) Link environment variables
|
||||
|
||||
This is only necessary if not using local emulator. Ensure these are ignored by git.
|
||||
|
||||
Rename the env var keys in .env to match the framework's convention for client-exposed variables. For example, Vite requires VITE_ prefix, Next.js uses NEXT_PUBLIC_, etc. The values should stay the same — only rename the keys.
|
||||
|
||||
The required variables are:
|
||||
- Project ID (e.g. NEXT_PUBLIC_STACK_PROJECT_ID, VITE_STACK_PROJECT_ID, etc.)
|
||||
- Secret server key: STACK_SECRET_SERVER_KEY (only for frameworks with server-side support, no prefix needed)
|
||||
|
||||
The publishable client key (e.g. NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY, VITE_STACK_PUBLISHABLE_CLIENT_KEY, etc.) is only required if your project has publishable client keys enabled as a requirement.
|
||||
|
||||
### 6) React only: Wrap the entire page in a Stack provider
|
||||
|
||||
This is used for the useUser and useStackApp hooks.
|
||||
|
||||
\`\`\`tsx
|
||||
import { StackProvider, StackTheme } from "@stackframe/stack";
|
||||
import { stackClientApp } from "../stack/client"; // adjust relative path
|
||||
\`\`\`
|
||||
|
||||
Then wrap the body content:
|
||||
|
||||
\`\`\`tsx
|
||||
return (
|
||||
<body>
|
||||
<StackProvider app={stackClientApp}>
|
||||
<StackTheme>{children}</StackTheme>
|
||||
</StackProvider>
|
||||
</body>
|
||||
);
|
||||
\`\`\`
|
||||
`;
|
||||
export { createInitPrompt } from "@stackframe/stack-shared/dist/helpers/init-prompt";
|
||||
|
||||
126
packages/stack-shared/src/helpers/init-prompt.ts
Normal file
126
packages/stack-shared/src/helpers/init-prompt.ts
Normal file
@ -0,0 +1,126 @@
|
||||
// TODO: Use configPath in the prompt once local emulator is set up:
|
||||
// Add "npx @stackframe/stack-cli emulator run --config-file ${configPath}" to project dev command
|
||||
export const createInitPrompt = (web: boolean, configPath?: string) => `=============================
|
||||
STACK AUTH SETUP INSTRUCTIONS
|
||||
=============================
|
||||
|
||||
These instructions describe how to set up Stack Auth.
|
||||
${web ? `
|
||||
First of all, if you have access to a terminal, it is better to use the \`npx @stackframe/stack-cli init\`` : ""}
|
||||
|
||||
Install mcp server from https://mcp.stack-auth.com if not already installed
|
||||
|
||||
For reference, questions, or information on Stack Auth, you can query the docs on https://docs.stack-auth.com via curl or any tools that you have
|
||||
|
||||
## Setup
|
||||
|
||||
### 1) Install the package
|
||||
|
||||
Run the install command using whatever package manager the project uses (npm, yarn, pnpm, bun):
|
||||
|
||||
| Framework | Package |
|
||||
|-----------|---------|
|
||||
| Next.js | \`@stackframe/stack\` |
|
||||
| React | \`@stackframe/react\` |
|
||||
| Vanilla JS | \`@stackframe/js\` |
|
||||
|
||||
### 2) Create the Stack apps
|
||||
|
||||
Depending on whether you're on a client or a server, you will want to create stackClientApp or stackServerApp. Some environments, like Next.js, have both, so create both files.
|
||||
|
||||
The stack client app has client-level permissions. It contains most of the useful methods and hooks for your client-side code.
|
||||
The stack server app has full read and write access to all users. It requires STACK_SECRET_SERVER_KEY env variable and should only be used in secure context
|
||||
|
||||
In Next.js, env vars are auto-detected (NEXT_PUBLIC_STACK_PROJECT_ID etc.), so the constructor needs no explicit config. For other frameworks, you must pass projectId explicitly using the framework's env var access method. Pass publishableClientKey only if your project is configured to require publishable client keys.
|
||||
|
||||
The tokenStore should be "nextjs-cookie" for Next.js, or "cookie" for all other frameworks.
|
||||
|
||||
Make sure to set redirectMethod on non next.js frameworks. For example for tanstack router import like so:
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
|
||||
\`\`\`ts
|
||||
// src/stack/client.ts
|
||||
import { StackClientApp } from "@stackframe/stack"; // or "@stackframe/react" or "@stackframe/js"
|
||||
|
||||
export const stackClientApp = new StackClientApp({
|
||||
// Next.js: omit projectId/publishableClientKey (auto-detected from NEXT_PUBLIC_ env vars)
|
||||
// Other frameworks: pass projectId explicitly, and publishableClientKey only if required by your project. For Vite:
|
||||
// projectId: import.meta.env.VITE_STACK_PROJECT_ID,
|
||||
// publishableClientKey: import.meta.env.VITE_STACK_PUBLISHABLE_CLIENT_KEY,
|
||||
tokenStore: "nextjs-cookie", // or "cookie" for non-Next.js,
|
||||
// redirectMethod: { useNavigate } // or "window"
|
||||
});
|
||||
\`\`\`
|
||||
|
||||
If the framework has server-side support (e.g. Next.js), also create a server app:
|
||||
|
||||
\`\`\`ts
|
||||
// src/stack/server.ts
|
||||
import "server-only";
|
||||
import { StackServerApp } from "@stackframe/stack";
|
||||
import { stackClientApp } from "./client";
|
||||
|
||||
export const stackServerApp = new StackServerApp({
|
||||
inheritsFrom: stackClientApp,
|
||||
});
|
||||
\`\`\`
|
||||
|
||||
### 3) Create the Stack handler (if available in framework)
|
||||
|
||||
This sets up pages for sign in, sign up, password reset, etc.
|
||||
|
||||
\`\`\`tsx
|
||||
import { StackHandler } from "@stackframe/stack"; // Next.js
|
||||
// import { StackHandler } from "@stackframe/react"; // React
|
||||
|
||||
export default function Handler() {
|
||||
return <StackHandler fullPage />;
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
### 4) Create a Suspense boundary
|
||||
|
||||
Suspense is necessary for many stack auth hooks such as useUser to function. Add a loading component with a custom loading indicator for the current project. Don't add if one already exists
|
||||
|
||||
For example:
|
||||
\`\`\`tsx
|
||||
//src/loading.tsx
|
||||
|
||||
export default function Loading() {
|
||||
return <p>Loading...</p>
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
### 5) Link environment variables
|
||||
|
||||
This is only necessary if not using local emulator. Ensure these are ignored by git.
|
||||
|
||||
Rename the env var keys in .env to match the framework's convention for client-exposed variables. For example, Vite requires VITE_ prefix, Next.js uses NEXT_PUBLIC_, etc. The values should stay the same — only rename the keys.
|
||||
|
||||
The required variables are:
|
||||
- Project ID (e.g. NEXT_PUBLIC_STACK_PROJECT_ID, VITE_STACK_PROJECT_ID, etc.)
|
||||
- Secret server key: STACK_SECRET_SERVER_KEY (only for frameworks with server-side support, no prefix needed)
|
||||
|
||||
The publishable client key (e.g. NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY, VITE_STACK_PUBLISHABLE_CLIENT_KEY, etc.) is only required if your project has publishable client keys enabled as a requirement.
|
||||
|
||||
### 6) React only: Wrap the entire page in a Stack provider
|
||||
|
||||
This is used for the useUser and useStackApp hooks.
|
||||
|
||||
\`\`\`tsx
|
||||
import { StackProvider, StackTheme } from "@stackframe/stack";
|
||||
import { stackClientApp } from "../stack/client"; // adjust relative path
|
||||
\`\`\`
|
||||
|
||||
Then wrap the body content:
|
||||
|
||||
\`\`\`tsx
|
||||
return (
|
||||
<body>
|
||||
<StackProvider app={stackClientApp}>
|
||||
<StackTheme>{children}</StackTheme>
|
||||
</StackProvider>
|
||||
</body>
|
||||
);
|
||||
\`\`\`
|
||||
`;
|
||||
Loading…
Reference in New Issue
Block a user