mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-20 21:29:36 +08:00
Various improvements
This commit is contained in:
+5
@@ -0,0 +1,5 @@
|
||||
-- SPLIT_STATEMENT_SENTINEL
|
||||
-- SINGLE_STATEMENT_SENTINEL
|
||||
-- RUN_OUTSIDE_TRANSACTION_SENTINEL
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS "CliAuthAttempt_tenancyId_createdAt_id_idx"
|
||||
ON /* SCHEMA_NAME_SENTINEL */."CliAuthAttempt"("tenancyId", "createdAt" DESC, "id" DESC);
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import type { Sql } from "postgres";
|
||||
import { expect } from "vitest";
|
||||
|
||||
export const postMigration = async (sql: Sql) => {
|
||||
const indexes = await sql`
|
||||
SELECT
|
||||
pg_get_indexdef(index_relation.oid) AS indexdef,
|
||||
index_metadata.indisvalid,
|
||||
index_metadata.indisready
|
||||
FROM pg_index index_metadata
|
||||
JOIN pg_class index_relation
|
||||
ON index_relation.oid = index_metadata.indexrelid
|
||||
JOIN pg_class table_relation
|
||||
ON table_relation.oid = index_metadata.indrelid
|
||||
JOIN pg_namespace table_namespace
|
||||
ON table_namespace.oid = table_relation.relnamespace
|
||||
WHERE table_namespace.nspname = current_schema()
|
||||
AND table_relation.relname = 'CliAuthAttempt'
|
||||
AND index_relation.relname = 'CliAuthAttempt_tenancyId_createdAt_id_idx'
|
||||
`;
|
||||
|
||||
expect(indexes).toHaveLength(1);
|
||||
expect(indexes[0]).toMatchObject({
|
||||
indisvalid: true,
|
||||
indisready: true,
|
||||
});
|
||||
expect(indexes[0].indexdef).toContain('("tenancyId", "createdAt" DESC, id DESC)');
|
||||
};
|
||||
@@ -1140,6 +1140,7 @@ model CliAuthAttempt {
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@id([tenancyId, id])
|
||||
@@index([tenancyId, createdAt(sort: Desc), id(sort: Desc)], name: "CliAuthAttempt_tenancyId_createdAt_id_idx")
|
||||
}
|
||||
|
||||
model UserNotificationPreference {
|
||||
|
||||
@@ -3,11 +3,12 @@ import { getPrismaClientForTenancy, getPrismaSchemaForTenancy, globalPrismaClien
|
||||
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
|
||||
import { adaptSchema, adminAuthTypeSchema, yupArray, yupBoolean, yupNumber, yupObject, yupString } from "@hexclave/shared/dist/schema-fields";
|
||||
|
||||
const recentAttemptLimit = 50;
|
||||
const activeTokenAttemptLimit = 200;
|
||||
|
||||
type CliAuthAttemptRow = {
|
||||
id: string,
|
||||
pollingCode: string,
|
||||
loginCode: string,
|
||||
refreshToken: string | null,
|
||||
status: "pending" | "completed" | "expired" | "used",
|
||||
expiresAt: Date,
|
||||
usedAt: Date | null,
|
||||
createdAt: Date,
|
||||
@@ -42,12 +43,14 @@ export const GET = createSmartRouteHandler({
|
||||
bodyType: yupString().oneOf(["json"]).defined(),
|
||||
body: yupObject({
|
||||
summary: yupObject({
|
||||
total_attempts: yupNumber().integer().defined(),
|
||||
completed_attempts: yupNumber().integer().defined(),
|
||||
used_attempts: yupNumber().integer().defined(),
|
||||
expired_attempts: yupNumber().integer().defined(),
|
||||
pending_attempts: yupNumber().integer().defined(),
|
||||
active_tokens: yupNumber().integer().defined(),
|
||||
attempts_in_window: yupNumber().integer().defined(),
|
||||
completed_attempts_in_window: yupNumber().integer().defined(),
|
||||
used_attempts_in_window: yupNumber().integer().defined(),
|
||||
expired_attempts_in_window: yupNumber().integer().defined(),
|
||||
pending_attempts_in_window: yupNumber().integer().defined(),
|
||||
active_tokens_in_lookup_window: yupNumber().integer().defined(),
|
||||
attempt_window_limit: yupNumber().integer().defined(),
|
||||
active_token_lookup_window_limit: yupNumber().integer().defined(),
|
||||
}).defined(),
|
||||
recent_attempts: yupArray(yupObject({
|
||||
id: yupString().defined(),
|
||||
@@ -73,72 +76,72 @@ export const GET = createSmartRouteHandler({
|
||||
const schema = await getPrismaSchemaForTenancy(tenancy);
|
||||
const now = new Date();
|
||||
|
||||
// Fetch recent CLI auth attempts (last 50)
|
||||
// These dashboard metrics intentionally describe a bounded recent window.
|
||||
// Exact all-time aggregates would scan unbounded history on every page load.
|
||||
const recentAttempts = await prisma.$replica().$queryRaw<CliAuthAttemptRow[]>(Prisma.sql`
|
||||
SELECT
|
||||
"id",
|
||||
"pollingCode",
|
||||
"loginCode",
|
||||
"refreshToken",
|
||||
CASE
|
||||
WHEN "usedAt" IS NOT NULL THEN 'used'
|
||||
WHEN "expiresAt" < ${now} THEN 'expired'
|
||||
WHEN "refreshToken" IS NOT NULL THEN 'completed'
|
||||
ELSE 'pending'
|
||||
END AS "status",
|
||||
"expiresAt",
|
||||
"usedAt",
|
||||
"createdAt"
|
||||
FROM ${sqlQuoteIdent(schema)}."CliAuthAttempt"
|
||||
WHERE "tenancyId" = ${tenancy.id}::UUID
|
||||
ORDER BY "createdAt" DESC
|
||||
LIMIT 50
|
||||
ORDER BY "createdAt" DESC, "id" DESC
|
||||
LIMIT ${recentAttemptLimit}
|
||||
`);
|
||||
|
||||
// Compute summary stats
|
||||
let used = 0;
|
||||
let completed = 0;
|
||||
let expired = 0;
|
||||
let pending = 0;
|
||||
for (const attempt of recentAttempts) {
|
||||
if (attempt.usedAt != null) {
|
||||
used++;
|
||||
} else if (attempt.expiresAt < now) {
|
||||
expired++;
|
||||
} else if (attempt.refreshToken != null) {
|
||||
completed++;
|
||||
} else {
|
||||
pending++;
|
||||
switch (attempt.status) {
|
||||
case "used": {
|
||||
used++;
|
||||
break;
|
||||
}
|
||||
case "completed": {
|
||||
completed++;
|
||||
break;
|
||||
}
|
||||
case "expired": {
|
||||
expired++;
|
||||
break;
|
||||
}
|
||||
case "pending": {
|
||||
pending++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Format recent attempts with status
|
||||
const formattedAttempts = recentAttempts.map((attempt) => {
|
||||
let status: "pending" | "completed" | "expired" | "used";
|
||||
if (attempt.usedAt != null) {
|
||||
status = "used";
|
||||
} else if (attempt.refreshToken != null && attempt.expiresAt >= now) {
|
||||
status = "completed";
|
||||
} else if (attempt.expiresAt < now) {
|
||||
status = "expired";
|
||||
} else {
|
||||
status = "pending";
|
||||
}
|
||||
return {
|
||||
id: attempt.id,
|
||||
status,
|
||||
status: attempt.status,
|
||||
created_at: attempt.createdAt.toISOString(),
|
||||
expires_at: attempt.expiresAt.toISOString(),
|
||||
used_at: attempt.usedAt?.toISOString() ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
// Find active CLI tokens: tokens that were created via CLI auth (by looking
|
||||
// at CliAuthAttempt rows that have a non-null refreshToken and usedAt).
|
||||
// We join with the global ProjectUserRefreshToken table to find active sessions.
|
||||
// Bounded to most recent 200 to avoid scanning entire history for high-usage tenants.
|
||||
// Active-session discovery is also bounded: it considers refresh tokens
|
||||
// attached to the most recently used CLI attempts, then checks which of
|
||||
// those sessions still exist in the canonical refresh-token table.
|
||||
const cliRefreshTokens = await prisma.$replica().$queryRaw<{ refreshToken: string }[]>(Prisma.sql`
|
||||
SELECT "refreshToken"
|
||||
FROM ${sqlQuoteIdent(schema)}."CliAuthAttempt"
|
||||
WHERE "tenancyId" = ${tenancy.id}::UUID
|
||||
AND "refreshToken" IS NOT NULL
|
||||
AND "usedAt" IS NOT NULL
|
||||
ORDER BY "createdAt" DESC
|
||||
LIMIT 200
|
||||
ORDER BY "createdAt" DESC, "id" DESC
|
||||
LIMIT ${activeTokenAttemptLimit}
|
||||
`);
|
||||
|
||||
let activeCliUsers: Array<{
|
||||
@@ -150,15 +153,12 @@ export const GET = createSmartRouteHandler({
|
||||
expires_at: string | null,
|
||||
is_expired: boolean,
|
||||
}> = [];
|
||||
|
||||
let activeTokenCount = 0;
|
||||
|
||||
if (cliRefreshTokens.length > 0) {
|
||||
const tokenValues = cliRefreshTokens.map((r) => r.refreshToken);
|
||||
|
||||
// Get accurate total count of active (non-expired) tokens separately from the display list
|
||||
const tokenValues = cliRefreshTokens.map((row) => row.refreshToken);
|
||||
const countResult = await globalPrismaClient.$replica().$queryRaw<{ count: bigint }[]>(Prisma.sql`
|
||||
SELECT COUNT(*) as count
|
||||
SELECT COUNT(*) AS count
|
||||
FROM "ProjectUserRefreshToken"
|
||||
WHERE "tenancyId" = ${tenancy.id}::UUID
|
||||
AND "refreshToken" = ANY(${tokenValues})
|
||||
@@ -166,7 +166,6 @@ export const GET = createSmartRouteHandler({
|
||||
`);
|
||||
activeTokenCount = Number(countResult[0]?.count ?? 0n);
|
||||
|
||||
// Look up which tokens are still active in the global refresh token table (limited for display)
|
||||
const activeTokens = await globalPrismaClient.$replica().$queryRaw<ActiveCliTokenRow[]>(Prisma.sql`
|
||||
SELECT
|
||||
"id",
|
||||
@@ -182,11 +181,7 @@ export const GET = createSmartRouteHandler({
|
||||
`);
|
||||
|
||||
if (activeTokens.length > 0) {
|
||||
// Fetch user info for the active tokens
|
||||
const userIds = [...new Set(activeTokens.map((t) => t.projectUserId))];
|
||||
// ProjectUser has no email column; the primary email lives in ContactChannel
|
||||
// (type = EMAIL, isPrimary = TRUE). isPrimary/type are Postgres enums, so we
|
||||
// cast to text for a schema-agnostic comparison.
|
||||
const userIds = [...new Set(activeTokens.map((token) => token.projectUserId))];
|
||||
const userRows = await prisma.$replica().$queryRaw<ProjectUserRow[]>(Prisma.sql`
|
||||
SELECT
|
||||
pu."projectUserId",
|
||||
@@ -201,11 +196,10 @@ export const GET = createSmartRouteHandler({
|
||||
WHERE pu."tenancyId" = ${tenancy.id}::UUID
|
||||
AND pu."projectUserId" = ANY(${userIds}::UUID[])
|
||||
`);
|
||||
const userMap = new Map(userRows.map((u) => [u.projectUserId, u]));
|
||||
const usersById = new Map(userRows.map((user) => [user.projectUserId, user]));
|
||||
|
||||
activeCliUsers = activeTokens.map((token) => {
|
||||
const user = userMap.get(token.projectUserId);
|
||||
const isExpired = token.expiresAt != null && token.expiresAt < now;
|
||||
const user = usersById.get(token.projectUserId);
|
||||
return {
|
||||
user_id: token.projectUserId,
|
||||
display_name: user?.displayName ?? null,
|
||||
@@ -213,7 +207,7 @@ export const GET = createSmartRouteHandler({
|
||||
token_created_at: token.createdAt.toISOString(),
|
||||
last_active_at: token.lastActiveAt.toISOString(),
|
||||
expires_at: token.expiresAt?.toISOString() ?? null,
|
||||
is_expired: isExpired,
|
||||
is_expired: token.expiresAt != null && token.expiresAt < now,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -224,12 +218,14 @@ export const GET = createSmartRouteHandler({
|
||||
bodyType: "json" as const,
|
||||
body: {
|
||||
summary: {
|
||||
total_attempts: recentAttempts.length,
|
||||
completed_attempts: completed,
|
||||
used_attempts: used,
|
||||
expired_attempts: expired,
|
||||
pending_attempts: pending,
|
||||
active_tokens: activeTokenCount,
|
||||
attempts_in_window: recentAttempts.length,
|
||||
completed_attempts_in_window: completed,
|
||||
used_attempts_in_window: used,
|
||||
expired_attempts_in_window: expired,
|
||||
pending_attempts_in_window: pending,
|
||||
active_tokens_in_lookup_window: activeTokenCount,
|
||||
attempt_window_limit: recentAttemptLimit,
|
||||
active_token_lookup_window_limit: activeTokenAttemptLimit,
|
||||
},
|
||||
recent_attempts: formattedAttempts,
|
||||
active_cli_users: activeCliUsers,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
|
||||
import { drainInFlightPromises } from "@/utils/background-tasks";
|
||||
import { adaptSchema, adminAuthTypeSchema, yupBoolean, yupNumber, yupObject, yupString } from "@hexclave/shared/dist/schema-fields";
|
||||
import { adminAuthTypeSchema, yupBoolean, yupNumber, yupObject, yupString } from "@hexclave/shared/dist/schema-fields";
|
||||
|
||||
// Test/dev-only hook that awaits any in-flight background tasks spawned via
|
||||
// `runAsynchronouslyAndWaitUntil` (e.g. Stripe webhook processing, which is
|
||||
@@ -18,7 +18,11 @@ export const POST = createSmartRouteHandler({
|
||||
request: yupObject({
|
||||
auth: yupObject({
|
||||
type: adminAuthTypeSchema,
|
||||
tenancy: adaptSchema.defined(),
|
||||
tenancy: yupObject({
|
||||
project: yupObject({
|
||||
id: yupString().oneOf(["internal"]).defined(),
|
||||
}).defined(),
|
||||
}).defined(),
|
||||
}).defined(),
|
||||
method: yupString().oneOf(["POST"]).defined(),
|
||||
}),
|
||||
|
||||
+27
-28
@@ -24,12 +24,14 @@ import { PageLayout } from "../page-layout";
|
||||
import { useAdminApp } from "../use-admin-app";
|
||||
|
||||
type CliAuthSummary = {
|
||||
total_attempts: number,
|
||||
completed_attempts: number,
|
||||
used_attempts: number,
|
||||
expired_attempts: number,
|
||||
pending_attempts: number,
|
||||
active_tokens: number,
|
||||
attempts_in_window: number,
|
||||
completed_attempts_in_window: number,
|
||||
used_attempts_in_window: number,
|
||||
expired_attempts_in_window: number,
|
||||
pending_attempts_in_window: number,
|
||||
active_tokens_in_lookup_window: number,
|
||||
attempt_window_limit: number,
|
||||
active_token_lookup_window_limit: number,
|
||||
};
|
||||
|
||||
type CliAuthAttempt = {
|
||||
@@ -96,7 +98,7 @@ function formatRelativeTime(dateStr: string): string {
|
||||
export default function PageClient() {
|
||||
return (
|
||||
<AppEnabledGuard appId="cli-auth">
|
||||
<PageLayout title="CLI Auth" description="Monitor CLI authentication sessions and active tokens">
|
||||
<PageLayout title="CLI Auth" description="Monitor recent authentication attempts and CLI sessions">
|
||||
<CliAuthContent />
|
||||
</PageLayout>
|
||||
</AppEnabledGuard>
|
||||
@@ -138,7 +140,7 @@ function CliAuthContent() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
|
||||
{Array.from({ length: 5 }).map((_, i) => <Skeleton key={i} className="h-20 w-full rounded-xl" />)}
|
||||
{Array.from({ length: 5 }).map((_, index) => <Skeleton key={index} className="h-20 w-full rounded-xl" />)}
|
||||
</div>
|
||||
<Skeleton className="h-64 w-full rounded-xl" />
|
||||
<Skeleton className="h-64 w-full rounded-xl" />
|
||||
@@ -161,8 +163,8 @@ function CliAuthContent() {
|
||||
|
||||
function CliAuthDashboard({ data }: { data: CliAuthData }) {
|
||||
const { summary, recent_attempts, active_cli_users } = data;
|
||||
const activeUsers = active_cli_users.filter((u) => !u.is_expired);
|
||||
const expiredUsers = active_cli_users.filter((u) => u.is_expired);
|
||||
const activeUsers = active_cli_users.filter((user) => !user.is_expired);
|
||||
const expiredUsers = active_cli_users.filter((user) => user.is_expired);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
@@ -172,28 +174,28 @@ function CliAuthDashboard({ data }: { data: CliAuthData }) {
|
||||
description={<>
|
||||
CLI Auth allows users to authenticate from command-line tools using a browser-based login flow.
|
||||
The CLI initiates a session, the user confirms in a browser, and a refresh token is issued to the CLI.
|
||||
Metrics below are bounded snapshots: attempt counts cover the newest {summary.attempt_window_limit} attempts,
|
||||
and active sessions are discovered from the newest {summary.active_token_lookup_window_limit} used attempts.
|
||||
</>}
|
||||
/>
|
||||
|
||||
{/* Summary KPIs */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
|
||||
<KpiCard label="Total" value={summary.total_attempts} icon={<TerminalWindowIcon className="h-4 w-4" />} />
|
||||
<KpiCard label="Used" value={summary.used_attempts} icon={<CheckCircleIcon className="h-4 w-4 text-emerald-500" />} />
|
||||
<KpiCard label="Ready" value={summary.completed_attempts} icon={<CheckCircleIcon className="h-4 w-4 text-blue-500" />} />
|
||||
<KpiCard label="Expired" value={summary.expired_attempts} icon={<XCircleIcon className="h-4 w-4 text-red-500" />} />
|
||||
<KpiCard label="Active tokens" value={summary.active_tokens} icon={<UserIcon className="h-4 w-4 text-blue-500" />} />
|
||||
<KpiCard label="Recent attempts" value={summary.attempts_in_window} icon={<TerminalWindowIcon className="h-4 w-4" />} />
|
||||
<KpiCard label="Used" value={summary.used_attempts_in_window} icon={<CheckCircleIcon className="h-4 w-4 text-emerald-500" />} />
|
||||
<KpiCard label="Ready" value={summary.completed_attempts_in_window} icon={<CheckCircleIcon className="h-4 w-4 text-blue-500" />} />
|
||||
<KpiCard label="Expired" value={summary.expired_attempts_in_window} icon={<XCircleIcon className="h-4 w-4 text-red-500" />} />
|
||||
<KpiCard label="Active tokens" value={summary.active_tokens_in_lookup_window} icon={<UserIcon className="h-4 w-4 text-blue-500" />} />
|
||||
</div>
|
||||
|
||||
{/* Active CLI Users */}
|
||||
<DesignCard
|
||||
title="Active CLI Sessions"
|
||||
subtitle={`${activeUsers.length} user${activeUsers.length === 1 ? "" : "s"} with active CLI refresh tokens`}
|
||||
subtitle={`${summary.active_tokens_in_lookup_window} active token${summary.active_tokens_in_lookup_window === 1 ? "" : "s"} found in the latest ${summary.active_token_lookup_window_limit} used attempts`}
|
||||
icon={UserIcon}
|
||||
glassmorphic
|
||||
>
|
||||
{activeUsers.length === 0 ? (
|
||||
<div className="py-6 text-center">
|
||||
<Typography variant="secondary" className="text-xs">No active CLI sessions.</Typography>
|
||||
<Typography variant="secondary" className="text-xs">No active CLI sessions in the lookup window.</Typography>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border/40">
|
||||
@@ -203,7 +205,7 @@ function CliAuthDashboard({ data }: { data: CliAuthData }) {
|
||||
<Typography className="text-sm font-medium truncate">
|
||||
{user.display_name ?? user.primary_email ?? user.user_id}
|
||||
</Typography>
|
||||
{user.primary_email && user.display_name && (
|
||||
{user.primary_email != null && user.display_name != null && (
|
||||
<Typography variant="secondary" className="text-xs truncate">{user.primary_email}</Typography>
|
||||
)}
|
||||
</div>
|
||||
@@ -224,17 +226,15 @@ function CliAuthDashboard({ data }: { data: CliAuthData }) {
|
||||
)}
|
||||
{expiredUsers.length > 0 && (
|
||||
<details className="mt-3">
|
||||
<summary className="cursor-pointer text-xs text-muted-foreground hover:text-foreground transition-colors hover:transition-none">
|
||||
{expiredUsers.length} expired session{expiredUsers.length === 1 ? "" : "s"}
|
||||
<summary className="cursor-pointer text-xs text-muted-foreground transition-colors duration-150 hover:text-foreground hover:transition-none">
|
||||
{expiredUsers.length} expired session{expiredUsers.length === 1 ? "" : "s"} in the lookup window
|
||||
</summary>
|
||||
<div className="mt-2 divide-y divide-border/40">
|
||||
{expiredUsers.map((user) => (
|
||||
<div key={`${user.user_id}-${user.token_created_at}`} className="flex items-center justify-between gap-3 py-2.5 opacity-60">
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<Typography className="text-sm truncate">
|
||||
{user.display_name ?? user.primary_email ?? user.user_id}
|
||||
</Typography>
|
||||
</div>
|
||||
<Typography className="text-sm truncate">
|
||||
{user.display_name ?? user.primary_email ?? user.user_id}
|
||||
</Typography>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<Typography variant="secondary" className="text-[11px]">
|
||||
Expired {user.expires_at != null ? formatRelativeTime(user.expires_at) : ""}
|
||||
@@ -248,7 +248,6 @@ function CliAuthDashboard({ data }: { data: CliAuthData }) {
|
||||
)}
|
||||
</DesignCard>
|
||||
|
||||
{/* Recent Attempts */}
|
||||
<DesignCard
|
||||
title="Recent Login Attempts"
|
||||
subtitle="Last 50 CLI authentication attempts"
|
||||
|
||||
@@ -1794,10 +1794,12 @@ export namespace Payments {
|
||||
// Waits for any in-flight background tasks (e.g. async Stripe webhook processing)
|
||||
// to finish. Backed by the internal flush-background-tasks endpoint.
|
||||
export async function flushBackgroundTasks() {
|
||||
const res = await niceBackendFetch("/api/latest/internal/flush-background-tasks", {
|
||||
method: "POST",
|
||||
accessType: "admin",
|
||||
body: {},
|
||||
const res = await withInternalProject(async () => {
|
||||
return await niceBackendFetch("/api/latest/internal/flush-background-tasks", {
|
||||
method: "POST",
|
||||
accessType: "admin",
|
||||
body: {},
|
||||
});
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { it } from "../../../../../helpers";
|
||||
import { Project, niceBackendFetch } from "../../../../backend-helpers";
|
||||
|
||||
it("returns bounded CLI authentication metrics without exposing login secrets", async ({ expect }) => {
|
||||
await Project.createAndSwitch();
|
||||
|
||||
const createdAttempt = await niceBackendFetch("/api/latest/auth/cli", {
|
||||
method: "POST",
|
||||
accessType: "server",
|
||||
body: {},
|
||||
});
|
||||
expect(createdAttempt.status).toBe(200);
|
||||
|
||||
const response = await niceBackendFetch("/api/latest/internal/cli-auth", {
|
||||
method: "GET",
|
||||
accessType: "admin",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.summary).toEqual({
|
||||
attempts_in_window: 1,
|
||||
completed_attempts_in_window: 0,
|
||||
used_attempts_in_window: 0,
|
||||
expired_attempts_in_window: 0,
|
||||
pending_attempts_in_window: 1,
|
||||
active_tokens_in_lookup_window: 0,
|
||||
attempt_window_limit: 50,
|
||||
active_token_lookup_window_limit: 200,
|
||||
});
|
||||
expect(response.body.recent_attempts).toHaveLength(1);
|
||||
expect(response.body.recent_attempts[0]).toMatchObject({
|
||||
status: "pending",
|
||||
used_at: null,
|
||||
});
|
||||
expect(response.body.recent_attempts[0].id).toEqual(expect.any(String));
|
||||
expect(response.body.recent_attempts[0].created_at).toEqual(expect.any(String));
|
||||
expect(response.body.recent_attempts[0].expires_at).toEqual(expect.any(String));
|
||||
expect(response.body.active_cli_users).toEqual([]);
|
||||
expect(JSON.stringify(response.body)).not.toContain(createdAttempt.body.polling_code);
|
||||
expect(JSON.stringify(response.body)).not.toContain(createdAttempt.body.login_code);
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { it } from "../../../../../helpers";
|
||||
import { Project, niceBackendFetch, withInternalProject } from "../../../../backend-helpers";
|
||||
|
||||
it("rejects a non-internal project", async ({ expect }) => {
|
||||
await Project.createAndSwitch();
|
||||
|
||||
const response = await niceBackendFetch("/api/latest/internal/flush-background-tasks", {
|
||||
method: "POST",
|
||||
accessType: "admin",
|
||||
body: {},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.headers.get("x-stack-known-error")).toBe("SCHEMA_ERROR");
|
||||
});
|
||||
|
||||
it("accepts the internal project admin key", async ({ expect }) => {
|
||||
const response = await withInternalProject(async () => {
|
||||
return await niceBackendFetch("/api/latest/internal/flush-background-tasks", {
|
||||
method: "POST",
|
||||
accessType: "admin",
|
||||
body: {},
|
||||
});
|
||||
});
|
||||
|
||||
expect(response).toMatchInlineSnapshot(`
|
||||
NiceResponse {
|
||||
"status": 200,
|
||||
"body": { "success": true },
|
||||
"headers": Headers { <some fields may have been hidden> },
|
||||
}
|
||||
`);
|
||||
});
|
||||
@@ -1007,7 +1007,7 @@ export function getSdkSetupPrompt(mainType: "ai-prompt" | "nextjs" | "react" | "
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
\`hexclave dev\` injects all necessary environment variables into the app process automatically, so the app is ready to use without any extra environment variable setup.${isAiPrompt ? " It injects non-sensitive environment variables (eg. the project ID) with and without the prefixes \`NEXT_PUBLIC_\` and \`VITE_\`, so no extra environment variable setup is necessary for most frameworks." : ""}
|
||||
\`hexclave dev\` injects all necessary environment variables into the app process automatically, so the app is ready to use without any extra environment variable setup.${isAiPrompt ? " It injects non-sensitive environment variables (eg. the project ID) with and without the prefixes \`NEXT_PUBLIC_\` and \`VITE_\`, so no extra environment variable setup is necessary for most frameworks. Do not run `npm run dev:inner`, as that will skip past the Hexclave server." : ""}
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Option 2: Connecting to a production project hosted in the cloud">
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ReactPromise, runAsynchronously } from "@hexclave/shared/dist/utils/pro
|
||||
import { suspendIfSsr, use } from "@hexclave/shared/dist/utils/react";
|
||||
import { Result } from "@hexclave/shared/dist/utils/results";
|
||||
import { Store } from "@hexclave/shared/dist/utils/stores";
|
||||
import { deindent } from "@hexclave/shared/dist/utils/strings";
|
||||
import { getDefaultApiUrls } from "@hexclave/shared/dist/utils/urls";
|
||||
import React, { useCallback } from "react"; // THIS_LINE_PLATFORM react-like
|
||||
import { envVars } from "../../../../generated/env";
|
||||
@@ -40,7 +41,23 @@ const showMissingConfigAlertInBrowser = (message: string) => {
|
||||
};
|
||||
|
||||
const throwMissingProjectIdError = (): never => {
|
||||
const message = "Welcome to Hexclave! It seems that you haven't provided a project ID. Please create a project on the Hexclave dashboard at https://app.hexclave.com and put it in the HEXCLAVE_PROJECT_ID environment variable.";
|
||||
const message = deindent`
|
||||
Welcome to Hexclave!
|
||||
|
||||
It seems that you haven't provided a project ID.
|
||||
|
||||
${envVars.NODE_ENV?.includes("dev") ? deindent`
|
||||
You are running this app in development mode. Make sure that your start command is wrapped in a call to the Hexclave CLI: \`npx @hexclave/cli dev --config-file <file> -- <command>\`
|
||||
|
||||
Alternatively, you can also connect to a Cloud project's environment variables instead by creating a project on the Hexclave dashboard at https://app.hexclave.com and put its information in the HEXCLAVE_PROJECT_ID environment variable.
|
||||
` : envVars.NODE_ENV?.includes("prod") ? deindent`
|
||||
It seems you are running a project in production. Please create a project on the Hexclave dashboard at https://app.hexclave.com and put it in the HEXCLAVE_PROJECT_ID environment variable.
|
||||
` : deindent`
|
||||
- If you are running a development project and would like to connect the local Hexclave dashboard, wrap your start command in: \`npx @hexclave/cli dev --config-file <file> -- <command>\`
|
||||
|
||||
- If you are running a project in production and would like to connect to Hexclave Cloud, create a project on the Hexclave dashboard at https://app.hexclave.com and put its information in the HEXCLAVE_PROJECT_ID environment variable.
|
||||
`}
|
||||
`;
|
||||
showMissingConfigAlertInBrowser(message);
|
||||
return throwErr(new Error(message));
|
||||
};
|
||||
|
||||
@@ -369,7 +369,7 @@ export function mountPushedConfigErrorOverlay(app: StackClientApp<true>): () =>
|
||||
return;
|
||||
}
|
||||
|
||||
const issueMessage = issue.messages.join("\n");
|
||||
const issueMessage = "Hexclave config " + issue.kind + ": " + issue.messages.join("\n");
|
||||
const issueKey = `${app.projectId}:${issue.kind}:${issueMessage}`;
|
||||
const issueLabel = issue.kind === "error" ? "error" : "warning";
|
||||
const issueTitle = issue.kind === "error"
|
||||
|
||||
+90
-13
@@ -15,29 +15,106 @@ PORT_PREFIX="${NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX:-81}"
|
||||
BULLDOZER_PORT="${PORT_PREFIX}46"
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
BULLDOZER_DIR="$REPO_ROOT/apps/bulldozer-js"
|
||||
DATA_DIR="$BULLDOZER_DIR/.data"
|
||||
TEMP_DATA_DIR=""
|
||||
BACKUP_DATA_DIR="$BULLDOZER_DIR/.data-backup.untracked.$$"
|
||||
BULLDOZER_PID=""
|
||||
|
||||
echo "Wiping bulldozer-js LMDB store at $BULLDOZER_DIR/.data ..."
|
||||
rm -rf "$BULLDOZER_DIR/.data"
|
||||
port_is_open() {
|
||||
node - "$BULLDOZER_PORT" <<'NODE'
|
||||
const net = require("node:net");
|
||||
const port = Number(process.argv[2]);
|
||||
const socket = net.createConnection({ host: "127.0.0.1", port });
|
||||
socket.setTimeout(250);
|
||||
socket.once("connect", () => {
|
||||
socket.destroy();
|
||||
process.exit(0);
|
||||
});
|
||||
socket.once("error", () => process.exit(1));
|
||||
socket.once("timeout", () => {
|
||||
socket.destroy();
|
||||
process.exit(1);
|
||||
});
|
||||
NODE
|
||||
}
|
||||
|
||||
echo "Starting temporary bulldozer-js on port $BULLDOZER_PORT ..."
|
||||
# Run the server directly (single node process, so we can reliably kill it) via
|
||||
# tsx's node loader — the same entrypoint the package's `start` script uses.
|
||||
(
|
||||
cd "$BULLDOZER_DIR" && NODE_ENV=development exec node --import tsx --expose-gc src/index.ts
|
||||
) &
|
||||
BULLDOZER_PID=$!
|
||||
|
||||
cleanup() {
|
||||
stop_bulldozer() {
|
||||
if [[ -z "$BULLDOZER_PID" ]]; then
|
||||
return
|
||||
fi
|
||||
echo "Shutting down temporary bulldozer-js (pid $BULLDOZER_PID) ..."
|
||||
kill "$BULLDOZER_PID" 2>/dev/null || true
|
||||
wait "$BULLDOZER_PID" 2>/dev/null || true
|
||||
BULLDOZER_PID=""
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
stop_bulldozer
|
||||
if [[ -n "$TEMP_DATA_DIR" && -d "$TEMP_DATA_DIR" ]]; then
|
||||
rm -rf "$TEMP_DATA_DIR"
|
||||
fi
|
||||
if [[ -e "$BACKUP_DATA_DIR" ]]; then
|
||||
if [[ ! -e "$DATA_DIR" ]]; then
|
||||
mv "$BACKUP_DATA_DIR" "$DATA_DIR"
|
||||
else
|
||||
rm -rf "$BACKUP_DATA_DIR"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "Waiting for bulldozer-js to accept connections on port $BULLDOZER_PORT ..."
|
||||
pnpm exec wait-on -t 60000 "tcp:localhost:$BULLDOZER_PORT"
|
||||
if port_is_open; then
|
||||
echo "Cannot re-seed bulldozer-js: port $BULLDOZER_PORT is already in use." >&2
|
||||
echo "Stop the existing bulldozer-js process and run restart-deps again." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Build the replacement store separately. The existing store remains untouched
|
||||
# unless startup and the full Postgres backfill both succeed.
|
||||
TEMP_DATA_DIR="$(mktemp -d "$BULLDOZER_DIR/.data-reseed.untracked.XXXXXX")"
|
||||
TEMP_LMDB_PATH="$TEMP_DATA_DIR/bulldozer-js-lmdb"
|
||||
|
||||
echo "Starting temporary bulldozer-js on port $BULLDOZER_PORT ..."
|
||||
(
|
||||
cd "$BULLDOZER_DIR" && \
|
||||
NODE_ENV=development \
|
||||
HEXCLAVE_BULLDOZER_JS_LMDB_PATH="$TEMP_LMDB_PATH" \
|
||||
exec node --import tsx --expose-gc src/index.ts
|
||||
) &
|
||||
BULLDOZER_PID=$!
|
||||
|
||||
echo "Waiting for temporary bulldozer-js to accept connections on port $BULLDOZER_PORT ..."
|
||||
ready=0
|
||||
for ((attempt = 0; attempt < 120; attempt++)); do
|
||||
if ! kill -0 "$BULLDOZER_PID" 2>/dev/null; then
|
||||
process_status=0
|
||||
wait "$BULLDOZER_PID" || process_status=$?
|
||||
BULLDOZER_PID=""
|
||||
echo "Temporary bulldozer-js exited before becoming ready (status $process_status)." >&2
|
||||
exit 1
|
||||
fi
|
||||
if port_is_open; then
|
||||
ready=1
|
||||
break
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
if [[ "$ready" -ne 1 ]]; then
|
||||
echo "Temporary bulldozer-js did not become ready within 60 seconds." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Running Postgres->bulldozer backfill ..."
|
||||
pnpm run db:backfill-bulldozer-from-prisma
|
||||
|
||||
stop_bulldozer
|
||||
|
||||
echo "Replacing bulldozer-js LMDB store at $DATA_DIR ..."
|
||||
if [[ -e "$DATA_DIR" ]]; then
|
||||
mv "$DATA_DIR" "$BACKUP_DATA_DIR"
|
||||
fi
|
||||
mv "$TEMP_DATA_DIR" "$DATA_DIR"
|
||||
TEMP_DATA_DIR=""
|
||||
rm -rf "$BACKUP_DATA_DIR"
|
||||
|
||||
echo "Bulldozer re-seed complete."
|
||||
|
||||
Reference in New Issue
Block a user