Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .github/workflows/docker-server-build-run.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ jobs:
- name: Run Docker container and check logs
run: |
docker run --add-host=host.docker.internal:host-gateway --env-file docker/server/.env.example -p 8101:8101 -p 8102:8102 -d --name stackframe-server server
sleep 60
sleep 120
docker logs -t stackframe-server

- name: Check server health
Expand Down
2 changes: 2 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
"typescript.tsdk": "node_modules/typescript/lib",
"editor.tabSize": 2,
"cSpell.words": [
"sparkline",
"Clickhouse",
"pushable",
"autoupdate",
"backlinks",
Expand Down
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,10 @@ To see all development ports, refer to the index.html of `apps/dev-launchpad/pub
- If there is an external browser tool connected, use it to test changes you make to the frontend when possible.
- Whenever you update an SDK implementation in `sdks/implementations`, make sure to update the specs accordingly in `sdks/specs` such that if you reimplemented the entire SDK from the specs again, you would get the same implementation. (For example, if the specs are not precise enough to describe a change you made, make the specs more precise.)
- 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.
- 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.
- Do NOT use `as`/`any`/type casts or anything else like that to bypass the type system unless you specifically asked the user about it. Most of the time a place where you would use type casts is not one where you actually need them. Avoid wherever possible.

### Code-related
- Use ES6 maps instead of records wherever you can.
1 change: 1 addition & 0 deletions apps/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@
"@vercel/sandbox": "^1.2.0",
"ai": "^4.3.17",
"bcrypt": "^5.1.1",
"cel-js": "^0.8.2",
"chokidar-cli": "^3.0.0",
"dotenv": "^16.4.5",
"dotenv-cli": "^7.3.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- Add restricted by admin fields to ProjectUser
ALTER TABLE "ProjectUser" ADD COLUMN "restrictedByAdmin" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "ProjectUser" ADD COLUMN "restrictedByAdminReason" TEXT;
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
-- Add restrictedByAdminPrivateDetails column
ALTER TABLE "ProjectUser" ADD COLUMN "restrictedByAdminPrivateDetails" TEXT;

-- Add constraint: When restrictedByAdmin is false, both reason and private details must be null
-- When restrictedByAdmin is true, reason and private details are optional
ALTER TABLE "ProjectUser" ADD CONSTRAINT "ProjectUser_restricted_by_admin_consistency"
CHECK (
("restrictedByAdmin" = true) OR
("restrictedByAdmin" = false AND "restrictedByAdminReason" IS NULL AND "restrictedByAdminPrivateDetails" IS NULL)
);
5 changes: 5 additions & 0 deletions apps/backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,11 @@ model ProjectUser {
totpSecret Bytes?
isAnonymous Boolean @default(false)

// Admin restriction fields - can be set by signup rules or manually by admins
restrictedByAdmin Boolean @default(false)
restrictedByAdminReason String? // Publicly viewable reason (shown to user)
restrictedByAdminPrivateDetails String? // Private details (server access only)

projectUserOAuthAccounts ProjectUserOAuthAccount[]
teamMembers TeamMember[]
contactChannels ContactChannel[]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,12 @@ const handler = createSmartRouteHandler({
currentUser,
displayName: userInfo.displayName ?? undefined,
profileImageUrl: userInfo.profileImageUrl ?? undefined,
signUpRuleOptions: {
authMethod: 'oauth',
oauthProvider: provider.id,
// Note: Request context not easily available in OAuth callback
// TODO: Pass IP and user agent from stored OAuth state if needed
},
}
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,11 @@ export const POST = createSmartRouteHandler({
email: appleUser.email,
emailVerified: appleUser.emailVerified,
primaryEmailAuthEnabled,
signUpRuleOptions: {
authMethod: 'oauth',
oauthProvider: 'apple',
// Note: Request context not easily available in native OAuth callback
},
});
projectUserId = result.projectUserId;
isNewUser = true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { getAuthContactChannelWithEmailNormalization } from "@/lib/contact-chann
import { sendEmailFromDefaultTemplate } from "@/lib/emails";
import { getSoleTenancyFromProjectBranch, Tenancy } from "@/lib/tenancies";
import { createAuthTokens } from "@/lib/tokens";
import { createOrUpgradeAnonymousUser } from "@/lib/users";
import { createOrUpgradeAnonymousUserWithRules } from "@/lib/users";
import { getPrismaClientForTenancy } from "@/prisma-client";
import { createVerificationCodeHandler } from "@/route-handlers/verification-code-handler";
import { VerificationCodeType } from "@/generated/prisma/client";
Expand Down Expand Up @@ -105,7 +105,9 @@ export const signInVerificationCodeHandler = createVerificationCodeHandler({
let isNewUser = false;

if (!user) {
user = await createOrUpgradeAnonymousUser(
// Note: Request context (IP, user agent) is not available in verification code handler
// The rule evaluation will proceed with limited context
user = await createOrUpgradeAnonymousUserWithRules(
tenancy,
currentUser ?? null,
{
Expand All @@ -114,7 +116,11 @@ export const signInVerificationCodeHandler = createVerificationCodeHandler({
primary_email_auth_enabled: true,
otp_auth_enabled: true,
},
[]
[],
{
authMethod: 'otp',
// TODO: Pass request context when available in verification code handler
}
);
isNewUser = true;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { validateRedirectUrl } from "@/lib/redirect-urls";
import { createAuthTokens } from "@/lib/tokens";
import { createOrUpgradeAnonymousUser } from "@/lib/users";
import { createOrUpgradeAnonymousUserWithRules } from "@/lib/users";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { runAsynchronouslyAndWaitUntil } from "@/utils/vercel";
import { KnownErrors } from "@stackframe/stack-shared";
Expand Down Expand Up @@ -54,7 +54,7 @@ export const POST = createSmartRouteHandler({
throw passwordError;
}

const createdUser = await createOrUpgradeAnonymousUser(
const createdUser = await createOrUpgradeAnonymousUserWithRules(
tenancy,
currentUser ?? null,
{
Expand All @@ -63,7 +63,10 @@ export const POST = createSmartRouteHandler({
primary_email_auth_enabled: true,
password,
},
[KnownErrors.UserWithEmailAlreadyExists]
[KnownErrors.UserWithEmailAlreadyExists],
{
authMethod: 'password',
}
);

if (verificationCallbackUrl) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getClickhouseExternalClient, isClickhouseConfigured } from "@/lib/clickhouse";
import { getClickhouseExternalClient } from "@/lib/clickhouse";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { KnownErrors } from "@stackframe/stack-shared";
import { adaptSchema, adminAuthTypeSchema, jsonSchema, yupBoolean, yupMixed, yupNumber, yupObject, yupRecord, yupString } from "@stackframe/stack-shared/dist/schema-fields";
Expand Down Expand Up @@ -36,9 +36,6 @@ export const POST = createSmartRouteHandler({
if (body.include_all_branches) {
throw new StackAssertionError("include_all_branches is not supported yet");
}
if (!isClickhouseConfigured()) {
throw new StackAssertionError("ClickHouse is not configured");
}
const client = getClickhouseExternalClient();
const queryId = `${auth.tenancy.project.id}:${auth.tenancy.branchId}:${randomUUID()}`;
const resultSet = await Result.fromPromise(client.query({
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { getClickhouseExternalClient, getQueryTimingStatsForProject, isClickhouseConfigured } from "@/lib/clickhouse";
import { getClickhouseExternalClient, getQueryTimingStatsForProject } from "@/lib/clickhouse";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { KnownErrors } from "@stackframe/stack-shared";
import { adaptSchema, serverOrHigherAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors";

export const POST = createSmartRouteHandler({
metadata: { hidden: true },
Expand All @@ -26,10 +25,6 @@ export const POST = createSmartRouteHandler({
}).defined(),
}),
async handler({ body, auth }) {
if (!isClickhouseConfigured()) {
throw new StackAssertionError("ClickHouse is not configured");
}

const expectedPrefix = `${auth.tenancy.project.id}:${auth.tenancy.branchId}:`;
if (!body.query_id.startsWith(expectedPrefix)) {
throw new KnownErrors.ItemNotFound(body.query_id);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Prisma } from "@/generated/prisma/client";
import { getClickhouseAdminClient, isClickhouseConfigured } from "@/lib/clickhouse";
import { getClickhouseAdminClient } from "@/lib/clickhouse";
import { endUserIpInfoSchema, type EndUserIpInfo } from "@/lib/events";
import { DEFAULT_BRANCH_ID } from "@/lib/tenancies";
import { globalPrismaClient } from "@/prisma-client";
Expand Down Expand Up @@ -192,9 +192,6 @@ export const POST = createSmartRouteHandler({
let migratedEvents = 0;

if (events.length) {
if (!isClickhouseConfigured()) {
throw new StatusError(StatusError.ServiceUnavailable, "ClickHouse is not configured");
}
const clickhouseClient = getClickhouseAdminClient();
const rowsToInsert = events.map(createClickhouseRow);
migratedEvents = events.length;
Expand Down
136 changes: 136 additions & 0 deletions apps/backend/src/app/api/latest/internal/sign-up-rules-stats/route.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { getClickhouseAdminClient } from "@/lib/clickhouse";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { adaptSchema, adminAuthTypeSchema, yupArray, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";

const ANALYTICS_HOURS = 48;

export const GET = createSmartRouteHandler({
metadata: {
hidden: true,
},
request: yupObject({
auth: yupObject({
type: adminAuthTypeSchema.defined(),
tenancy: adaptSchema.defined(),
}),
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).defined(),
bodyType: yupString().oneOf(["json"]).defined(),
body: yupObject({
// Triggers per rule with hourly breakdown for sparklines
rule_triggers: yupArray(yupObject({
rule_id: yupString().defined(),
total_count: yupNumber().integer().defined(),
hourly_counts: yupArray(yupObject({
hour: yupString().defined(),
count: yupNumber().integer().defined(),
}).defined()).defined(),
}).defined()).defined(),
// Summary stats
total_triggers: yupNumber().integer().defined(),
triggers_by_action: yupObject({
allow: yupNumber().integer().defined(),
reject: yupNumber().integer().defined(),
restrict: yupNumber().integer().defined(),
log: yupNumber().integer().defined(),
}).defined(),
}).defined(),
}),
handler: async (req) => {
const projectId = req.auth.tenancy.project.id;
const branchId = req.auth.tenancy.branchId;

// Generate hour keys for the sparkline
const now = new Date();
now.setUTCMinutes(0, 0, 0);
const since = new Date(now.getTime() - (ANALYTICS_HOURS - 1) * 60 * 60 * 1000);
const hourKeys = Array.from({ length: ANALYTICS_HOURS }, (_, index) => {
const hour = new Date(since.getTime() + index * 60 * 60 * 1000);
return hour.toISOString().slice(0, 13) + ':00:00.000Z';
});

const client = getClickhouseAdminClient();

const result = await client.query({
query: `
SELECT
data.ruleId as rule_id,
data.action as action,
toStartOfHour(event_at) as hour
FROM analytics_internal.events
WHERE event_type = '$sign-up-rule-trigger'
AND project_id = {projectId:String}
AND branch_id = {branchId:String}
AND event_at >= {since:DateTime}
ORDER BY event_at ASC
`,
query_params: {
projectId,
branchId,
since: since.toISOString().slice(0, 19),
},
format: "JSONEachRow",
});
const rows: {
rule_id: string,
action: "allow" | "reject" | "restrict" | "log",
hour: string,
}[] = await result.json();

// Group by rule and hour for sparkline data
const ruleTriggersMap = new Map<string, {
totalCount: number,
hourlyMap: Map<string, number>,
}>();

// Summary counts by action
const actionCounts = {
allow: 0,
reject: 0,
restrict: 0,
log: 0,
};

for (const row of rows) {
// Update action counts
const action = row.action;
if (action in actionCounts) {
actionCounts[action]++;
}

// Update rule triggers
let ruleData = ruleTriggersMap.get(row.rule_id);
if (!ruleData) {
ruleData = { totalCount: 0, hourlyMap: new Map() };
ruleTriggersMap.set(row.rule_id, ruleData);
}
ruleData.totalCount++;

// Group by hour (normalize to ISO format)
// ClickHouse returns datetime without timezone, treat as UTC
const hourKey = new Date(row.hour + 'Z').toISOString().slice(0, 13) + ':00:00.000Z';
ruleData.hourlyMap.set(hourKey, (ruleData.hourlyMap.get(hourKey) ?? 0) + 1);
}

// Build hourly breakdown for each rule
const ruleTriggers = Array.from(ruleTriggersMap.entries()).map(([ruleId, data]) => ({
rule_id: ruleId,
total_count: data.totalCount,
hourly_counts: hourKeys.map((hour) => ({
hour,
count: data.hourlyMap.get(hour) ?? 0,
})),
}));

return {
statusCode: 200 as const,
bodyType: "json" as const,
body: {
rule_triggers: ruleTriggers,
total_triggers: rows.length,
triggers_by_action: actionCounts,
},
};
},
});
Loading