Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,8 @@ To see all development ports, refer to the index.html of `apps/dev-launchpad/pub
- 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!
- NEVER EVER AUTOMATICALLY COMMIT OR STAGE ANY CHANGES — DON'T MODIFY GIT WITHOUT USER CONSENT! if its already staged and you didnt do it then dont unstage it.
- NEVER run destructive or working-tree-mutating git operations without EXPLICIT user permission in the current turn. This includes (non-exhaustive): `git stash` / `stash pop` / `stash drop` / `stash clear`, `git reset` (any flag), `git checkout -- <path>` / `git restore`, `git clean`, `git rebase`, `git revert`, `git commit --amend`, `git push --force` / `--force-with-lease`, `git branch -D`, `git tag -d`, `git filter-branch`, `git update-ref`. Read-only inspection (`git status`, `git diff`, `git log`, `git blame`, `git show`, `git show HEAD:path`) is fine. If you think a destructive op is the right move, describe it and wait for a yes — do NOT run it to "verify" or "clean up".
- 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.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- SPLIT_STATEMENT_SENTINEL
-- SINGLE_STATEMENT_SENTINEL
-- RUN_OUTSIDE_TRANSACTION_SENTINEL
CREATE INDEX CONCURRENTLY IF NOT EXISTS "ProjectUser_lastActiveAt"
ON "ProjectUser"("tenancyId", "isAnonymous", "lastActiveAt");
1 change: 1 addition & 0 deletions apps/backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,7 @@ model ProjectUser {
@@index([tenancyId, createdAt(sort: Asc)], name: "ProjectUser_createdAt_asc")
@@index([tenancyId, createdAt(sort: Desc)], name: "ProjectUser_createdAt_desc")
@@index([tenancyId, isAnonymous, signedUpAt(sort: Asc)], name: "ProjectUser_signedUpAt_asc")
@@index([tenancyId, isAnonymous, lastActiveAt], name: "ProjectUser_lastActiveAt")
@@index([tenancyId, isAnonymous, signUpIp, signedUpAt], name: "ProjectUser_signUpIp_recent_idx")
@@index([tenancyId, isAnonymous, signUpEmailNormalized, signedUpAt], name: "ProjectUser_signUpEmailNormalized_recent_idx")
@@index([tenancyId, isAnonymous, signUpEmailBase, signedUpAt], name: "ProjectUser_signUpEmailBase_recent_idx")
Expand Down
66 changes: 57 additions & 9 deletions apps/backend/src/app/api/latest/internal/session-replays/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ function parseCsvUuids(name: string, raw: string | undefined): string[] {
return values;
}

function escapeLikePattern(input: string): string {
return input.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_");
}

function parseNonNegativeInt(name: string, raw: string | undefined): number | null {
if (!raw) return null;
const value = Number(raw);
Expand Down Expand Up @@ -100,6 +104,8 @@ export const GET = createSmartRouteHandler({
last_event_at_from_millis: yupString().optional(),
last_event_at_to_millis: yupString().optional(),
click_count_min: yupString().optional(),
sort_direction: yupString().oneOf(["asc", "desc"]).optional(),
q: yupString().optional(),
}).optional(),
}),
response: yupObject({
Expand Down Expand Up @@ -138,6 +144,8 @@ export const GET = createSmartRouteHandler({
const clickCountMin = parseNonNegativeInt("click_count_min", query.click_count_min);
const lastEventAtFrom = parseMillis("last_event_at_from_millis", query.last_event_at_from_millis);
const lastEventAtTo = parseMillis("last_event_at_to_millis", query.last_event_at_to_millis);
const sortDirection: "asc" | "desc" = query.sort_direction === "asc" ? "asc" : "desc";
const searchQuery = query.q?.trim() || null;

if (durationMsMin !== null && durationMsMax !== null && durationMsMin > durationMsMax) {
throw new StatusError(StatusError.BadRequest, "duration_ms_min must be less than or equal to duration_ms_max");
Expand All @@ -163,17 +171,46 @@ export const GET = createSmartRouteHandler({
};
}

// Handle cursor-based pagination
// Handle cursor-based pagination — validate the cursor row still matches
// the current filter set so that swapping filters between requests doesn't
// anchor pagination on a row that no longer qualifies.
const cursorId = query.cursor;
let cursorPivot: { id: string, lastEventAt: Date } | null = null;
if (cursorId) {
cursorPivot = await prisma.sessionReplay.findUnique({
where: { tenancyId_id: { tenancyId: auth.tenancy.id, id: cursorId } },
select: { id: true, lastEventAt: true },
if (clickQualifiedIds && !clickQualifiedIds.includes(cursorId)) {
throw new KnownErrors.ItemNotFound(cursorId);
}
const row = await prisma.sessionReplay.findFirst({
where: {
tenancyId: auth.tenancy.id,
id: cursorId,
...userIdsFilter.length > 0 ? { projectUserId: { in: userIdsFilter } } : {},
...lastEventAtFrom ? { lastEventAt: { gte: lastEventAtFrom } } : {},
...lastEventAtTo ? { lastEventAt: { lte: lastEventAtTo } } : {},
...teamIdsFilter.length > 0 ? {
projectUser: {
teamMembers: {
some: {
tenancyId: auth.tenancy.id,
teamId: { in: teamIdsFilter },
},
},
},
} : {},
},
select: { id: true, lastEventAt: true, startedAt: true },
});
Comment on lines +183 to 202
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

lastEventAtFrom filter is silently dropped from cursor validation when both bounds are set.

Two separate object spreads target the same lastEventAt key:

...lastEventAtFrom ? { lastEventAt: { gte: lastEventAtFrom } } : {},
...lastEventAtTo   ? { lastEventAt: { lte: lastEventAtTo   } } : {},

When both bounds are provided, the second spread overwrites the first, so findFirst only enforces lte and the gte lower bound is lost. This means a cursor whose lastEventAt is before lastEventAtFrom will still pass validation (no ItemNotFound), even though the main query at lines 218–219 correctly excludes such rows. The result is a cursor that anchors paging outside the active filter window — defeating the whole purpose of the validation block called out in the PR commit ("ensure the cursor matches the current filter set").

Note the teamIdsFilter and userIdsFilter are also keyed on tenancyId/teamId but those collisions happen to merge cleanly via Prisma nested types; only the lastEventAt pair self-collides.

🐛 Proposed fix: combine into a single `lastEventAt` clause
       const row = await prisma.sessionReplay.findFirst({
         where: {
           tenancyId: auth.tenancy.id,
           id: cursorId,
           ...userIdsFilter.length > 0 ? { projectUserId: { in: userIdsFilter } } : {},
-          ...lastEventAtFrom ? { lastEventAt: { gte: lastEventAtFrom } } : {},
-          ...lastEventAtTo ? { lastEventAt: { lte: lastEventAtTo } } : {},
+          ...(lastEventAtFrom || lastEventAtTo) ? {
+            lastEventAt: {
+              ...lastEventAtFrom ? { gte: lastEventAtFrom } : {},
+              ...lastEventAtTo ? { lte: lastEventAtTo } : {},
+            },
+          } : {},
           ...teamIdsFilter.length > 0 ? {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/backend/src/app/api/latest/internal/session-replays/route.tsx` around
lines 183 - 202, The cursor validation in prisma.sessionReplay.findFirst is
dropping the lower bound because lastEventAtFrom and lastEventAtTo are spread
into separate lastEventAt keys so the second overwrites the first; update the
where clause in route.tsx (the prisma.sessionReplay.findFirst call that uses
cursorId and auth.tenancy.id) to build a single lastEventAt object that
conditionally includes gte: lastEventAtFrom and lte: lastEventAtTo (or omits the
entire lastEventAt filter if neither bound exists), preserving both bounds when
both are provided so cursor validation matches the main query.

if (!cursorPivot) {
if (!row) {
throw new KnownErrors.ItemNotFound(cursorId);
}
const durationMs = row.lastEventAt.getTime() - row.startedAt.getTime();
if (durationMsMin !== null && durationMs < durationMsMin) {
throw new KnownErrors.ItemNotFound(cursorId);
}
if (durationMsMax !== null && durationMs > durationMsMax) {
throw new KnownErrors.ItemNotFound(cursorId);
}
cursorPivot = { id: row.id, lastEventAt: row.lastEventAt };
}

const suffixSql = Prisma.sql`
Expand All @@ -189,11 +226,22 @@ export const GET = createSmartRouteHandler({
${clickQualifiedIds ? Prisma.sql`AND sr."id" IN (${Prisma.join(clickQualifiedIds)})` : Prisma.empty}
${durationMsMin !== null ? Prisma.sql`AND EXTRACT(EPOCH FROM (sr."lastEventAt" - sr."startedAt")) * 1000 >= ${durationMsMin}` : Prisma.empty}
${durationMsMax !== null ? Prisma.sql`AND EXTRACT(EPOCH FROM (sr."lastEventAt" - sr."startedAt")) * 1000 <= ${durationMsMax}` : Prisma.empty}
${cursorPivot ? Prisma.sql`AND (
Comment thread
mantrakp04 marked this conversation as resolved.
sr."lastEventAt" < ${cursorPivot.lastEventAt}
OR (sr."lastEventAt" = ${cursorPivot.lastEventAt} AND sr."id" < ${cursorId})
${searchQuery ? Prisma.sql`AND (
sr."id"::text ILIKE ${`%${escapeLikePattern(searchQuery)}%`}
OR pu."displayName" ILIKE ${`%${escapeLikePattern(searchQuery)}%`}
)` : Prisma.empty}
ORDER BY sr."lastEventAt" DESC, sr."id" DESC
${cursorPivot ? (sortDirection === "asc"
? Prisma.sql`AND (
sr."lastEventAt" > ${cursorPivot.lastEventAt}
OR (sr."lastEventAt" = ${cursorPivot.lastEventAt} AND sr."id" > ${cursorId})
)`
: Prisma.sql`AND (
sr."lastEventAt" < ${cursorPivot.lastEventAt}
OR (sr."lastEventAt" = ${cursorPivot.lastEventAt} AND sr."id" < ${cursorId})
)`) : Prisma.empty}
${sortDirection === "asc"
? Prisma.sql`ORDER BY sr."lastEventAt" ASC, sr."id" ASC`
: Prisma.sql`ORDER BY sr."lastEventAt" DESC, sr."id" DESC`}
LIMIT ${limit + 1}
`;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
import { stringCompare } from "@stackframe/stack-shared/dist/utils/strings";

// Binary search: index of the first item whose id > cursor, in an
// array already sorted by `stringCompare(a.id, b.id)`.
function firstIndexAfter<T extends { id: string }>(sorted: T[], cursor: string): number {
let lo = 0;
let hi = sorted.length;
while (lo < hi) {
const mid = (lo + hi) >>> 1;
if (stringCompare(sorted[mid].id, cursor) <= 0) lo = mid + 1;
else hi = mid;
}
return lo;
}

type PermissionDefinition = {
id: string,
description?: string,
contained_permission_ids: string[],
};

type ListQuery = {
limit?: number,
cursor?: string,
query?: string,
};

export const permissionDefinitionsListQuerySchema = yupObject({
limit: yupNumber().integer().min(1).max(200).optional().meta({ openapiField: { onlyShowInOperations: ['List'], description: "Maximum number of items to return (capped at 200). When set, the response is paginated via cursor." } }),
cursor: yupString().optional().meta({ openapiField: { onlyShowInOperations: ['List'], description: "Cursor (permission id) to start the next page from. Requires `limit` to also be set." } }),
query: yupString().optional().meta({ openapiField: { onlyShowInOperations: ['List'], description: "Free-text filter applied to permission id and description (case-insensitive)." } }),
});

export function paginatePermissionDefinitions(items: PermissionDefinition[], query: ListQuery) {
if (query.cursor != null && query.limit === undefined) {
throw new StatusError(StatusError.BadRequest, "`cursor` requires `limit` to also be set.");
}

const search = query.query?.trim().toLowerCase();
const filtered = (search
? items.filter((p) =>
p.id.toLowerCase().includes(search)
|| (p.description?.toLowerCase().includes(search) ?? false))
: items.slice()
).sort((a, b) => stringCompare(a.id, b.id));

if (query.limit === undefined) {
return { items: filtered, is_paginated: false as const };
}

let startIdx = 0;
if (query.cursor != null) {
const cursorIdx = filtered.findIndex((p) => p.id === query.cursor);
// If the cursor row was deleted (or filtered out) between page
// requests, fall back to "first id strictly greater than the cursor"
// rather than 400'ing the client mid-scroll. Worst case the user
// sees a one-row gap; the alternative is a hard error on infinite
// scroll for any concurrent edit.
startIdx = cursorIdx === -1
? firstIndexAfter(filtered, query.cursor)
: cursorIdx + 1;
}
const slice = filtered.slice(startIdx, startIdx + query.limit);
const hasMore = startIdx + query.limit < filtered.length;

return {
items: slice,
is_paginated: true as const,
pagination: {
next_cursor: hasMore && slice.length > 0 ? slice[slice.length - 1].id : null,
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@ import { createCrudHandlers } from "@/route-handlers/crud-handler";
import { projectPermissionDefinitionsCrud } from '@stackframe/stack-shared/dist/interface/crud/project-permissions';
import { permissionDefinitionIdSchema, yupObject } from "@stackframe/stack-shared/dist/schema-fields";
import { createLazyProxy } from "@stackframe/stack-shared/dist/utils/proxies";
import { paginatePermissionDefinitions, permissionDefinitionsListQuerySchema } from "../permission-definitions-pagination";


export const projectPermissionDefinitionsCrudHandlers = createLazyProxy(() => createCrudHandlers(projectPermissionDefinitionsCrud, {
paramsSchema: yupObject({
permission_id: permissionDefinitionIdSchema.defined(),
}),
querySchema: permissionDefinitionsListQuerySchema,
async onCreate({ auth, data }) {
return await createPermissionDefinition(
globalPrismaClient,
Expand Down Expand Up @@ -45,13 +47,11 @@ export const projectPermissionDefinitionsCrudHandlers = createLazyProxy(() => cr
}
);
},
async onList({ auth }) {
return {
items: await listPermissionDefinitions({
scope: "project",
tenancy: auth.tenancy,
}),
is_paginated: false,
};
async onList({ auth, query }) {
const all = await listPermissionDefinitions({
scope: "project",
tenancy: auth.tenancy,
});
return paginatePermissionDefinitions(all, query);
},
}));
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ import { createCrudHandlers } from "@/route-handlers/crud-handler";
import { teamPermissionDefinitionsCrud } from '@stackframe/stack-shared/dist/interface/crud/team-permissions';
import { permissionDefinitionIdSchema, yupObject } from "@stackframe/stack-shared/dist/schema-fields";
import { createLazyProxy } from "@stackframe/stack-shared/dist/utils/proxies";
import { paginatePermissionDefinitions, permissionDefinitionsListQuerySchema } from "../permission-definitions-pagination";

export const teamPermissionDefinitionsCrudHandlers = createLazyProxy(() => createCrudHandlers(teamPermissionDefinitionsCrud, {
paramsSchema: yupObject({
permission_id: permissionDefinitionIdSchema.defined(),
}),
querySchema: permissionDefinitionsListQuerySchema,
async onCreate({ auth, data }) {
return await createPermissionDefinition(
globalPrismaClient,
Expand Down Expand Up @@ -48,13 +50,11 @@ export const teamPermissionDefinitionsCrudHandlers = createLazyProxy(() => creat
}
);
},
async onList({ auth }) {
return {
items: await listPermissionDefinitions({
scope: "team",
tenancy: auth.tenancy,
}),
is_paginated: false,
};
async onList({ auth, query }) {
const all = await listPermissionDefinitions({
scope: "team",
tenancy: auth.tenancy,
});
return paginatePermissionDefinitions(all, query);
},
}));
59 changes: 55 additions & 4 deletions apps/backend/src/app/api/latest/teams/crud.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,12 @@ import { runAsynchronouslyAndWaitUntil } from "@/utils/background-tasks";
import { Prisma, PurchaseCreationSource } from "@/generated/prisma/client";
import { KnownErrors } from "@stackframe/stack-shared";
import { teamsCrud } from "@stackframe/stack-shared/dist/interface/crud/teams";
import { userIdOrMeSchema, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { userIdOrMeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { validateBase64Image } from "@stackframe/stack-shared/dist/utils/base64";
import { StatusError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { getOrUndefined } from "@stackframe/stack-shared/dist/utils/objects";
import { createLazyProxy } from "@stackframe/stack-shared/dist/utils/proxies";
import { isUuid } from "@stackframe/stack-shared/dist/utils/uuids";
import { addUserToTeam } from "../team-memberships/crud";


Expand All @@ -35,6 +36,11 @@ export const teamsCrudHandlers = createLazyProxy(() => createCrudHandlers(teamsC
user_id: userIdOrMeSchema.optional().meta({ openapiField: { onlyShowInOperations: ['List'], description: 'Filter for the teams that the user is a member of. Can be either `me` or an ID. Must be `me` in the client API', exampleValue: 'me' } }),
/** @deprecated use creator_user_id in the body instead */
add_current_user: yupString().oneOf(["true", "false"]).optional().meta({ openapiField: { onlyShowInOperations: ['Create'], hidden: true } }),
order_by: yupString().oneOf(["created_at"]).optional().meta({ openapiField: { onlyShowInOperations: ['List'], description: 'Field to order results by. Currently only `created_at` is supported.', exampleValue: 'created_at' } }),
desc: yupString().oneOf(["true", "false"]).optional().meta({ openapiField: { onlyShowInOperations: ['List'], description: 'Whether to order results in descending order. Defaults to false (ascending).', exampleValue: 'false' } }),
limit: yupNumber().integer().min(1).max(200).optional().meta({ openapiField: { onlyShowInOperations: ['List'], description: 'The maximum number of items to return (capped at 200).' } }),
cursor: yupString().uuid().optional().meta({ openapiField: { onlyShowInOperations: ['List'], description: 'The cursor to start the result set from. Requires `limit` to also be set.' } }),
query: yupString().optional().meta({ openapiField: { onlyShowInOperations: ['List'], description: "A search query to filter the results by. Free-text search applied to the team's id (exact-match) and display name." } }),
}),
paramsSchema: yupObject({
team_id: yupString().uuid().defined(),
Expand Down Expand Up @@ -273,7 +279,28 @@ export const teamsCrudHandlers = createLazyProxy(() => createCrudHandlers(teamsC
}
}

if (query.cursor && !query.limit) {
throw new StatusError(StatusError.BadRequest, "`cursor` requires `limit` to also be set.");
}

const prisma = await getPrismaClientForTenancy(auth.tenancy);
const sortDirection = query.desc === 'true' ? 'desc' : 'asc';

let queryFilter: Prisma.TeamWhereInput | undefined;
if (query.query) {
queryFilter = {
OR: [
...isUuid(query.query) ? [{ teamId: { equals: query.query } }] : [],
Comment thread
mantrakp04 marked this conversation as resolved.
{
displayName: {
contains: query.query,
mode: 'insensitive' as const,
},
},
],
};
}

const db = await prisma.team.findMany({
where: {
tenancyId: auth.tenancy.id,
Expand All @@ -284,12 +311,36 @@ export const teamsCrudHandlers = createLazyProxy(() => createCrudHandlers(teamsC
},
},
} : {},
...queryFilter ?? {},
},
orderBy: {
createdAt: 'asc',
},
orderBy: [
{ createdAt: sortDirection },
{ teamId: sortDirection },
],
take: query.limit ? query.limit + 1 : undefined,
...query.cursor ? {
Comment thread
mantrakp04 marked this conversation as resolved.
skip: 1,
cursor: {
tenancyId_teamId: {
tenancyId: auth.tenancy.id,
teamId: query.cursor,
},
},
} : {},
});

if (query.limit) {
const items = db.slice(0, query.limit).map(teamPrismaToCrud);
const hasMore = db.length > query.limit;
return {
items,
is_paginated: true,
pagination: {
next_cursor: hasMore && items.length > 0 ? items[items.length - 1].id : null,
},
};
}

return {
items: db.map(teamPrismaToCrud),
is_paginated: false,
Expand Down
Loading
Loading