Skip to content

Commit 50430fb

Browse files
authored
Merge branch 'dev' into fix/onboarding-migration-test-and-metrics-snapshot
2 parents 6306300 + 6724bc6 commit 50430fb

70 files changed

Lines changed: 3895 additions & 486 deletions

File tree

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

apps/backend/src/app/api/latest/internal/local-emulator/project/route.tsx

Lines changed: 102 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@ import {
44
LOCAL_EMULATOR_ADMIN_USER_ID,
55
LOCAL_EMULATOR_ONLY_ENDPOINT_MESSAGE,
66
LOCAL_EMULATOR_OWNER_TEAM_ID,
7-
isLocalEmulatorOnboardingEnabledInConfig,
87
isLocalEmulatorEnabled,
8+
isLocalEmulatorOnboardingEnabledInConfig,
99
readConfigFromFile,
1010
resolveEmulatorPath,
11+
writeConfigToFile,
1112
writeShowOnboardingConfigToFile,
1213
} from "@/lib/local-emulator";
1314
import { DEFAULT_BRANCH_ID, getSoleTenancyFromProjectBranch } from "@/lib/tenancies";
@@ -18,6 +19,7 @@ import {
1819
projectOnboardingStatusSchema,
1920
projectOnboardingStatusValues,
2021
type ProjectOnboardingStatus,
22+
yupArray,
2123
yupBoolean,
2224
yupNumber,
2325
yupObject,
@@ -37,6 +39,14 @@ function isProjectOnboardingStatus(value: string): value is ProjectOnboardingSta
3739
return projectOnboardingStatusValues.some((status) => status === value);
3840
}
3941

42+
function deriveDisplayLabel(absoluteFilePath: string): string {
43+
const base = path.basename(absoluteFilePath);
44+
if (base.toLowerCase() === "stack.config.ts") {
45+
return path.basename(path.dirname(absoluteFilePath)) || base;
46+
}
47+
return base;
48+
}
49+
4050
async function assertLocalEmulatorOwnerTeamReadiness() {
4151
const internalTenancy = await getSoleTenancyFromProjectBranch("internal", DEFAULT_BRANCH_ID);
4252
const internalPrisma = await getPrismaClientForTenancy(internalTenancy);
@@ -90,7 +100,7 @@ async function getOrCreateLocalEmulatorProjectId(absoluteFilePath: string): Prom
90100
update: {},
91101
create: {
92102
id: projectId,
93-
displayName: `Local Emulator: ${path.basename(absoluteFilePath) || "Project"}`,
103+
displayName: `Local Emulator: ${deriveDisplayLabel(absoluteFilePath) || "Project"}`,
94104
description: `Local emulator project for ${absoluteFilePath}`,
95105
isProductionMode: false,
96106
ownerTeamId: LOCAL_EMULATOR_OWNER_TEAM_ID,
@@ -287,14 +297,30 @@ export const POST = createSmartRouteHandler({
287297
if (!isLocalEmulatorEnabled()) {
288298
throw new StatusError(StatusError.BadRequest, LOCAL_EMULATOR_ONLY_ENDPOINT_MESSAGE);
289299
}
290-
if (!path.isAbsolute(req.body.absolute_file_path)) {
291-
throw new StatusError(StatusError.BadRequest, "absolute_file_path must be an absolute path.");
300+
if (!path.posix.isAbsolute(req.body.absolute_file_path)) {
301+
const looksWindows = path.win32.isAbsolute(req.body.absolute_file_path);
302+
throw new StatusError(
303+
StatusError.BadRequest,
304+
looksWindows
305+
? "absolute_file_path must be a POSIX absolute path. The local emulator runs in a Linux VM and does not accept Windows-style paths. Use the in-VM path or run the emulator from WSL."
306+
: "absolute_file_path must be an absolute path.",
307+
);
292308
}
293309

294-
const absoluteFilePath = path.resolve(req.body.absolute_file_path);
295-
const resolvedFilePath = resolveEmulatorPath(absoluteFilePath);
310+
const inputPath = path.resolve(req.body.absolute_file_path);
311+
let inputStat;
312+
try {
313+
inputStat = await fs.stat(resolveEmulatorPath(inputPath));
314+
} catch {
315+
inputStat = undefined;
316+
}
296317

297-
// Validate file exists before creating a project
318+
const looksLikeConfigFile = /\.(ts|js|mjs)$/i.test(inputPath);
319+
const absoluteFilePath = (inputStat?.isDirectory() || (!inputStat && !looksLikeConfigFile))
320+
? path.join(inputPath, "stack.config.ts")
321+
: inputPath;
322+
323+
const resolvedFilePath = resolveEmulatorPath(absoluteFilePath);
298324
let fileExists: boolean;
299325
try {
300326
await fs.access(resolvedFilePath);
@@ -303,7 +329,7 @@ export const POST = createSmartRouteHandler({
303329
fileExists = false;
304330
}
305331
if (!fileExists) {
306-
throw new StatusError(StatusError.BadRequest, `Config file not found: ${absoluteFilePath}`);
332+
await writeConfigToFile(absoluteFilePath, {});
307333
}
308334

309335
const fileContent = await fs.readFile(resolvedFilePath, "utf-8");
@@ -335,3 +361,71 @@ export const POST = createSmartRouteHandler({
335361
};
336362
},
337363
});
364+
365+
type LocalEmulatorProjectListRow = {
366+
projectId: string,
367+
absoluteFilePath: string,
368+
updatedAt: Date,
369+
};
370+
371+
export const GET = createSmartRouteHandler({
372+
metadata: {
373+
hidden: true,
374+
summary: "List recent local emulator projects",
375+
description: "Returns previously opened local emulator project mappings, most-recent first.",
376+
tags: ["Local Emulator"],
377+
},
378+
request: yupObject({
379+
auth: yupObject({
380+
type: clientOrHigherAuthTypeSchema.defined(),
381+
project: yupObject({
382+
id: yupString().oneOf(["internal"]).defined(),
383+
}).defined(),
384+
}).defined(),
385+
method: yupString().oneOf(["GET"]).defined(),
386+
}),
387+
response: yupObject({
388+
statusCode: yupNumber().oneOf([200]).defined(),
389+
bodyType: yupString().oneOf(["json"]).defined(),
390+
body: yupObject({
391+
projects: yupArray(yupObject({
392+
project_id: yupString().defined(),
393+
absolute_file_path: yupString().defined(),
394+
display_name: yupString().defined(),
395+
}).defined()).defined(),
396+
}).defined(),
397+
}),
398+
handler: async () => {
399+
if (!isLocalEmulatorEnabled()) {
400+
throw new StatusError(StatusError.BadRequest, LOCAL_EMULATOR_ONLY_ENDPOINT_MESSAGE);
401+
}
402+
403+
const rows = await globalPrismaClient.$queryRaw<LocalEmulatorProjectListRow[]>(Prisma.sql`
404+
SELECT "projectId", "absoluteFilePath", "updatedAt"
405+
FROM "LocalEmulatorProject"
406+
ORDER BY "updatedAt" DESC
407+
LIMIT 20
408+
`);
409+
410+
const projectIds = rows.map((r) => r.projectId);
411+
const projects = projectIds.length > 0
412+
? await globalPrismaClient.project.findMany({
413+
where: { id: { in: projectIds } },
414+
select: { id: true, displayName: true },
415+
})
416+
: [];
417+
const displayNameById = new Map(projects.map((p) => [p.id, p.displayName]));
418+
419+
return {
420+
statusCode: 200 as const,
421+
bodyType: "json" as const,
422+
body: {
423+
projects: rows.map((r) => ({
424+
project_id: r.projectId,
425+
absolute_file_path: r.absoluteFilePath,
426+
display_name: displayNameById.get(r.projectId) ?? deriveDisplayLabel(r.absoluteFilePath),
427+
})),
428+
},
429+
};
430+
},
431+
});
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+
});

0 commit comments

Comments
 (0)