Skip to content

Commit 07b8b41

Browse files
authored
Merge branch 'dev' into cli-exec-changes
2 parents 00e17be + 6724bc6 commit 07b8b41

61 files changed

Lines changed: 3230 additions & 315 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,10 +140,12 @@ packages/js/*
140140
packages/react/*
141141
packages/next/*
142142
packages/stack/*
143+
packages/tanstack-start/*
143144
!packages/js/package.json
144145
!packages/react/package.json
145146
!packages/next/package.json
146147
!packages/stack/package.json
148+
!packages/tanstack-start/package.json
147149

148150
# claude code
149151
.claude/scheduled_tasks.lock

apps/backend/src/app/api/latest/auth/cli/poll/route.tsx

Lines changed: 43 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
1-
import { getPrismaClientForTenancy } from "@/prisma-client";
1+
import { Prisma } from "@/generated/prisma/client";
2+
import { getPrismaClientForTenancy, getPrismaSchemaForTenancy, sqlQuoteIdent } from "@/prisma-client";
23
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
34
import { KnownErrors } from "@stackframe/stack-shared";
45
import { adaptSchema, clientOrHigherAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
56

7+
type CliAuthAttemptRow = {
8+
id: string,
9+
refreshToken: string | null,
10+
expiresAt: Date,
11+
usedAt: Date | null,
12+
};
13+
614
// Helper function to create response
715
const createResponse = (status: 'waiting' | 'success' | 'expired' | 'used', refreshToken?: string) => ({
816
statusCode: status === 'success' ? 201 : 200,
@@ -38,44 +46,55 @@ export const POST = createSmartRouteHandler({
3846
}),
3947
async handler({ auth: { tenancy }, body: { polling_code } }) {
4048
const prisma = await getPrismaClientForTenancy(tenancy);
49+
const schema = await getPrismaSchemaForTenancy(tenancy);
4150

42-
// Find the CLI auth attempt
43-
const cliAuth = await prisma.cliAuthAttempt.findFirst({
44-
where: {
45-
tenancyId: tenancy.id,
46-
pollingCode: polling_code,
47-
},
48-
});
51+
const cliAuthRows = await prisma.$queryRaw<CliAuthAttemptRow[]>(Prisma.sql`
52+
SELECT
53+
"id",
54+
"refreshToken",
55+
"expiresAt",
56+
"usedAt"
57+
FROM ${sqlQuoteIdent(schema)}."CliAuthAttempt"
58+
WHERE "tenancyId" = ${tenancy.id}::UUID
59+
AND "pollingCode" = ${polling_code}
60+
LIMIT 1
61+
`);
4962

50-
if (!cliAuth) {
63+
if (cliAuthRows.length === 0) {
5164
throw new KnownErrors.InvalidPollingCodeError();
5265
}
66+
const cliAuth = cliAuthRows[0];
5367

5468
if (cliAuth.expiresAt < new Date()) {
5569
return createResponse('expired');
5670
}
5771

58-
if (cliAuth.usedAt) {
72+
if (cliAuth.usedAt !== null) {
5973
return createResponse('used');
6074
}
6175

62-
if (!cliAuth.refreshToken) {
76+
if (cliAuth.refreshToken === null) {
6377
return createResponse('waiting');
6478
}
6579

66-
// Mark as used
67-
await prisma.cliAuthAttempt.update({
68-
where: {
69-
tenancyId_id: {
70-
tenancyId: tenancy.id,
71-
id: cliAuth.id,
72-
},
73-
},
74-
data: {
75-
usedAt: new Date(),
76-
},
77-
});
80+
// Atomically mark as used, claiming the row only if no one else has.
81+
// This prevents a TOCTOU race where two concurrent polls could both
82+
// read usedAt = null and both receive the same refresh token.
83+
const claimed = await prisma.$queryRaw<{ refreshToken: string }[]>(Prisma.sql`
84+
UPDATE ${sqlQuoteIdent(schema)}."CliAuthAttempt"
85+
SET
86+
"usedAt" = NOW(),
87+
"updatedAt" = NOW()
88+
WHERE "tenancyId" = ${tenancy.id}::UUID
89+
AND "id" = ${cliAuth.id}::UUID
90+
AND "usedAt" IS NULL
91+
RETURNING "refreshToken"
92+
`);
93+
94+
if (claimed.length === 0) {
95+
return createResponse('used');
96+
}
7897

79-
return createResponse('success', cliAuth.refreshToken);
98+
return createResponse('success', claimed[0].refreshToken);
8099
},
81100
});

apps/backend/src/app/api/latest/auth/cli/route.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export const POST = createSmartRouteHandler({
2525
tenancy: adaptSchema.defined(),
2626
}).defined(),
2727
body: yupObject({
28-
expires_in_millis: yupNumber().max(1000 * 60 * 60 * 24).default(1000 * 60 * 120), // Default: 2 hours, max: 24 hours
28+
expires_in_millis: yupNumber().max(1000 * 60 * 15).default(1000 * 60 * 2), // Default: 2 minutes, max: 15 minutes
2929
anon_refresh_token: yupString().optional(),
3030
}).default({}),
3131
}),
@@ -41,8 +41,7 @@ export const POST = createSmartRouteHandler({
4141
async handler({ auth: { tenancy }, body: { expires_in_millis, anon_refresh_token } }) {
4242
let anonRefreshToken: string | null = null;
4343

44-
if (anon_refresh_token) {
45-
// ProjectUserRefreshToken lives in the global DB (see tokens.tsx and oauth/model.tsx).
44+
if (anon_refresh_token != null) {
4645
const refreshTokenRows = await globalPrismaClient.$queryRaw<RefreshTokenRow[]>(Prisma.sql`
4746
SELECT "tenancyId", "projectUserId", "expiresAt"
4847
FROM "ProjectUserRefreshToken"
@@ -58,7 +57,7 @@ export const POST = createSmartRouteHandler({
5857
throw new StatusError(400, "Anon refresh token does not belong to this project");
5958
}
6059

61-
if (refreshTokenObj.expiresAt && refreshTokenObj.expiresAt < new Date()) {
60+
if (refreshTokenObj.expiresAt != null && refreshTokenObj.expiresAt < new Date()) {
6261
throw new StatusError(400, "The provided anon refresh token has expired");
6362
}
6463

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { describe, expect, it } from "vitest";
2+
import { applyProjectWeeklyUsersRows } from "./route";
3+
4+
describe("internal projects weekly users helpers", () => {
5+
it("applies ClickHouse rows through a Map and skips unknown projects", () => {
6+
const byProject = new Map([
7+
["project-a", {
8+
weekly_users: 0,
9+
daily_users: [
10+
{ date: "2026-05-01", activity: 0 },
11+
{ date: "2026-05-02", activity: 0 },
12+
],
13+
}],
14+
["__proto__", {
15+
weekly_users: 0,
16+
daily_users: [
17+
{ date: "2026-05-01", activity: 0 },
18+
{ date: "2026-05-02", activity: 0 },
19+
],
20+
}],
21+
]);
22+
23+
applyProjectWeeklyUsersRows(
24+
byProject,
25+
[
26+
{ projectId: "project-a", day: "1970-01-01", users: 4 },
27+
{ projectId: "__proto__", day: "1970-01-01", users: 7 },
28+
{ projectId: "missing-project", day: "1970-01-01", users: 99 },
29+
{ projectId: "project-a", day: "2026-05-01", users: 2 },
30+
{ projectId: "__proto__", day: "2026-05-02", users: 5 },
31+
{ projectId: "missing-project", day: "2026-05-01", users: 99 },
32+
],
33+
);
34+
35+
expect(Object.fromEntries(byProject)).toMatchInlineSnapshot(`
36+
{
37+
"__proto__": {
38+
"daily_users": [
39+
{
40+
"activity": 0,
41+
"date": "2026-05-01",
42+
},
43+
{
44+
"activity": 5,
45+
"date": "2026-05-02",
46+
},
47+
],
48+
"weekly_users": 7,
49+
},
50+
"project-a": {
51+
"daily_users": [
52+
{
53+
"activity": 2,
54+
"date": "2026-05-01",
55+
},
56+
{
57+
"activity": 0,
58+
"date": "2026-05-02",
59+
},
60+
],
61+
"weekly_users": 4,
62+
},
63+
}
64+
`);
65+
});
66+
});

apps/backend/src/app/api/latest/internal/projects-dau/route.tsx renamed to apps/backend/src/app/api/latest/internal/projects-weekly-users/route.tsx

Lines changed: 71 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,46 @@ import { StackAssertionError, captureError } from "@stackframe/stack-shared/dist
1010
const WINDOW_DAYS = 7;
1111
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
1212

13+
type ProjectWeeklyUsers = {
14+
weekly_users: number,
15+
daily_users: { date: string, activity: number }[],
16+
};
17+
18+
export function applyProjectWeeklyUsersRows(
19+
byProject: Map<string, ProjectWeeklyUsers>,
20+
rows: { projectId: string, day: string, users: number }[],
21+
) {
22+
// GROUPING SETS emits one rollup row per project with day defaulted to the
23+
// ClickHouse Date epoch ("1970-01-01"); those rows hold the weekly total.
24+
const dailyIndex = new Map<string, Map<string, number>>();
25+
for (const row of rows) {
26+
const project = byProject.get(row.projectId);
27+
if (project == null) {
28+
continue;
29+
}
30+
const dayKey = row.day.split("T")[0];
31+
if (dayKey === "1970-01-01") {
32+
project.weekly_users = Number(row.users);
33+
continue;
34+
}
35+
let m = dailyIndex.get(row.projectId);
36+
if (!m) {
37+
m = new Map();
38+
dailyIndex.set(row.projectId, m);
39+
}
40+
m.set(dayKey, Number(row.users));
41+
}
42+
43+
for (const [id, project] of byProject) {
44+
const m = dailyIndex.get(id);
45+
if (!m) continue;
46+
project.daily_users = project.daily_users.map((point) => ({
47+
date: point.date,
48+
activity: m.get(point.date) ?? 0,
49+
}));
50+
}
51+
}
52+
1353
export const GET = createSmartRouteHandler({
1454
metadata: { hidden: true },
1555
request: yupObject({
@@ -24,7 +64,10 @@ export const GET = createSmartRouteHandler({
2464
statusCode: yupNumber().oneOf([200]).defined(),
2565
bodyType: yupString().oneOf(["json"]).defined(),
2666
body: yupObject({
27-
projects: yupRecord(yupString().defined(), MetricsDataPointsSchema).defined(),
67+
projects: yupRecord(yupString().defined(), yupObject({
68+
weekly_users: yupNumber().integer().defined(),
69+
daily_users: MetricsDataPointsSchema,
70+
}).defined()).defined(),
2871
}).defined(),
2972
}),
3073
handler: async (req) => {
@@ -52,28 +95,39 @@ export const GET = createSmartRouteHandler({
5295
return out;
5396
};
5497

55-
const byProject: Record<string, { date: string, activity: number }[]> = {};
98+
const byProject = new Map<string, ProjectWeeklyUsers>();
5699
for (const id of projectIds) {
57-
byProject[id] = emptySeries();
100+
byProject.set(id, {
101+
weekly_users: 0,
102+
daily_users: emptySeries(),
103+
});
58104
}
105+
const projectsResponse = () => Object.fromEntries(byProject);
59106

60107
if (projectIds.length === 0) {
61108
return {
62109
statusCode: 200,
63110
bodyType: "json",
64-
body: { projects: byProject },
111+
body: { projects: projectsResponse() },
65112
};
66113
}
67114

68-
let rows: { projectId: string, day: string, dau: number }[] = [];
115+
const clickhouseClient = getClickhouseAdminClient();
116+
const queryParams = {
117+
projectIds,
118+
branchId: DEFAULT_BRANCH_ID,
119+
since: since.toISOString().slice(0, 19),
120+
untilExclusive: untilExclusive.toISOString().slice(0, 19),
121+
};
122+
123+
let rows: { projectId: string, day: string, users: number }[] = [];
69124
try {
70-
const clickhouseClient = getClickhouseAdminClient();
71125
const result = await clickhouseClient.query({
72126
query: `
73127
SELECT
74128
project_id AS projectId,
75-
toDate(event_at) AS day,
76-
uniqExact(assumeNotNull(user_id)) AS dau
129+
toDate(event_at, 'UTC') AS day,
130+
uniqExact(assumeNotNull(user_id)) AS users
77131
FROM analytics_internal.events
78132
WHERE event_type = '$token-refresh'
79133
AND project_id IN {projectIds:Array(String)}
@@ -82,55 +136,33 @@ export const GET = createSmartRouteHandler({
82136
AND event_at >= {since:DateTime}
83137
AND event_at < {untilExclusive:DateTime}
84138
AND coalesce(CAST(data.is_anonymous, 'Nullable(UInt8)'), 0) = 0
85-
GROUP BY projectId, day
139+
GROUP BY GROUPING SETS ((projectId), (projectId, day))
86140
`,
87-
query_params: {
88-
projectIds,
89-
branchId: DEFAULT_BRANCH_ID,
90-
since: since.toISOString().slice(0, 19),
91-
untilExclusive: untilExclusive.toISOString().slice(0, 19),
92-
},
141+
query_params: queryParams,
93142
format: "JSONEachRow",
94143
});
95-
rows = await result.json();
144+
rows = await result.json<{ projectId: string, day: string, users: number }>();
96145
} catch (error) {
97146
const captureId = error instanceof ClickHouseError
98-
? "internal-projects-dau-clickhouse-error"
99-
: "internal-projects-dau-unexpected-error";
147+
? "internal-projects-weekly-users-clickhouse-error"
148+
: "internal-projects-weekly-users-unexpected-error";
100149
captureError(captureId, new StackAssertionError(
101-
"Failed to load projects DAU.",
150+
"Failed to load projects weekly users.",
102151
{ cause: error, projectCount: projectIds.length },
103152
));
104153
return {
105154
statusCode: 200,
106155
bodyType: "json",
107-
body: { projects: byProject },
156+
body: { projects: projectsResponse() },
108157
};
109158
}
110-
const index = new Map<string, Map<string, number>>();
111-
for (const row of rows) {
112-
const dayKey = row.day.split("T")[0];
113-
let m = index.get(row.projectId);
114-
if (!m) {
115-
m = new Map();
116-
index.set(row.projectId, m);
117-
}
118-
m.set(dayKey, Number(row.dau));
119-
}
120159

121-
for (const id of projectIds) {
122-
const m = index.get(id);
123-
if (!m) continue;
124-
byProject[id] = byProject[id].map((point) => ({
125-
date: point.date,
126-
activity: m.get(point.date) ?? 0,
127-
}));
128-
}
160+
applyProjectWeeklyUsersRows(byProject, rows);
129161

130162
return {
131163
statusCode: 200,
132164
bodyType: "json",
133-
body: { projects: byProject },
165+
body: { projects: projectsResponse() },
134166
};
135167
},
136168
});
14.2 KB
Loading

0 commit comments

Comments
 (0)