mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-30 21:01:54 +08:00
Merge remote-tracking branch 'origin/dev' into devin/1782257319-migrate-config-to-jiti
This commit is contained in:
commit
abf95240f9
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/backend",
|
||||
"version": "1.0.28",
|
||||
"version": "1.0.30",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@ -0,0 +1,21 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "StripeWebhookEventStatus" AS ENUM ('PENDING', 'PROCESSED', 'FAILED');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "StripeWebhookEvent" (
|
||||
"id" UUID NOT NULL,
|
||||
"stripeEventId" TEXT NOT NULL,
|
||||
"eventType" TEXT NOT NULL,
|
||||
"stripeAccountId" TEXT,
|
||||
"payload" JSONB NOT NULL,
|
||||
"status" "StripeWebhookEventStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"lastError" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"processedAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "StripeWebhookEvent_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "StripeWebhookEvent_stripeEventId_key" ON "StripeWebhookEvent"("stripeEventId");
|
||||
@ -0,0 +1,60 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import type { Sql } from "postgres";
|
||||
import { expect } from "vitest";
|
||||
|
||||
export const preMigration = async (sql: Sql) => {
|
||||
// Table does not exist before the migration, so nothing to seed.
|
||||
return {};
|
||||
};
|
||||
|
||||
export const postMigration = async (sql: Sql) => {
|
||||
const tables = await sql<{ table_name: string }[]>`
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'StripeWebhookEvent'
|
||||
`;
|
||||
expect(Array.from(tables)).toMatchInlineSnapshot(`
|
||||
[
|
||||
{
|
||||
"table_name": "StripeWebhookEvent",
|
||||
},
|
||||
]
|
||||
`);
|
||||
|
||||
const eventId = `evt_${randomUUID()}`;
|
||||
|
||||
await sql`
|
||||
INSERT INTO "StripeWebhookEvent" ("id", "stripeEventId", "eventType", "payload", "updatedAt")
|
||||
VALUES (${randomUUID()}::uuid, ${eventId}, 'invoice.payment_succeeded', '{"id":"evt"}'::jsonb, NOW())
|
||||
`;
|
||||
|
||||
// Status defaults to PENDING.
|
||||
const inserted = await sql`
|
||||
SELECT "status" FROM "StripeWebhookEvent" WHERE "stripeEventId" = ${eventId}
|
||||
`;
|
||||
expect(Array.from(inserted)).toMatchInlineSnapshot(`
|
||||
[
|
||||
{
|
||||
"status": "PENDING",
|
||||
},
|
||||
]
|
||||
`);
|
||||
|
||||
// The same Stripe event id cannot be inserted twice (idempotency guarantee).
|
||||
await expect(sql`
|
||||
INSERT INTO "StripeWebhookEvent" ("id", "stripeEventId", "eventType", "payload", "updatedAt")
|
||||
VALUES (${randomUUID()}::uuid, ${eventId}, 'invoice.payment_succeeded', '{"id":"evt2"}'::jsonb, NOW())
|
||||
`).rejects.toThrow(/StripeWebhookEvent_stripeEventId_key/);
|
||||
|
||||
// A different event id is fine, and the status enum rejects invalid values.
|
||||
await sql`
|
||||
INSERT INTO "StripeWebhookEvent" ("id", "stripeEventId", "eventType", "payload", "status", "updatedAt")
|
||||
VALUES (${randomUUID()}::uuid, ${`evt_${randomUUID()}`}, 'invoice.paid', '{}'::jsonb, 'PROCESSED', NOW())
|
||||
`;
|
||||
|
||||
await expect(sql`
|
||||
INSERT INTO "StripeWebhookEvent" ("id", "stripeEventId", "eventType", "payload", "status", "updatedAt")
|
||||
VALUES (${randomUUID()}::uuid, ${`evt_${randomUUID()}`}, 'invoice.paid', '{}'::jsonb, 'NOT_A_STATUS', NOW())
|
||||
`).rejects.toThrow();
|
||||
};
|
||||
@ -0,0 +1,11 @@
|
||||
-- SPLIT_STATEMENT_SENTINEL
|
||||
-- SINGLE_STATEMENT_SENTINEL
|
||||
-- RUN_OUTSIDE_TRANSACTION_SENTINEL
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS "EmailOutbox_tenancyId_startedSendingAt_idx"
|
||||
ON /* SCHEMA_NAME_SENTINEL */."EmailOutbox"("tenancyId", "startedSendingAt");
|
||||
|
||||
-- SPLIT_STATEMENT_SENTINEL
|
||||
-- SINGLE_STATEMENT_SENTINEL
|
||||
-- RUN_OUTSIDE_TRANSACTION_SENTINEL
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS "SessionReplay_tenancyId_startedAt_idx"
|
||||
ON /* SCHEMA_NAME_SENTINEL */."SessionReplay"("tenancyId", "startedAt");
|
||||
@ -407,6 +407,7 @@ model SessionReplay {
|
||||
|
||||
@@id([tenancyId, id])
|
||||
@@index([tenancyId, projectUserId, startedAt])
|
||||
@@index([tenancyId, startedAt], name: "SessionReplay_tenancyId_startedAt_idx")
|
||||
@@index([tenancyId, lastEventAt])
|
||||
// index by updatedAt instead of lastEventAt because event timing can be spoofed
|
||||
@@index([tenancyId, refreshTokenId, updatedAt])
|
||||
@ -1069,6 +1070,7 @@ model EmailOutbox {
|
||||
|
||||
@@id([tenancyId, id])
|
||||
@@index([tenancyId, finishedSendingAt(sort: Desc), scheduledAtIfNotYetQueued(sort: Desc), priority, id], map: "EmailOutbox_ordering_idx")
|
||||
@@index([tenancyId, startedSendingAt], name: "EmailOutbox_tenancyId_startedSendingAt_idx")
|
||||
@@index([tenancyId, simpleStatus], map: "EmailOutbox_simple_status_tenancy_idx")
|
||||
@@index([tenancyId, status], map: "EmailOutbox_status_tenancy_idx")
|
||||
@@index([isQueued], map: "EmailOutbox_isQueued_idx")
|
||||
@ -1427,6 +1429,32 @@ model OutgoingRequest {
|
||||
@@index([startedFulfillingAt, deduplicationKey])
|
||||
}
|
||||
|
||||
enum StripeWebhookEventStatus {
|
||||
PENDING
|
||||
PROCESSED
|
||||
FAILED
|
||||
}
|
||||
|
||||
// Idempotency + recovery log for incoming Stripe webhook events. Each event is
|
||||
// persisted synchronously (keyed on the Stripe `event.id`) before we ack 200 to
|
||||
// Stripe, so redeliveries are deduped and the full `payload` of any
|
||||
// PENDING/FAILED row can be replayed manually. Not tenancy-scoped: the Stripe
|
||||
// account -> tenancy resolution happens during processing, not here.
|
||||
model StripeWebhookEvent {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
|
||||
stripeEventId String @unique
|
||||
eventType String
|
||||
stripeAccountId String?
|
||||
payload Json
|
||||
status StripeWebhookEventStatus @default(PENDING)
|
||||
lastError String?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
processedAt DateTime?
|
||||
}
|
||||
|
||||
// BulldozerStorageEngine is managed externally (see prisma.config.ts
|
||||
// `tables.external`). It's created by migrations and interacted with
|
||||
// via raw SQL — not through the Prisma client. Keeping it out of the
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import { sendEmailToMany, type EmailOutboxRecipient } from "@/lib/emails";
|
||||
import { bulldozerWriteOneTimePurchase } from "@/lib/payments/bulldozer-dual-write";
|
||||
import { claimStripeEvent, markStripeEventFailed, markStripeEventProcessed } from "@/lib/stripe-webhook-events";
|
||||
import { runAsynchronouslyAndWaitUntil } from "@/utils/background-tasks";
|
||||
import { listPermissions } from "@/lib/permissions";
|
||||
import { getHexclaveStripe, getStripeForAccount, resolveProductFromStripeMetadata, syncStripeSubscriptions, upsertStripeInvoice } from "@/lib/stripe";
|
||||
import type { StripeOverridesMap } from "@/lib/stripe-proxy";
|
||||
@ -466,13 +468,29 @@ export const POST = createSmartRouteHandler({
|
||||
throw new StatusError(400, "Invalid stripe-signature header");
|
||||
}
|
||||
|
||||
try {
|
||||
await processStripeWebhookEvent(event);
|
||||
} catch (error) {
|
||||
captureError("stripe-webhook-receiver", error);
|
||||
throw error;
|
||||
// Persist the event for idempotency + recovery BEFORE acking. Stripe
|
||||
// delivers at-least-once
|
||||
const { shouldProcess } = await claimStripeEvent(event);
|
||||
if (!shouldProcess) {
|
||||
return {
|
||||
statusCode: 200,
|
||||
bodyType: "json",
|
||||
body: { received: true, deduplicated: true },
|
||||
};
|
||||
}
|
||||
|
||||
// Ack Stripe immediately and process in the background.
|
||||
// Stripe recommends ACKing ASAP to avoid timeouts and redeliveries
|
||||
runAsynchronouslyAndWaitUntil(async () => {
|
||||
try {
|
||||
await processStripeWebhookEvent(event);
|
||||
await markStripeEventProcessed(event.id);
|
||||
} catch (error) {
|
||||
captureError("stripe-webhook-receiver", error);
|
||||
await markStripeEventFailed(event.id, error);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
bodyType: "json",
|
||||
|
||||
@ -4,6 +4,8 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
arePlanLimitsEnforced,
|
||||
getBillingTeamId,
|
||||
getNonAnonymousUserCountForTenancies,
|
||||
getOwnedProjectAndTenancyIdsForBillingTeam,
|
||||
getOwnedProjectIdsForBillingTeam,
|
||||
getOwnedTenancyIdsForBillingTeam,
|
||||
getTeamWideItemCapacityForTests,
|
||||
@ -98,6 +100,28 @@ describe("team-wide ownership aggregation", () => {
|
||||
expect(tenancyIds).toEqual(["tenancy-a-main", "tenancy-a-dev", "tenancy-b-main"]);
|
||||
});
|
||||
|
||||
it("lists owned project and tenancy ids from one ownership scope", async () => {
|
||||
const scope = await getOwnedProjectAndTenancyIdsForBillingTeam("team-1", globalPrisma);
|
||||
expect(scope).toMatchInlineSnapshot(`
|
||||
{
|
||||
"projectIds": [
|
||||
"project-a",
|
||||
"project-b",
|
||||
],
|
||||
"tenancyIds": [
|
||||
"tenancy-a-main",
|
||||
"tenancy-a-dev",
|
||||
"tenancy-b-main",
|
||||
],
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it("counts non-anonymous users from already-resolved tenancies", async () => {
|
||||
const usage = await getNonAnonymousUserCountForTenancies(["tenancy-a-main", "tenancy-b-main"], globalPrisma);
|
||||
expect(usage).toBe(2);
|
||||
});
|
||||
|
||||
it("counts only non-anonymous users across all owned tenancies", async () => {
|
||||
const usage = await getTeamWideNonAnonymousUserCount("team-1", globalPrisma);
|
||||
expect(usage).toBe(3);
|
||||
|
||||
@ -39,6 +39,11 @@ type GlobalPrismaLike = {
|
||||
},
|
||||
};
|
||||
|
||||
type OwnedBillingScope = {
|
||||
projectIds: string[],
|
||||
tenancyIds: string[],
|
||||
};
|
||||
|
||||
type ItemCapacityReaders = {
|
||||
getPrismaForTenancy: (tenancy: Tenancy) => Promise<unknown>,
|
||||
getItemQuantityForCustomer: (options: {
|
||||
@ -85,13 +90,16 @@ export async function getOwnedProjectIdsForBillingTeam(
|
||||
return projects.map((project) => project.id);
|
||||
}
|
||||
|
||||
export async function getOwnedTenancyIdsForBillingTeam(
|
||||
export async function getOwnedProjectAndTenancyIdsForBillingTeam(
|
||||
billingTeamId: string,
|
||||
globalPrisma: GlobalPrismaLike = globalPrismaClient,
|
||||
): Promise<string[]> {
|
||||
): Promise<OwnedBillingScope> {
|
||||
const projectIds = await getOwnedProjectIdsForBillingTeam(billingTeamId, globalPrisma);
|
||||
if (projectIds.length === 0) {
|
||||
return [];
|
||||
return {
|
||||
projectIds,
|
||||
tenancyIds: [],
|
||||
};
|
||||
}
|
||||
const tenancies = await globalPrisma.tenancy.findMany({
|
||||
where: {
|
||||
@ -103,16 +111,23 @@ export async function getOwnedTenancyIdsForBillingTeam(
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
return tenancies.map((tenancy) => tenancy.id);
|
||||
return {
|
||||
projectIds,
|
||||
tenancyIds: tenancies.map((tenancy) => tenancy.id),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getTeamWideNonAnonymousUserCount(
|
||||
export async function getOwnedTenancyIdsForBillingTeam(
|
||||
billingTeamId: string,
|
||||
globalPrisma: GlobalPrismaLike = globalPrismaClient,
|
||||
): Promise<string[]> {
|
||||
return (await getOwnedProjectAndTenancyIdsForBillingTeam(billingTeamId, globalPrisma)).tenancyIds;
|
||||
}
|
||||
|
||||
export async function getNonAnonymousUserCountForTenancies(
|
||||
tenancyIds: string[],
|
||||
globalPrisma: GlobalPrismaLike = globalPrismaClient,
|
||||
): Promise<number> {
|
||||
// Usage metric: how many non-anonymous users are currently consumed by this billing team.
|
||||
// This is compared against auth user capacity to determine over-limit conditions.
|
||||
const tenancyIds = await getOwnedTenancyIdsForBillingTeam(billingTeamId, globalPrisma);
|
||||
if (tenancyIds.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
@ -126,6 +141,16 @@ export async function getTeamWideNonAnonymousUserCount(
|
||||
});
|
||||
}
|
||||
|
||||
export async function getTeamWideNonAnonymousUserCount(
|
||||
billingTeamId: string,
|
||||
globalPrisma: GlobalPrismaLike = globalPrismaClient,
|
||||
): Promise<number> {
|
||||
// Usage metric: how many non-anonymous users are currently consumed by this billing team.
|
||||
// This is compared against auth user capacity to determine over-limit conditions.
|
||||
const tenancyIds = await getOwnedTenancyIdsForBillingTeam(billingTeamId, globalPrisma);
|
||||
return await getNonAnonymousUserCountForTenancies(tenancyIds, globalPrisma);
|
||||
}
|
||||
|
||||
async function getTeamWideItemCapacity(
|
||||
billingTeamId: string,
|
||||
itemId: string,
|
||||
|
||||
@ -4,20 +4,24 @@ import { getSubscriptionMapForCustomer } from "@/lib/payments/customer-data";
|
||||
import { isActiveSubscription } from "@/lib/payments";
|
||||
import {
|
||||
getBillingTeamId,
|
||||
getOwnedProjectIdsForBillingTeam,
|
||||
getOwnedTenancyIdsForBillingTeam,
|
||||
getTeamWideNonAnonymousUserCount,
|
||||
getNonAnonymousUserCountForTenancies,
|
||||
getOwnedProjectAndTenancyIdsForBillingTeam,
|
||||
} from "@/lib/plan-entitlements";
|
||||
import { DEFAULT_BRANCH_ID, getSoleTenancyFromProjectBranch, getTenancy, type Tenancy } from "@/lib/tenancies";
|
||||
import { getPrismaClientForTenancy, getPrismaSchemaForTenancy, globalPrismaClient, sqlQuoteIdent } from "@/prisma-client";
|
||||
import { BASE_PLAN_IDS_BY_TIER, ITEM_IDS, PLAN_LIMITS, UNLIMITED, type ItemId, type PlanId } from "@hexclave/shared/dist/plans";
|
||||
import type { PlanUsageResponse } from "@hexclave/shared/dist/interface/admin-interface";
|
||||
import { HexclaveAssertionError, throwErr } from "@hexclave/shared/dist/utils/errors";
|
||||
import { mapWithConcurrency } from "@hexclave/shared/dist/utils/promises";
|
||||
import type { SubscriptionRow } from "./payments/schema/types";
|
||||
|
||||
type PlanUsageKind = PlanUsageResponse["rows"][number]["kind"];
|
||||
type PlanUsageRow = PlanUsageResponse["rows"][number];
|
||||
type UsageLimit = number | null;
|
||||
type TenancyMeteredUsage = {
|
||||
emails: number,
|
||||
sessionReplays: number,
|
||||
};
|
||||
|
||||
type UsagePeriod = {
|
||||
start: Date,
|
||||
@ -46,6 +50,8 @@ const PLAN_LABELS = new Map<PlanId, string>([
|
||||
["growth", "Growth"],
|
||||
]);
|
||||
|
||||
const PLAN_USAGE_TENANCY_COUNTER_CONCURRENCY = 4;
|
||||
|
||||
export function getNextPlanId(planId: PlanId): "team" | "growth" | null {
|
||||
if (planId === "free") {
|
||||
return "team";
|
||||
@ -202,38 +208,99 @@ async function getOwnerTeamDisplayName(internalTenancy: Tenancy, ownerTeamId: st
|
||||
return team?.displayName ?? throwErr(`Owner team ${ownerTeamId} not found in the internal tenancy`);
|
||||
}
|
||||
|
||||
async function countEmailsForTenancy(tenancyId: string, period: UsagePeriod): Promise<number> {
|
||||
const tenancy = await getTenancy(tenancyId) ?? throwErr(`Tenancy ${tenancyId} not found while counting email usage`);
|
||||
const schema = await getPrismaSchemaForTenancy(tenancy);
|
||||
const prisma = await getPrismaClientForTenancy(tenancy);
|
||||
const rows = await prisma.$replica().$queryRaw<[{ count: number }]>`
|
||||
SELECT COUNT(*)::int AS count
|
||||
FROM ${sqlQuoteIdent(schema)}."EmailOutbox"
|
||||
WHERE "tenancyId" = ${tenancy.id}::uuid
|
||||
AND "startedSendingAt" IS NOT NULL
|
||||
AND "startedSendingAt" >= ${period.start}
|
||||
AND "startedSendingAt" < ${period.end}
|
||||
`;
|
||||
return Number(rows[0].count);
|
||||
type TenancyPrismaClient = Awaited<ReturnType<typeof getPrismaClientForTenancy>>;
|
||||
|
||||
type TenancyMeteredUsageGroup = {
|
||||
prisma: TenancyPrismaClient,
|
||||
schema: string,
|
||||
tenancyIds: string[],
|
||||
};
|
||||
|
||||
// Tenancies can route to different source-of-truth databases/schemas, so we can't assume a single
|
||||
// query covers every tenancy. We group tenancies that share a (client, schema) and run one aggregate
|
||||
// COUNT per group: the common case (all projects on one database) collapses to a single round trip,
|
||||
// while multi-database teams fan out to one query per distinct database instead of one per tenancy.
|
||||
async function groupTenanciesByMeteredUsageSource(tenancyIds: string[]): Promise<TenancyMeteredUsageGroup[]> {
|
||||
const resolved = await mapWithConcurrency(tenancyIds, PLAN_USAGE_TENANCY_COUNTER_CONCURRENCY, async (tenancyId) => {
|
||||
const tenancy = await getTenancy(tenancyId) ?? throwErr(`Tenancy ${tenancyId} not found while counting plan usage`);
|
||||
const [schema, prisma] = await Promise.all([
|
||||
getPrismaSchemaForTenancy(tenancy),
|
||||
getPrismaClientForTenancy(tenancy),
|
||||
]);
|
||||
return { tenancyId: tenancy.id, schema, prisma };
|
||||
});
|
||||
|
||||
const byClient = new Map<TenancyPrismaClient, Map<string, string[]>>();
|
||||
for (const { tenancyId, schema, prisma } of resolved) {
|
||||
let bySchema = byClient.get(prisma);
|
||||
if (bySchema == null) {
|
||||
bySchema = new Map<string, string[]>();
|
||||
byClient.set(prisma, bySchema);
|
||||
}
|
||||
const existing = bySchema.get(schema);
|
||||
if (existing == null) {
|
||||
bySchema.set(schema, [tenancyId]);
|
||||
} else {
|
||||
existing.push(tenancyId);
|
||||
}
|
||||
}
|
||||
|
||||
const groups: TenancyMeteredUsageGroup[] = [];
|
||||
for (const [prisma, bySchema] of byClient) {
|
||||
for (const [schema, groupTenancyIds] of bySchema) {
|
||||
groups.push({ prisma, schema, tenancyIds: groupTenancyIds });
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
async function countSessionReplaysForTenancy(tenancyId: string, period: UsagePeriod): Promise<number> {
|
||||
const tenancy = await getTenancy(tenancyId) ?? throwErr(`Tenancy ${tenancyId} not found while counting session replay usage`);
|
||||
const schema = await getPrismaSchemaForTenancy(tenancy);
|
||||
const prisma = await getPrismaClientForTenancy(tenancy);
|
||||
const rows = await prisma.$replica().$queryRaw<[{ count: number }]>`
|
||||
SELECT COUNT(*)::int AS count
|
||||
FROM ${sqlQuoteIdent(schema)}."SessionReplay"
|
||||
WHERE "tenancyId" = ${tenancy.id}::uuid
|
||||
AND "startedAt" >= ${period.start}
|
||||
AND "startedAt" < ${period.end}
|
||||
async function countMeteredUsageForGroup(group: TenancyMeteredUsageGroup, period: UsagePeriod): Promise<TenancyMeteredUsage> {
|
||||
const rows = await group.prisma.$replica().$queryRaw<Array<{ emails: number, sessionReplays: number }>>`
|
||||
SELECT
|
||||
(
|
||||
SELECT COUNT(*)::int
|
||||
FROM ${sqlQuoteIdent(group.schema)}."EmailOutbox"
|
||||
WHERE "tenancyId" = ANY(${group.tenancyIds}::uuid[])
|
||||
AND "startedSendingAt" IS NOT NULL
|
||||
AND "startedSendingAt" >= ${period.start}
|
||||
AND "startedSendingAt" < ${period.end}
|
||||
) AS "emails",
|
||||
(
|
||||
SELECT COUNT(*)::int
|
||||
FROM ${sqlQuoteIdent(group.schema)}."SessionReplay"
|
||||
WHERE "tenancyId" = ANY(${group.tenancyIds}::uuid[])
|
||||
AND "startedAt" >= ${period.start}
|
||||
AND "startedAt" < ${period.end}
|
||||
) AS "sessionReplays"
|
||||
`;
|
||||
return Number(rows[0].count);
|
||||
const row = rows[0] ?? throwErr(`Missing plan usage count row for metered usage group on schema ${group.schema}`);
|
||||
return {
|
||||
emails: Number(row.emails),
|
||||
sessionReplays: Number(row.sessionReplays),
|
||||
};
|
||||
}
|
||||
|
||||
async function sumTenancyUsage(tenancyIds: string[], counter: (tenancyId: string) => Promise<number>): Promise<number> {
|
||||
const counts = await Promise.all(tenancyIds.map(counter));
|
||||
return counts.reduce((sum, count) => sum + count, 0);
|
||||
async function sumTenancyMeteredUsage(tenancyIds: string[], period: UsagePeriod): Promise<TenancyMeteredUsage> {
|
||||
if (tenancyIds.length === 0) {
|
||||
return { emails: 0, sessionReplays: 0 };
|
||||
}
|
||||
|
||||
const groups = await groupTenanciesByMeteredUsageSource(tenancyIds);
|
||||
// The group count equals the number of distinct databases (usually 1), so concurrency mostly guards
|
||||
// the pathological multi-database team rather than the per-tenancy fan-out it used to.
|
||||
const subtotals = await mapWithConcurrency(
|
||||
groups,
|
||||
PLAN_USAGE_TENANCY_COUNTER_CONCURRENCY,
|
||||
(group) => countMeteredUsageForGroup(group, period),
|
||||
);
|
||||
|
||||
return subtotals.reduce<TenancyMeteredUsage>(
|
||||
(totals, subtotal) => ({
|
||||
emails: totals.emails + subtotal.emails,
|
||||
sessionReplays: totals.sessionReplays + subtotal.sessionReplays,
|
||||
}),
|
||||
{ emails: 0, sessionReplays: 0 },
|
||||
);
|
||||
}
|
||||
|
||||
async function countAnalyticsEventsForProjects(projectIds: string[], period: UsagePeriod): Promise<number> {
|
||||
@ -336,18 +403,16 @@ export async function getPlanUsageForProject(project: UsageSourceProject, now: D
|
||||
const planId = resolveActivePlanId(activePlanSubscription);
|
||||
const period = getPlanUsagePeriod(activePlanSubscription, now);
|
||||
|
||||
const [ownerTeamDisplayName, ownedProjectIds, ownedTenancyIds, dashboardAdmins, authUsers] = await Promise.all([
|
||||
const [ownerTeamDisplayName, ownedScope, dashboardAdmins] = await Promise.all([
|
||||
getOwnerTeamDisplayName(internalTenancy, ownerTeamId),
|
||||
getOwnedProjectIdsForBillingTeam(ownerTeamId),
|
||||
getOwnedTenancyIdsForBillingTeam(ownerTeamId),
|
||||
getOwnedProjectAndTenancyIdsForBillingTeam(ownerTeamId),
|
||||
countDashboardAdmins(internalTenancy, ownerTeamId, now),
|
||||
getTeamWideNonAnonymousUserCount(ownerTeamId),
|
||||
]);
|
||||
|
||||
const [emails, analyticsEvents, sessionReplays] = await Promise.all([
|
||||
sumTenancyUsage(ownedTenancyIds, async (tenancyId) => await countEmailsForTenancy(tenancyId, period)),
|
||||
countAnalyticsEventsForProjects(ownedProjectIds, period),
|
||||
sumTenancyUsage(ownedTenancyIds, async (tenancyId) => await countSessionReplaysForTenancy(tenancyId, period)),
|
||||
const [authUsers, meteredUsage, analyticsEvents] = await Promise.all([
|
||||
getNonAnonymousUserCountForTenancies(ownedScope.tenancyIds),
|
||||
sumTenancyMeteredUsage(ownedScope.tenancyIds, period),
|
||||
countAnalyticsEventsForProjects(ownedScope.projectIds, period),
|
||||
]);
|
||||
|
||||
return {
|
||||
@ -362,9 +427,9 @@ export async function getPlanUsageForProject(project: UsageSourceProject, now: D
|
||||
planId,
|
||||
dashboardAdmins,
|
||||
authUsers,
|
||||
emails,
|
||||
emails: meteredUsage.emails,
|
||||
analyticsEvents,
|
||||
sessionReplays,
|
||||
sessionReplays: meteredUsage.sessionReplays,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
122
apps/backend/src/lib/stripe-webhook-events.test.ts
Normal file
122
apps/backend/src/lib/stripe-webhook-events.test.ts
Normal file
@ -0,0 +1,122 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type Stripe from "stripe";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { StripeWebhookEventStatus } from "@/generated/prisma/client";
|
||||
import { globalPrismaClient } from "@/prisma-client";
|
||||
import { claimStripeEvent, markStripeEventFailed, markStripeEventProcessed } from "./stripe-webhook-events";
|
||||
|
||||
// Test fixtures only need the fields the helper reads (id/type/account) plus a
|
||||
// JSON-serializable body. Building a full Stripe.Event is impractical, so we
|
||||
// cast a minimal object — any drift in the fields we actually use is still
|
||||
// caught because the helper reads them directly.
|
||||
function makeEvent(): Stripe.Event {
|
||||
return {
|
||||
id: `evt_${randomUUID()}`,
|
||||
type: "invoice.payment_succeeded",
|
||||
account: "acct_test_123",
|
||||
data: { object: { id: "in_test", note: "fixture" } },
|
||||
} as unknown as Stripe.Event;
|
||||
}
|
||||
|
||||
describe("stripe webhook event idempotency (real DB)", () => {
|
||||
it("claims a brand new event and persists it as PENDING", async ({ expect }) => {
|
||||
const event = makeEvent();
|
||||
|
||||
const { shouldProcess } = await claimStripeEvent(event);
|
||||
expect(shouldProcess).toBe(true);
|
||||
|
||||
const row = await globalPrismaClient.stripeWebhookEvent.findUnique({
|
||||
where: { stripeEventId: event.id },
|
||||
});
|
||||
expect(row).not.toBeNull();
|
||||
expect(row?.status).toBe(StripeWebhookEventStatus.PENDING);
|
||||
expect(row?.eventType).toBe(event.type);
|
||||
expect(row?.stripeAccountId).toBe(event.account);
|
||||
expect(row?.processedAt).toBeNull();
|
||||
expect(row?.lastError).toBeNull();
|
||||
// The full event payload is stored so dropped/failed events can be replayed.
|
||||
expect(row?.payload).toMatchObject({ id: event.id, type: event.type });
|
||||
});
|
||||
|
||||
it("skips a redelivery while the prior delivery is still in-flight (PENDING)", async ({ expect }) => {
|
||||
const event = makeEvent();
|
||||
|
||||
const first = await claimStripeEvent(event);
|
||||
expect(first.shouldProcess).toBe(true);
|
||||
|
||||
// Single-flight: a redelivery that arrives while the first attempt is still
|
||||
// PENDING must not spin up a second processor (that would double the fan-out).
|
||||
const second = await claimStripeEvent(event);
|
||||
expect(second.shouldProcess).toBe(false);
|
||||
});
|
||||
|
||||
it("deduplicates once the event has been fully PROCESSED", async ({ expect }) => {
|
||||
const event = makeEvent();
|
||||
|
||||
await claimStripeEvent(event);
|
||||
await markStripeEventProcessed(event.id);
|
||||
|
||||
const processedRow = await globalPrismaClient.stripeWebhookEvent.findUnique({
|
||||
where: { stripeEventId: event.id },
|
||||
});
|
||||
expect(processedRow?.status).toBe(StripeWebhookEventStatus.PROCESSED);
|
||||
expect(processedRow?.processedAt).not.toBeNull();
|
||||
expect(processedRow?.lastError).toBeNull();
|
||||
|
||||
// A Stripe redelivery of an already-processed event must be a no-op.
|
||||
const redelivery = await claimStripeEvent(event);
|
||||
expect(redelivery.shouldProcess).toBe(false);
|
||||
});
|
||||
|
||||
it("records the error on failure and allows recovery via redelivery", async ({ expect }) => {
|
||||
const event = makeEvent();
|
||||
|
||||
await claimStripeEvent(event);
|
||||
await markStripeEventFailed(event.id, new Error("boom while processing"));
|
||||
|
||||
const failedRow = await globalPrismaClient.stripeWebhookEvent.findUnique({
|
||||
where: { stripeEventId: event.id },
|
||||
});
|
||||
expect(failedRow?.status).toBe(StripeWebhookEventStatus.FAILED);
|
||||
expect(failedRow?.lastError).toContain("boom while processing");
|
||||
|
||||
// FAILED rows must reprocess so a manual Stripe "Resend" can recover them.
|
||||
const recovery = await claimStripeEvent(event);
|
||||
expect(recovery.shouldProcess).toBe(true);
|
||||
|
||||
// ...but reclaiming a FAILED row flips it back to in-flight (PENDING), so a
|
||||
// further redelivery during that retry is once again skipped (single-flight).
|
||||
const concurrentRetry = await claimStripeEvent(event);
|
||||
expect(concurrentRetry.shouldProcess).toBe(false);
|
||||
});
|
||||
|
||||
it("scrubs a stale processedAt when a row leaves the PROCESSED state", async ({ expect }) => {
|
||||
const event = makeEvent();
|
||||
|
||||
await claimStripeEvent(event);
|
||||
await markStripeEventProcessed(event.id);
|
||||
|
||||
// markStripeEventFailed must clear processedAt so a recovered/re-failed row is
|
||||
// never readable as "completed at <time>".
|
||||
await markStripeEventFailed(event.id, new Error("late failure after success"));
|
||||
const failedRow = await globalPrismaClient.stripeWebhookEvent.findUnique({
|
||||
where: { stripeEventId: event.id },
|
||||
});
|
||||
expect(failedRow?.status).toBe(StripeWebhookEventStatus.FAILED);
|
||||
expect(failedRow?.processedAt).toBeNull();
|
||||
|
||||
// Force a stale processedAt on a FAILED row, then prove the FAILED -> PENDING
|
||||
// recovery transition scrubs it too.
|
||||
await globalPrismaClient.stripeWebhookEvent.update({
|
||||
where: { stripeEventId: event.id },
|
||||
data: { processedAt: new Date() },
|
||||
});
|
||||
const recovery = await claimStripeEvent(event);
|
||||
expect(recovery.shouldProcess).toBe(true);
|
||||
const recoveredRow = await globalPrismaClient.stripeWebhookEvent.findUnique({
|
||||
where: { stripeEventId: event.id },
|
||||
});
|
||||
expect(recoveredRow?.status).toBe(StripeWebhookEventStatus.PENDING);
|
||||
expect(recoveredRow?.processedAt).toBeNull();
|
||||
});
|
||||
});
|
||||
60
apps/backend/src/lib/stripe-webhook-events.ts
Normal file
60
apps/backend/src/lib/stripe-webhook-events.ts
Normal file
@ -0,0 +1,60 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { StripeWebhookEventStatus } from "@/generated/prisma/client";
|
||||
import { globalPrismaClient } from "@/prisma-client";
|
||||
import { errorToNiceString } from "@hexclave/shared/dist/utils/errors";
|
||||
import type Stripe from "stripe";
|
||||
|
||||
/**
|
||||
* Idempotency + recovery layer for incoming Stripe webhook events.
|
||||
*
|
||||
* Each event is persisted (keyed on the Stripe `event.id`) synchronously before
|
||||
* we ack 200 to Stripe. Processing then runs in the background. Because Stripe
|
||||
* delivers at-least-once, this is what guarantees the receipt fan-out happens at
|
||||
* most once per event. The full `payload` is stored so PENDING/FAILED rows can
|
||||
* be replayed manually if the background work is dropped (e.g. instance recycle).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Atomically claims an event for processing, guaranteeing single-flight: at most
|
||||
* one worker processes a given event at a time. Returns `shouldProcess: true`
|
||||
* only to the caller that won the claim.
|
||||
*
|
||||
* A new event is inserted as PENDING. On a redelivery, the claim is only handed
|
||||
* out again if the previous attempt FAILED (the `WHERE status = 'FAILED'` makes
|
||||
* the takeover conditional and atomic, so concurrent redeliveries can't both
|
||||
* win). PENDING (in-flight) and PROCESSED (done) rows yield no row and are
|
||||
* skipped. A PENDING row whose worker died is recovered manually from `payload`.
|
||||
*/
|
||||
export async function claimStripeEvent(event: Stripe.Event): Promise<{ shouldProcess: boolean }> {
|
||||
const claimed = await globalPrismaClient.$queryRaw<{ id: string }[]>`
|
||||
INSERT INTO "StripeWebhookEvent" ("id", "stripeEventId", "eventType", "stripeAccountId", "payload", "status", "updatedAt")
|
||||
VALUES (${randomUUID()}::uuid, ${event.id}, ${event.type}, ${event.account ?? null}, ${JSON.stringify(event)}::jsonb, 'PENDING', now())
|
||||
ON CONFLICT ("stripeEventId") DO UPDATE
|
||||
SET "status" = 'PENDING', "lastError" = NULL, "processedAt" = NULL, "updatedAt" = now()
|
||||
WHERE "StripeWebhookEvent"."status" = 'FAILED'
|
||||
RETURNING "id"
|
||||
`;
|
||||
return { shouldProcess: claimed.length === 1 };
|
||||
}
|
||||
|
||||
export async function markStripeEventProcessed(stripeEventId: string): Promise<void> {
|
||||
await globalPrismaClient.stripeWebhookEvent.update({
|
||||
where: { stripeEventId },
|
||||
data: {
|
||||
status: StripeWebhookEventStatus.PROCESSED,
|
||||
processedAt: new Date(),
|
||||
lastError: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function markStripeEventFailed(stripeEventId: string, error: unknown): Promise<void> {
|
||||
await globalPrismaClient.stripeWebhookEvent.update({
|
||||
where: { stripeEventId },
|
||||
data: {
|
||||
status: StripeWebhookEventStatus.FAILED,
|
||||
lastError: errorToNiceString(error),
|
||||
processedAt: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -4,6 +4,7 @@ import { wait } from "@hexclave/shared/dist/utils/promises";
|
||||
import { Result } from "@hexclave/shared/dist/utils/results";
|
||||
import { mergeScopeStrings } from "@hexclave/shared/dist/utils/strings";
|
||||
import { CallbackParamsType, Client, Issuer, TokenSet as OIDCTokenSet, custom, generators } from "openid-client";
|
||||
import { assertSafeOAuthUrl, safeOAuthDnsLookup } from "../ssrf-protection";
|
||||
import { OAuthUserInfo } from "../utils";
|
||||
|
||||
const OAUTH_USERINFO_TOTAL_ATTEMPTS = 3;
|
||||
@ -32,6 +33,7 @@ const RETRYABLE_OAUTH_PROVIDER_ERROR_CODES = new Set([
|
||||
// requests a little more room while still bounding backend request latency.
|
||||
custom.setHttpOptionsDefaults({
|
||||
timeout: OAUTH_HTTP_TIMEOUT_MS,
|
||||
lookup: safeOAuthDnsLookup,
|
||||
});
|
||||
|
||||
export type TokenSet = {
|
||||
@ -305,6 +307,26 @@ function processTokenSet(providerName: string, tokenSet: OIDCTokenSet, defaultAc
|
||||
};
|
||||
}
|
||||
|
||||
async function assertSafeDiscoveredIssuerMetadata(issuer: {
|
||||
metadata: {
|
||||
authorization_endpoint?: string,
|
||||
token_endpoint?: string,
|
||||
userinfo_endpoint?: string,
|
||||
jwks_uri?: string,
|
||||
},
|
||||
}) {
|
||||
for (const url of [
|
||||
issuer.metadata.authorization_endpoint,
|
||||
issuer.metadata.token_endpoint,
|
||||
issuer.metadata.userinfo_endpoint,
|
||||
issuer.metadata.jwks_uri,
|
||||
]) {
|
||||
if (url !== undefined) {
|
||||
await assertSafeOAuthUrl(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export abstract class OAuthBaseProvider {
|
||||
constructor(
|
||||
public readonly oauthClient: Client,
|
||||
@ -354,13 +376,20 @@ export abstract class OAuthBaseProvider {
|
||||
}
|
||||
)
|
||||
) {
|
||||
const issuer = "discoverFromUrl" in options ? await Issuer.discover(options.discoverFromUrl) : new Issuer({
|
||||
issuer: options.issuer,
|
||||
authorization_endpoint: options.authorizationEndpoint,
|
||||
token_endpoint: options.tokenEndpoint,
|
||||
userinfo_endpoint: options.userinfoEndpoint,
|
||||
jwks_uri: options.openid ? options.jwksUri : undefined,
|
||||
});
|
||||
const issuer = "discoverFromUrl" in options
|
||||
? await (async () => {
|
||||
await assertSafeOAuthUrl(options.discoverFromUrl);
|
||||
const discoveredIssuer = await Issuer.discover(options.discoverFromUrl);
|
||||
await assertSafeDiscoveredIssuerMetadata(discoveredIssuer);
|
||||
return discoveredIssuer;
|
||||
})()
|
||||
: new Issuer({
|
||||
issuer: options.issuer,
|
||||
authorization_endpoint: options.authorizationEndpoint,
|
||||
token_endpoint: options.tokenEndpoint,
|
||||
userinfo_endpoint: options.userinfoEndpoint,
|
||||
jwks_uri: options.openid ? options.jwksUri : undefined,
|
||||
});
|
||||
const oauthClient = new issuer.Client({
|
||||
client_id: options.clientId,
|
||||
client_secret: options.clientSecret,
|
||||
|
||||
@ -41,6 +41,9 @@ export class TwitchProvider extends OAuthBaseProvider {
|
||||
displayName: userInfo.display_name,
|
||||
email: userInfo.email,
|
||||
profileImageUrl: userInfo.profile_image_url,
|
||||
// Twitch documents this Helix field as "the user's verified email address"
|
||||
// when the token has `user:read:email`.
|
||||
// https://dev.twitch.tv/docs/api/reference/#get-users
|
||||
emailVerified: true,
|
||||
});
|
||||
}
|
||||
|
||||
52
apps/backend/src/oauth/ssrf-protection.test.ts
Normal file
52
apps/backend/src/oauth/ssrf-protection.test.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import { StatusError } from "@hexclave/shared/dist/utils/errors";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { assertSafeOAuthResolvedAddress, assertSafeOAuthUrlWithoutDns, isBlockedOAuthIpAddress } from "./ssrf-protection";
|
||||
|
||||
describe("isBlockedOAuthIpAddress", () => {
|
||||
it("blocks AWS metadata, loopback, and private IPv4 ranges", () => {
|
||||
expect(isBlockedOAuthIpAddress("169.254.169.254")).toBe(true);
|
||||
expect(isBlockedOAuthIpAddress("127.0.0.1")).toBe(true);
|
||||
expect(isBlockedOAuthIpAddress("10.0.0.8")).toBe(true);
|
||||
expect(isBlockedOAuthIpAddress("172.16.0.1")).toBe(true);
|
||||
expect(isBlockedOAuthIpAddress("192.168.1.1")).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks local and private IPv6 ranges", () => {
|
||||
expect(isBlockedOAuthIpAddress("::1")).toBe(true);
|
||||
expect(isBlockedOAuthIpAddress("[::1]")).toBe(true);
|
||||
expect(isBlockedOAuthIpAddress("fe80::1")).toBe(true);
|
||||
expect(isBlockedOAuthIpAddress("fc00::1")).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks IPv4-mapped IPv6 internal addresses", () => {
|
||||
expect(isBlockedOAuthIpAddress("::ffff:127.0.0.1")).toBe(true);
|
||||
expect(isBlockedOAuthIpAddress("::ffff:169.254.169.254")).toBe(true);
|
||||
});
|
||||
|
||||
it("allows public IP addresses", () => {
|
||||
expect(isBlockedOAuthIpAddress("8.8.8.8")).toBe(false);
|
||||
expect(isBlockedOAuthIpAddress("2001:4860:4860::8888")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertSafeOAuthUrlWithoutDns", () => {
|
||||
it("requires HTTPS", () => {
|
||||
expect(() => assertSafeOAuthUrlWithoutDns("http://accounts.example.com")).toThrow(StatusError);
|
||||
});
|
||||
|
||||
it("blocks IP-literal internal hosts", () => {
|
||||
expect(() => assertSafeOAuthUrlWithoutDns("https://169.254.169.254/latest/meta-data/")).toThrow(StatusError);
|
||||
expect(() => assertSafeOAuthUrlWithoutDns("https://[::1]/.well-known/openid-configuration")).toThrow(StatusError);
|
||||
});
|
||||
|
||||
it("allows public HTTPS URLs before DNS resolution", () => {
|
||||
expect(assertSafeOAuthUrlWithoutDns("https://accounts.google.com").hostname).toBe("accounts.google.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertSafeOAuthResolvedAddress", () => {
|
||||
it("rejects DNS results that resolve to internal addresses", () => {
|
||||
expect(() => assertSafeOAuthResolvedAddress("192.168.0.10")).toThrow(StatusError);
|
||||
});
|
||||
});
|
||||
|
||||
154
apps/backend/src/oauth/ssrf-protection.ts
Normal file
154
apps/backend/src/oauth/ssrf-protection.ts
Normal file
@ -0,0 +1,154 @@
|
||||
import dns from "node:dns";
|
||||
import net from "node:net";
|
||||
import { StatusError } from "@hexclave/shared/dist/utils/errors";
|
||||
import { getNodeEnvironment } from "@hexclave/shared/dist/utils/env";
|
||||
|
||||
const OAUTH_SSRF_PROTECTION_ERROR = "OAuth provider URLs must use HTTPS and resolve only to public internet addresses.";
|
||||
|
||||
const blockedAddressRanges = new net.BlockList();
|
||||
for (const [address, prefix, type] of [
|
||||
["0.0.0.0", 8, "ipv4"],
|
||||
["10.0.0.0", 8, "ipv4"],
|
||||
["100.64.0.0", 10, "ipv4"],
|
||||
["127.0.0.0", 8, "ipv4"],
|
||||
["169.254.0.0", 16, "ipv4"],
|
||||
["172.16.0.0", 12, "ipv4"],
|
||||
["192.0.0.0", 24, "ipv4"],
|
||||
["192.0.2.0", 24, "ipv4"],
|
||||
["192.168.0.0", 16, "ipv4"],
|
||||
["198.18.0.0", 15, "ipv4"],
|
||||
["198.51.100.0", 24, "ipv4"],
|
||||
["203.0.113.0", 24, "ipv4"],
|
||||
["224.0.0.0", 4, "ipv4"],
|
||||
["240.0.0.0", 4, "ipv4"],
|
||||
["::", 128, "ipv6"],
|
||||
["::1", 128, "ipv6"],
|
||||
["64:ff9b::", 96, "ipv6"],
|
||||
["100::", 64, "ipv6"],
|
||||
["2001::", 23, "ipv6"],
|
||||
["2001:db8::", 32, "ipv6"],
|
||||
["fc00::", 7, "ipv6"],
|
||||
["fe80::", 10, "ipv6"],
|
||||
["ff00::", 8, "ipv6"],
|
||||
] as const) {
|
||||
blockedAddressRanges.addSubnet(address, prefix, type);
|
||||
}
|
||||
|
||||
function shouldEnforceOAuthSsrfProtection(): boolean {
|
||||
return !["development", "test"].includes(getNodeEnvironment());
|
||||
}
|
||||
|
||||
function hostnameWithoutIpv6Brackets(hostname: string): string {
|
||||
if (hostname.startsWith("[") && hostname.endsWith("]")) {
|
||||
return hostname.slice(1, -1);
|
||||
}
|
||||
return hostname;
|
||||
}
|
||||
|
||||
function getIpv4MappedAddress(address: string): string | null {
|
||||
const prefix = "::ffff:";
|
||||
if (!address.toLowerCase().startsWith(prefix)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mappedAddress = address.slice(prefix.length);
|
||||
return net.isIP(mappedAddress) === 4 ? mappedAddress : null;
|
||||
}
|
||||
|
||||
export function isBlockedOAuthIpAddress(address: string): boolean {
|
||||
const normalizedAddress = hostnameWithoutIpv6Brackets(address);
|
||||
const ipVersion = net.isIP(normalizedAddress);
|
||||
if (ipVersion === 4) {
|
||||
return blockedAddressRanges.check(normalizedAddress, "ipv4");
|
||||
}
|
||||
if (ipVersion === 6) {
|
||||
const mappedAddress = getIpv4MappedAddress(normalizedAddress);
|
||||
if (mappedAddress !== null) {
|
||||
return blockedAddressRanges.check(mappedAddress, "ipv4");
|
||||
}
|
||||
return blockedAddressRanges.check(normalizedAddress, "ipv6");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function assertSafeOAuthUrlWithoutDns(urlString: string): URL {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(urlString);
|
||||
} catch (error) {
|
||||
throw new StatusError(StatusError.BadRequest, "OAuth provider URL is not a valid URL.");
|
||||
}
|
||||
|
||||
if (url.protocol !== "https:") {
|
||||
throw new StatusError(StatusError.BadRequest, OAUTH_SSRF_PROTECTION_ERROR);
|
||||
}
|
||||
|
||||
if (isBlockedOAuthIpAddress(url.hostname)) {
|
||||
throw new StatusError(StatusError.BadRequest, OAUTH_SSRF_PROTECTION_ERROR);
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
export async function assertSafeOAuthUrl(urlString: string): Promise<void> {
|
||||
if (!shouldEnforceOAuthSsrfProtection()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const url = assertSafeOAuthUrlWithoutDns(urlString);
|
||||
const hostname = hostnameWithoutIpv6Brackets(url.hostname);
|
||||
if (net.isIP(hostname) !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const addresses = await dns.promises.lookup(hostname, { all: true, verbatim: true });
|
||||
for (const address of addresses) {
|
||||
assertSafeOAuthResolvedAddress(address.address);
|
||||
}
|
||||
}
|
||||
|
||||
export function assertSafeOAuthResolvedAddress(address: string): void {
|
||||
if (isBlockedOAuthIpAddress(address)) {
|
||||
throw new StatusError(StatusError.BadRequest, OAUTH_SSRF_PROTECTION_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
type DnsLookupCallback = (
|
||||
error: NodeJS.ErrnoException | null,
|
||||
address: string | dns.LookupAddress[],
|
||||
family?: number,
|
||||
) => void;
|
||||
|
||||
export function safeOAuthDnsLookup(hostname: string, options: dns.LookupOptions, callback: DnsLookupCallback): void {
|
||||
if (!shouldEnforceOAuthSsrfProtection()) {
|
||||
dns.lookup(hostname, options, callback);
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.all) {
|
||||
const lookupOptions: dns.LookupAllOptions = { ...options, all: true };
|
||||
dns.lookup(hostname, lookupOptions, (error, addresses) => {
|
||||
if (error) {
|
||||
callback(error, []);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const address of addresses) {
|
||||
assertSafeOAuthResolvedAddress(address.address);
|
||||
}
|
||||
callback(null, addresses);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const lookupOptions: dns.LookupOneOptions = { ...options, all: false };
|
||||
dns.lookup(hostname, lookupOptions, (error, address, family) => {
|
||||
if (error) {
|
||||
callback(error, "", 0);
|
||||
return;
|
||||
}
|
||||
|
||||
assertSafeOAuthResolvedAddress(address);
|
||||
callback(null, address, family);
|
||||
});
|
||||
}
|
||||
@ -742,7 +742,6 @@ Reference surfaces:
|
||||
- `apps/dashboard/src/app/(main)/(protected)/projects/[projectId]`
|
||||
- `apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/emails`
|
||||
- `apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts`
|
||||
- `apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-outbox`
|
||||
- `apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-templates`
|
||||
- `apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-themes`
|
||||
|
||||
@ -803,21 +802,7 @@ Keep:
|
||||
|
||||
- specialized editor layout systems if no design-components equivalent exists
|
||||
|
||||
### 5.4 `/projects/[projectId]/email-outbox`
|
||||
|
||||
Use:
|
||||
|
||||
- section cards: `DesignCard` (preferred for visual consistency with other email screens)
|
||||
- filters: `DesignSelectorDropdown`, `DesignInput`
|
||||
- status badges: `DesignBadge`
|
||||
- action buttons/menus: `DesignButton`, `DesignMenu`
|
||||
- data grid/list table: `DataGrid` + `useDataSource` + `createDefaultDataGridState`
|
||||
|
||||
Avoid:
|
||||
|
||||
- mixed badge systems (`Badge` in some places, custom badges elsewhere)
|
||||
|
||||
### 5.5 `/projects/[projectId]/email-templates`
|
||||
### 5.4 `/projects/[projectId]/email-templates`
|
||||
|
||||
Use:
|
||||
|
||||
@ -830,7 +815,7 @@ Avoid:
|
||||
|
||||
- inline repeated glass class blocks for each template card
|
||||
|
||||
### 5.6 `/projects/[projectId]/email-templates/[templateId]`
|
||||
### 5.5 `/projects/[projectId]/email-templates/[templateId]`
|
||||
|
||||
Use:
|
||||
|
||||
@ -838,7 +823,7 @@ Use:
|
||||
- top actions: `DesignButton`
|
||||
- state tags: `DesignBadge` where needed
|
||||
|
||||
### 5.7 `/projects/[projectId]/email-themes`
|
||||
### 5.6 `/projects/[projectId]/email-themes`
|
||||
|
||||
Use:
|
||||
|
||||
@ -852,7 +837,7 @@ Avoid:
|
||||
|
||||
- custom `ViewportSelector` if `DesignPillToggle` supports the same behavior
|
||||
|
||||
### 5.8 `/projects/[projectId]/email-themes/[themeId]`
|
||||
### 5.7 `/projects/[projectId]/email-themes/[themeId]`
|
||||
|
||||
Use:
|
||||
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
{
|
||||
"name": "@hexclave/dashboard",
|
||||
"version": "1.0.28",
|
||||
"version": "1.0.30",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"clean": "rimraf .next && rimraf node_modules",
|
||||
"clean": "rimraf .next .next-development-environment node_modules",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"with-env": "dotenv -c development --",
|
||||
"with-env:prod": "dotenv -c --",
|
||||
@ -12,7 +12,7 @@
|
||||
"dev:rde-production": "node scripts/dev-rde-production.mjs",
|
||||
"bundle-type-definitions": "tsx scripts/bundle-type-definitions.ts",
|
||||
"bundle-type-definitions:watch": "tsx watch --clear-screen=false scripts/bundle-type-definitions.ts",
|
||||
"build": "pnpm run bundle-type-definitions && next build",
|
||||
"build": "rimraf .next/types .next/dev/types .next-development-environment/types .next-development-environment/dev/types && pnpm run bundle-type-definitions && next build",
|
||||
"build:rde-standalone": "NEXT_CONFIG_OUTPUT=standalone STACK_NEXT_CONFIG_DISABLE_TYPESCRIPT=true pnpm run build",
|
||||
"docker-build": "pnpm run bundle-type-definitions && next build --experimental-build-mode compile",
|
||||
"analyze-bundle": "next experimental-analyze",
|
||||
|
||||
@ -15,6 +15,7 @@ import {
|
||||
WarningCircleIcon
|
||||
} from "@phosphor-icons/react";
|
||||
import { Alert, AlertDescription, Button } from "@/components/ui";
|
||||
import { Link } from "@/components/link";
|
||||
import { useDashboardInternalUser } from "@/lib/dashboard-user";
|
||||
import { PLAN_LIMITS, resolvePlanId } from "@hexclave/shared/dist/plans";
|
||||
import { runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises";
|
||||
@ -340,7 +341,7 @@ export function AnalyticsEventLimitBanner() {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <AnalyticsEventLimitBannerInner team={ownerTeam} />;
|
||||
return <AnalyticsEventLimitBannerInner team={ownerTeam} projectId={project.id} />;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -395,7 +396,7 @@ function SessionReplayLimitBannerInner({ team }: { team: { useItem: (itemId: str
|
||||
);
|
||||
}
|
||||
|
||||
function AnalyticsEventLimitBannerInner({ team }: { team: { useItem: (itemId: string) => { quantity: number }, useProducts: () => Array<{ id: string | null, type?: string }>, createCheckoutUrl: (options: { productId: string, returnUrl: string }) => Promise<string> } }) {
|
||||
function AnalyticsEventLimitBannerInner({ team, projectId }: { team: { useItem: (itemId: string) => { quantity: number }, useProducts: () => Array<{ id: string | null, type?: string }>, createCheckoutUrl: (options: { productId: string, returnUrl: string }) => Promise<string> }, projectId: string }) {
|
||||
const eventsItem = team.useItem("analytics_events");
|
||||
const products = team.useProducts();
|
||||
const planId = resolvePlanId(products);
|
||||
@ -433,15 +434,26 @@ function AnalyticsEventLimitBannerInner({ team }: { team: { useItem: (itemId: st
|
||||
}
|
||||
{canUpgrade && !isExhausted && " Consider upgrading your plan."}
|
||||
</span>
|
||||
{canUpgrade && (
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={isExhausted ? "destructive" : "outline"}
|
||||
onClick={handleUpgrade}
|
||||
variant="outline"
|
||||
asChild
|
||||
>
|
||||
Upgrade plan
|
||||
<Link href={`/projects/${projectId}/project-settings/usage`}>
|
||||
View usage
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{canUpgrade && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant={isExhausted ? "destructive" : "outline"}
|
||||
onClick={handleUpgrade}
|
||||
>
|
||||
Upgrade plan
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
@ -1,835 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { SettingCard } from "@/components/settings";
|
||||
import { ActionDialog, Badge, Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SimpleTooltip, Switch, Typography, useToast } from "@/components/ui";
|
||||
import { DataGrid, DataGridToolbar, useDataGridUrlState, useDataSource, type DataGridColumnDef, type DataGridDataSource } from "@hexclave/dashboard-ui-components";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { DotsThreeIcon, PauseIcon, PlayIcon, XCircleIcon } from "@phosphor-icons/react";
|
||||
import { AdminEmailOutbox, AdminEmailOutboxSimpleStatus, AdminEmailOutboxStatus } from "@hexclave/next";
|
||||
import { fromNow } from "@hexclave/shared/dist/utils/dates";
|
||||
import { runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { PageLayout } from "../page-layout";
|
||||
import { useAdminApp } from "../use-admin-app";
|
||||
|
||||
const STATUS_LABELS: Record<AdminEmailOutboxStatus, string> = {
|
||||
"paused": "Paused",
|
||||
"preparing": "Preparing",
|
||||
"rendering": "Rendering",
|
||||
"render-error": "Render Error",
|
||||
"scheduled": "Scheduled",
|
||||
"queued": "Queued",
|
||||
"sending": "Sending",
|
||||
"server-error": "Server Error",
|
||||
"skipped": "Skipped",
|
||||
"bounced": "Bounced",
|
||||
"delivery-delayed": "Delivery Delayed",
|
||||
"sent": "Sent",
|
||||
"opened": "Opened",
|
||||
"clicked": "Clicked",
|
||||
"marked-as-spam": "Marked as Spam",
|
||||
};
|
||||
|
||||
const SIMPLE_STATUS_LABELS: Record<AdminEmailOutboxSimpleStatus, string> = {
|
||||
"in-progress": "In Progress",
|
||||
"ok": "Completed",
|
||||
"error": "Error",
|
||||
};
|
||||
|
||||
function getStatusBadgeVariant(simpleStatus: AdminEmailOutboxSimpleStatus): "default" | "secondary" | "destructive" | "outline" {
|
||||
switch (simpleStatus) {
|
||||
case "ok": {
|
||||
return "secondary";
|
||||
}
|
||||
case "error": {
|
||||
return "destructive";
|
||||
}
|
||||
case "in-progress": {
|
||||
return "default";
|
||||
}
|
||||
default: {
|
||||
return "default";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getRecipientDisplay(email: AdminEmailOutbox): string {
|
||||
const to = email.to;
|
||||
if (to.type === "user-primary-email") {
|
||||
return `User: ${to.userId.slice(0, 8)}...`;
|
||||
} else if (to.type === "user-custom-emails") {
|
||||
return to.emails.join(", ") || `User: ${to.userId.slice(0, 8)}...`;
|
||||
} else {
|
||||
return to.emails.join(", ") || "No recipients";
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to check if email is paused (avoids type narrowing issues)
|
||||
function isEmailPaused(email: AdminEmailOutbox): boolean {
|
||||
// Cast to string to avoid TypeScript complaining about exhaustive type narrowing
|
||||
return (email.status as string) === "paused";
|
||||
}
|
||||
|
||||
// Helper to check if we can pause - works with any email type
|
||||
function canPauseEmail(email: AdminEmailOutbox): boolean {
|
||||
const pausableStatuses = ["preparing", "rendering", "scheduled", "queued", "render-error", "server-error"];
|
||||
return !isEmailPaused(email) && pausableStatuses.includes(email.status);
|
||||
}
|
||||
|
||||
// Helper to check if we can cancel - works with any email type
|
||||
function canCancelEmail(email: AdminEmailOutbox): boolean {
|
||||
const cancellableStatuses = ["paused", "preparing", "rendering", "scheduled", "queued", "render-error", "server-error"];
|
||||
return cancellableStatuses.includes(email.status);
|
||||
}
|
||||
|
||||
function EmailActions({
|
||||
email,
|
||||
onRefresh,
|
||||
}: {
|
||||
email: AdminEmailOutbox,
|
||||
onRefresh: () => Promise<void>,
|
||||
}) {
|
||||
const hexclaveAdminApp = useAdminApp();
|
||||
const { toast } = useToast();
|
||||
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
|
||||
|
||||
const canPause = canPauseEmail(email);
|
||||
const canUnpause = isEmailPaused(email);
|
||||
const canCancel = canCancelEmail(email);
|
||||
|
||||
const handlePause = () => {
|
||||
runAsynchronouslyWithAlert(async () => {
|
||||
await hexclaveAdminApp.pauseOutboxEmail(email.id);
|
||||
toast({
|
||||
title: "Email paused",
|
||||
description: "The email has been paused and will not be sent until unpaused.",
|
||||
variant: "success",
|
||||
});
|
||||
await onRefresh();
|
||||
});
|
||||
};
|
||||
|
||||
const handleUnpause = () => {
|
||||
runAsynchronouslyWithAlert(async () => {
|
||||
await hexclaveAdminApp.unpauseOutboxEmail(email.id);
|
||||
toast({
|
||||
title: "Email unpaused",
|
||||
description: "The email will continue processing.",
|
||||
variant: "success",
|
||||
});
|
||||
await onRefresh();
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancel = async () => {
|
||||
await hexclaveAdminApp.cancelOutboxEmail(email.id);
|
||||
toast({
|
||||
title: "Email cancelled",
|
||||
description: "The email has been cancelled and will not be sent.",
|
||||
variant: "success",
|
||||
});
|
||||
setCancelDialogOpen(false);
|
||||
await onRefresh();
|
||||
};
|
||||
|
||||
if (!canPause && !canUnpause && !canCancel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<DotsThreeIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{canPause && (
|
||||
<DropdownMenuItem onClick={handlePause}>
|
||||
<PauseIcon className="mr-2 h-4 w-4" />
|
||||
Pause
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canUnpause && (
|
||||
<DropdownMenuItem onClick={handleUnpause}>
|
||||
<PlayIcon className="mr-2 h-4 w-4" />
|
||||
Unpause
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{(canPause || canUnpause) && canCancel && <DropdownMenuSeparator />}
|
||||
{canCancel && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => setCancelDialogOpen(true)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<XCircleIcon className="mr-2 h-4 w-4" />
|
||||
Cancel
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<ActionDialog
|
||||
open={cancelDialogOpen}
|
||||
onClose={() => setCancelDialogOpen(false)}
|
||||
title="Cancel Email"
|
||||
cancelButton
|
||||
okButton={{
|
||||
label: "Cancel Email",
|
||||
onClick: handleCancel,
|
||||
props: { variant: "destructive" },
|
||||
}}
|
||||
>
|
||||
<Typography>
|
||||
Are you sure you want to cancel this email? This action cannot be undone.
|
||||
</Typography>
|
||||
</ActionDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const EDITABLE_STATUSES: AdminEmailOutboxStatus[] = [
|
||||
"paused", "preparing", "rendering", "render-error", "scheduled", "queued", "server-error",
|
||||
];
|
||||
|
||||
function isEditable(email: AdminEmailOutbox): boolean {
|
||||
return EDITABLE_STATUSES.includes(email.status);
|
||||
}
|
||||
|
||||
// Helper type to extract optional properties from the discriminated union for display
|
||||
type EmailDisplayData = {
|
||||
// Rendering
|
||||
startedRenderingAt?: Date,
|
||||
renderedAt?: Date,
|
||||
subject?: string,
|
||||
isTransactional?: boolean,
|
||||
isHighPriority?: boolean,
|
||||
renderError?: string,
|
||||
// Sending
|
||||
startedSendingAt?: Date,
|
||||
deliveredAt?: Date,
|
||||
serverError?: string,
|
||||
errorAt?: Date,
|
||||
// Skipped
|
||||
skippedAt?: Date,
|
||||
skippedReason?: string,
|
||||
skippedDetails?: Record<string, unknown>,
|
||||
// Tracking
|
||||
canHaveDeliveryInfo?: boolean,
|
||||
bouncedAt?: Date,
|
||||
deliveryDelayedAt?: Date,
|
||||
openedAt?: Date,
|
||||
clickedAt?: Date,
|
||||
markedAsSpamAt?: Date,
|
||||
};
|
||||
|
||||
// Extract display data from any email type
|
||||
function getEmailDisplayData(email: AdminEmailOutbox): EmailDisplayData {
|
||||
// Cast to any to access properties that may not exist on all variants
|
||||
// This is safe because we're just extracting values for display
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const e = email as any;
|
||||
return {
|
||||
startedRenderingAt: e.startedRenderingAt,
|
||||
renderedAt: e.renderedAt,
|
||||
subject: e.subject,
|
||||
isTransactional: e.isTransactional,
|
||||
isHighPriority: e.isHighPriority,
|
||||
renderError: e.renderError,
|
||||
startedSendingAt: e.startedSendingAt,
|
||||
deliveredAt: e.deliveredAt,
|
||||
serverError: e.serverError,
|
||||
errorAt: e.errorAt,
|
||||
skippedAt: e.skippedAt,
|
||||
skippedReason: e.skippedReason,
|
||||
skippedDetails: e.skippedDetails,
|
||||
canHaveDeliveryInfo: e.canHaveDeliveryInfo,
|
||||
bouncedAt: e.bouncedAt,
|
||||
deliveryDelayedAt: e.deliveryDelayedAt,
|
||||
openedAt: e.openedAt,
|
||||
clickedAt: e.clickedAt,
|
||||
markedAsSpamAt: e.markedAsSpamAt,
|
||||
};
|
||||
}
|
||||
|
||||
function PropertyRow({
|
||||
label,
|
||||
value,
|
||||
className,
|
||||
}: {
|
||||
label: string,
|
||||
value: React.ReactNode,
|
||||
className?: string,
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-1", className)}>
|
||||
<Typography className="text-xs font-medium text-muted-foreground uppercase tracking-wide">{label}</Typography>
|
||||
<div className="text-sm">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmailDetailSheet({
|
||||
email,
|
||||
open,
|
||||
onOpenChange,
|
||||
onRefresh,
|
||||
}: {
|
||||
email: AdminEmailOutbox | null,
|
||||
open: boolean,
|
||||
onOpenChange: (open: boolean) => void,
|
||||
onRefresh: () => Promise<void>,
|
||||
}) {
|
||||
const hexclaveAdminApp = useAdminApp();
|
||||
const { toast } = useToast();
|
||||
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// Editable fields state
|
||||
const [scheduledAt, setScheduledAt] = useState<string>("");
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
|
||||
// Initialize form when email changes
|
||||
const initForm = (e: AdminEmailOutbox) => {
|
||||
setScheduledAt(e.scheduledAt.toISOString().slice(0, 16));
|
||||
setIsPaused(isEmailPaused(e));
|
||||
};
|
||||
|
||||
// Reset form when sheet opens
|
||||
if (email && open) {
|
||||
// Only reset if values haven't been initialized yet
|
||||
const expectedScheduledAt = email.scheduledAt.toISOString().slice(0, 16);
|
||||
if (scheduledAt !== expectedScheduledAt && !isSaving) {
|
||||
initForm(email);
|
||||
}
|
||||
}
|
||||
|
||||
if (!email) return null;
|
||||
|
||||
const editable = isEditable(email);
|
||||
const displayData = getEmailDisplayData(email);
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const updates: { isPaused?: boolean, scheduledAtMillis?: number } = {};
|
||||
if (isPaused !== isEmailPaused(email)) {
|
||||
updates.isPaused = isPaused;
|
||||
}
|
||||
const newScheduledAt = new Date(scheduledAt);
|
||||
if (newScheduledAt.getTime() !== email.scheduledAt.getTime()) {
|
||||
updates.scheduledAtMillis = newScheduledAt.getTime();
|
||||
}
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await hexclaveAdminApp.updateOutboxEmail(email.id, updates);
|
||||
toast({
|
||||
title: "Email updated",
|
||||
description: "The email has been updated successfully.",
|
||||
variant: "success",
|
||||
});
|
||||
await onRefresh();
|
||||
}
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Failed to update email",
|
||||
description: String(error),
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async () => {
|
||||
await hexclaveAdminApp.cancelOutboxEmail(email.id);
|
||||
toast({
|
||||
title: "Email cancelled",
|
||||
description: "The email has been cancelled and will not be sent.",
|
||||
variant: "success",
|
||||
});
|
||||
setCancelDialogOpen(false);
|
||||
await onRefresh();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="w-full sm:max-w-lg overflow-y-auto">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Email Details</SheetTitle>
|
||||
<SheetDescription>
|
||||
View and manage this email
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="mt-6 space-y-6">
|
||||
{/* Status Section */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant={getStatusBadgeVariant(email.simpleStatus)} className="text-sm">
|
||||
{STATUS_LABELS[email.status]}
|
||||
</Badge>
|
||||
{isEmailPaused(email) && (
|
||||
<Badge variant="outline" className="text-sm">
|
||||
<PauseIcon className="h-3 w-3 mr-1" />
|
||||
Paused
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Basic Info */}
|
||||
<div className="space-y-4 border-t pt-4">
|
||||
<Typography className="font-semibold">Basic Information</Typography>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<PropertyRow label="ID" value={<code className="text-xs bg-muted px-1 py-0.5 rounded">{email.id}</code>} />
|
||||
<PropertyRow label="Created" value={email.createdAt.toLocaleString()} />
|
||||
<PropertyRow label="Updated" value={email.updatedAt.toLocaleString()} />
|
||||
{editable ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Scheduled At</Label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={scheduledAt}
|
||||
onChange={(e) => setScheduledAt(e.target.value)}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<PropertyRow label="Scheduled At" value={email.scheduledAt.toLocaleString()} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recipient Info */}
|
||||
<div className="space-y-4 border-t pt-4">
|
||||
<Typography className="font-semibold">Recipient</Typography>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<PropertyRow label="Type" value={email.to.type} />
|
||||
{email.to.type === "user-primary-email" && (
|
||||
<PropertyRow label="User ID" value={<code className="text-xs bg-muted px-1 py-0.5 rounded">{email.to.userId}</code>} />
|
||||
)}
|
||||
{email.to.type === "user-custom-emails" && (
|
||||
<>
|
||||
<PropertyRow label="User ID" value={<code className="text-xs bg-muted px-1 py-0.5 rounded">{email.to.userId}</code>} />
|
||||
<PropertyRow label="Emails" value={email.to.emails.join(", ") || "None"} className="col-span-2" />
|
||||
</>
|
||||
)}
|
||||
{email.to.type === "custom-emails" && (
|
||||
<PropertyRow label="Emails" value={email.to.emails.join(", ") || "None"} className="col-span-2" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rendering Info */}
|
||||
{displayData.startedRenderingAt && (
|
||||
<div className="space-y-4 border-t pt-4">
|
||||
<Typography className="font-semibold">Rendering</Typography>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<PropertyRow label="Started Rendering" value={displayData.startedRenderingAt.toLocaleString()} />
|
||||
{displayData.renderedAt && (
|
||||
<PropertyRow label="Rendered At" value={displayData.renderedAt.toLocaleString()} />
|
||||
)}
|
||||
{displayData.subject && (
|
||||
<PropertyRow label="Subject" value={displayData.subject} className="col-span-2" />
|
||||
)}
|
||||
{displayData.isTransactional !== undefined && (
|
||||
<PropertyRow label="Transactional" value={displayData.isTransactional ? "Yes" : "No"} />
|
||||
)}
|
||||
{displayData.isHighPriority !== undefined && (
|
||||
<PropertyRow label="High Priority" value={displayData.isHighPriority ? "Yes" : "No"} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Render Error */}
|
||||
{displayData.renderError && (
|
||||
<div className="space-y-4 border-t pt-4">
|
||||
<Typography className="font-semibold text-destructive">Render Error</Typography>
|
||||
<pre className="text-xs bg-destructive/10 text-destructive p-3 rounded overflow-x-auto whitespace-pre-wrap break-words">
|
||||
{displayData.renderError}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sending Info */}
|
||||
{displayData.startedSendingAt && (
|
||||
<div className="space-y-4 border-t pt-4">
|
||||
<Typography className="font-semibold">Sending</Typography>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<PropertyRow label="Started Sending" value={displayData.startedSendingAt.toLocaleString()} />
|
||||
{displayData.deliveredAt && (
|
||||
<PropertyRow label="Delivered At" value={displayData.deliveredAt.toLocaleString()} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Server Error */}
|
||||
{displayData.serverError && (
|
||||
<div className="space-y-4 border-t pt-4">
|
||||
<Typography className="font-semibold text-destructive">Server Error</Typography>
|
||||
<pre className="text-xs bg-destructive/10 text-destructive p-3 rounded overflow-x-auto whitespace-pre-wrap break-words">
|
||||
{displayData.serverError}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Skipped Info */}
|
||||
{displayData.skippedAt && (
|
||||
<div className="space-y-4 border-t pt-4">
|
||||
<Typography className="font-semibold">Skipped</Typography>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<PropertyRow label="Skipped At" value={displayData.skippedAt.toLocaleString()} />
|
||||
{displayData.skippedReason && <PropertyRow label="Reason" value={displayData.skippedReason} />}
|
||||
{displayData.skippedDetails && Object.keys(displayData.skippedDetails).length > 0 && (
|
||||
<PropertyRow
|
||||
label="Details"
|
||||
value={<pre className="text-xs bg-muted p-2 rounded overflow-x-auto whitespace-pre-wrap break-words">{JSON.stringify(displayData.skippedDetails, null, 2)}</pre>}
|
||||
className="col-span-2"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bounce Info */}
|
||||
{displayData.bouncedAt && (
|
||||
<div className="space-y-4 border-t pt-4">
|
||||
<Typography className="font-semibold text-destructive">Bounced</Typography>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<PropertyRow label="Bounced At" value={displayData.bouncedAt.toLocaleString()} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delivery Delayed Info */}
|
||||
{displayData.deliveryDelayedAt && (
|
||||
<div className="space-y-4 border-t pt-4">
|
||||
<Typography className="font-semibold">Delivery Delayed</Typography>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<PropertyRow label="Delayed At" value={displayData.deliveryDelayedAt.toLocaleString()} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delivery Tracking */}
|
||||
{displayData.canHaveDeliveryInfo !== undefined && (
|
||||
<div className="space-y-4 border-t pt-4">
|
||||
<Typography className="font-semibold">Delivery Tracking</Typography>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<PropertyRow label="Tracking Available" value={displayData.canHaveDeliveryInfo ? "Yes" : "No"} />
|
||||
{displayData.openedAt && <PropertyRow label="Opened At" value={displayData.openedAt.toLocaleString()} />}
|
||||
{displayData.clickedAt && <PropertyRow label="Clicked At" value={displayData.clickedAt.toLocaleString()} />}
|
||||
{displayData.markedAsSpamAt && <PropertyRow label="Marked as Spam At" value={displayData.markedAsSpamAt.toLocaleString()} />}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Controls Section */}
|
||||
{editable && (
|
||||
<div className="space-y-4 border-t pt-4">
|
||||
<Typography className="font-semibold">Controls</Typography>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label>Paused</Label>
|
||||
<Typography className="text-xs text-muted-foreground">Pause email processing</Typography>
|
||||
</div>
|
||||
<Switch
|
||||
checked={isPaused}
|
||||
onCheckedChange={setIsPaused}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 pt-4 border-t">
|
||||
{editable && (
|
||||
<>
|
||||
<Button onClick={handleSave} disabled={isSaving}>
|
||||
{isSaving ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => setCancelDialogOpen(true)}
|
||||
>
|
||||
Cancel Email
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<ActionDialog
|
||||
open={cancelDialogOpen}
|
||||
onClose={() => setCancelDialogOpen(false)}
|
||||
title="Cancel Email"
|
||||
cancelButton
|
||||
okButton={{
|
||||
label: "Cancel Email",
|
||||
onClick: handleCancel,
|
||||
props: { variant: "destructive" },
|
||||
}}
|
||||
>
|
||||
<Typography>
|
||||
Are you sure you want to cancel this email? This action cannot be undone.
|
||||
</Typography>
|
||||
</ActionDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const EMAIL_PAGE_SIZE = 50;
|
||||
|
||||
export default function PageClient() {
|
||||
const hexclaveAdminApp = useAdminApp();
|
||||
const [statusFilter, setStatusFilter] = useState<string>("all");
|
||||
const [simpleStatusFilter, setSimpleStatusFilter] = useState<string>("all");
|
||||
const [selectedEmail, setSelectedEmail] = useState<AdminEmailOutbox | null>(null);
|
||||
const [detailSheetOpen, setDetailSheetOpen] = useState(false);
|
||||
|
||||
// Server-side infinite data source — cursor pagination against
|
||||
// `listOutboxEmails`. Closure captures `statusFilter`/`simpleStatusFilter`
|
||||
// so a filter change produces a new `dataSource` identity, which
|
||||
// `useDataSource` uses to refetch from scratch.
|
||||
const dataSource = useMemo<DataGridDataSource<AdminEmailOutbox>>(
|
||||
() => async function* (params) {
|
||||
const options: { status?: string, simpleStatus?: string, cursor?: string, limit?: number } = {
|
||||
limit: EMAIL_PAGE_SIZE,
|
||||
};
|
||||
if (statusFilter !== "all") options.status = statusFilter;
|
||||
if (simpleStatusFilter !== "all") options.simpleStatus = simpleStatusFilter;
|
||||
if (typeof params.cursor === "string") options.cursor = params.cursor;
|
||||
const result = await hexclaveAdminApp.listOutboxEmails(options);
|
||||
yield {
|
||||
rows: result.items,
|
||||
hasMore: result.nextCursor != null,
|
||||
nextCursor: result.nextCursor ?? undefined,
|
||||
};
|
||||
},
|
||||
[hexclaveAdminApp, statusFilter, simpleStatusFilter],
|
||||
);
|
||||
|
||||
const handleFilterChange = (newStatusFilter: string, newSimpleStatusFilter: string) => {
|
||||
setStatusFilter(newStatusFilter);
|
||||
setSimpleStatusFilter(newSimpleStatusFilter);
|
||||
};
|
||||
|
||||
// Stable ref the `renderCell` closures reach through to trigger a
|
||||
// refresh. Populated further below once `useDataSource` has returned its
|
||||
// `reload` function.
|
||||
const reloadRef = useRef<() => void>(() => {});
|
||||
|
||||
const emailColumns = useMemo<DataGridColumnDef<AdminEmailOutbox>[]>(() => [
|
||||
{
|
||||
id: "subject",
|
||||
header: "Subject",
|
||||
width: 200,
|
||||
renderCell: ({ row }) => {
|
||||
const subject = getEmailDisplayData(row).subject;
|
||||
return (
|
||||
<div className="truncate">
|
||||
<SimpleTooltip tooltip={subject || "Not rendered yet"}>
|
||||
<span>{subject || <span className="text-muted-foreground italic">Pending</span>}</span>
|
||||
</SimpleTooltip>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "recipient",
|
||||
header: "Recipient",
|
||||
width: 150,
|
||||
renderCell: ({ row }) => {
|
||||
const display = getRecipientDisplay(row);
|
||||
return (
|
||||
<div className="truncate">
|
||||
<SimpleTooltip tooltip={display}>
|
||||
<span className="text-sm">{display}</span>
|
||||
</SimpleTooltip>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
width: 150,
|
||||
renderCell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={getStatusBadgeVariant(row.simpleStatus)}>
|
||||
{STATUS_LABELS[row.status]}
|
||||
</Badge>
|
||||
{isEmailPaused(row) && (
|
||||
<SimpleTooltip tooltip="This email is paused">
|
||||
<PauseIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</SimpleTooltip>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "scheduled",
|
||||
header: "Scheduled",
|
||||
width: 130,
|
||||
type: "dateTime",
|
||||
accessor: "scheduledAt",
|
||||
},
|
||||
{
|
||||
id: "created",
|
||||
header: "Created",
|
||||
width: 130,
|
||||
type: "dateTime",
|
||||
accessor: "createdAt",
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "",
|
||||
width: 60,
|
||||
sortable: false,
|
||||
resizable: false,
|
||||
renderCell: ({ row }) => <EmailActions email={row} onRefresh={async () => { reloadRef.current(); }} />,
|
||||
},
|
||||
], []);
|
||||
|
||||
const [emailGridState, setEmailGridState] = useDataGridUrlState(emailColumns, { paramPrefix: "outbox" });
|
||||
const getRowId = useCallback((row: AdminEmailOutbox) => row.id, []);
|
||||
const emailGridData = useDataSource({
|
||||
dataSource,
|
||||
columns: emailColumns,
|
||||
getRowId,
|
||||
sorting: emailGridState.sorting,
|
||||
quickSearch: emailGridState.quickSearch,
|
||||
pagination: emailGridState.pagination,
|
||||
paginationMode: "infinite",
|
||||
});
|
||||
|
||||
// Keep the ref pointed at the current `reload` so column `renderCell`
|
||||
// closures built once still trigger a fresh fetch.
|
||||
reloadRef.current = emailGridData.reload;
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
emailGridData.reload();
|
||||
}, [emailGridData]);
|
||||
|
||||
const emails = emailGridData.rows;
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
title="Email Outbox"
|
||||
description="View and manage scheduled and sent emails"
|
||||
actions={
|
||||
<Button onClick={() => runAsynchronouslyWithAlert(handleRefresh)} variant="outline">
|
||||
Refresh
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<SettingCard title="Email Queue" description="All emails in the outbox">
|
||||
<div className="flex gap-4 mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Typography className="text-sm font-medium">Status:</Typography>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onValueChange={(value) => handleFilterChange(value, simpleStatusFilter)}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="All statuses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All statuses</SelectItem>
|
||||
{(Object.entries(STATUS_LABELS) as [AdminEmailOutboxStatus, string][]).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>{label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Typography className="text-sm font-medium">Category:</Typography>
|
||||
<Select
|
||||
value={simpleStatusFilter}
|
||||
onValueChange={(value) => handleFilterChange(statusFilter, value)}
|
||||
>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue placeholder="All categories" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All categories</SelectItem>
|
||||
{(Object.entries(SIMPLE_STATUS_LABELS) as [AdminEmailOutboxSimpleStatus, string][]).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>{label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{emailGridData.isLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Typography className="text-muted-foreground">Loading emails...</Typography>
|
||||
</div>
|
||||
) : emails.length === 0 ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Typography className="text-muted-foreground">No emails found</Typography>
|
||||
</div>
|
||||
) : (
|
||||
<DataGrid
|
||||
columns={emailColumns}
|
||||
rows={emailGridData.rows}
|
||||
getRowId={getRowId}
|
||||
totalRowCount={emailGridData.totalRowCount}
|
||||
state={emailGridState}
|
||||
onChange={setEmailGridState}
|
||||
paginationMode="infinite"
|
||||
hasMore={emailGridData.hasMore}
|
||||
isLoadingMore={emailGridData.isLoadingMore}
|
||||
onLoadMore={emailGridData.loadMore}
|
||||
footer={false}
|
||||
fillHeight={false}
|
||||
maxHeight={500}
|
||||
toolbar={(ctx) => <DataGridToolbar ctx={ctx} hideQuickSearch />}
|
||||
onRowClick={(row) => {
|
||||
setSelectedEmail(row);
|
||||
setDetailSheetOpen(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</SettingCard>
|
||||
|
||||
<EmailDetailSheet
|
||||
email={selectedEmail}
|
||||
open={detailSheetOpen}
|
||||
onOpenChange={(open) => {
|
||||
setDetailSheetOpen(open);
|
||||
if (!open) {
|
||||
// Refresh the selected email from the list after closing
|
||||
if (selectedEmail) {
|
||||
const updated = emails.find(e => e.id === selectedEmail.id);
|
||||
if (updated) {
|
||||
setSelectedEmail(updated);
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
onRefresh={async () => {
|
||||
await handleRefresh();
|
||||
// Update selected email with fresh data
|
||||
if (selectedEmail) {
|
||||
const updated = emails.find(e => e.id === selectedEmail.id);
|
||||
if (updated) {
|
||||
setSelectedEmail(updated);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,13 +0,0 @@
|
||||
import PageClient from "./page-client";
|
||||
|
||||
export const metadata = {
|
||||
title: "Email Outbox",
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<PageClient />
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -106,8 +106,8 @@ describe("Usage settings page", () => {
|
||||
it("renders the plan, usage rows, and overage state", () => {
|
||||
render(<PageClient />);
|
||||
|
||||
// The page title and usage card share this label.
|
||||
expect(screen.getAllByText("Usage").length).toBeGreaterThan(0);
|
||||
// The page title
|
||||
expect(screen.getAllByText("Billing & Usage").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("Free").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("Owner").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("Dashboard admins").length).toBeGreaterThan(0);
|
||||
|
||||
@ -362,7 +362,7 @@ export default function PageClient() {
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
title="Usage"
|
||||
title="Billing & Usage"
|
||||
description={`Usage for ${planUsage.ownerTeamDisplayName} across all projects owned by this team.`}
|
||||
width={1050}
|
||||
>
|
||||
|
||||
@ -139,7 +139,7 @@ const projectSettingsItem: AppSection = {
|
||||
match: (fullUrl: URL) => /^\/projects\/[^\/]+\/project-settings\/?$/.test(fullUrl.pathname),
|
||||
},
|
||||
{
|
||||
name: "Usage",
|
||||
name: "Billing & Usage",
|
||||
href: "/project-settings/usage",
|
||||
match: (fullUrl: URL) => /^\/projects\/[^\/]+\/project-settings\/usage(\/.*)?$/.test(fullUrl.pathname),
|
||||
},
|
||||
|
||||
@ -7,7 +7,7 @@ import { ALL_APPS_FRONTEND, getAppPath, getItemPath, hasNavigationItems, type Na
|
||||
import { getUninstalledAppIds } from "@/lib/apps-utils";
|
||||
import { classifyClickHouseSqlVsPrompt } from "@/lib/classify-query";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChartBarIcon, CheckIcon, CubeIcon, DownloadSimpleIcon, EnvelopeSimpleIcon, GearIcon, GlobeIcon, HardDriveIcon, InfoIcon, KeyIcon, LayoutIcon, LightningIcon, Palette, PlayIcon, PlusIcon, ShieldCheckIcon, SparkleIcon, UsersIcon } from "@phosphor-icons/react";
|
||||
import { ChartBarIcon, CheckIcon, CubeIcon, DownloadSimpleIcon, GearIcon, GlobeIcon, HardDriveIcon, InfoIcon, KeyIcon, LayoutIcon, LightningIcon, Palette, PlayIcon, PlusIcon, ShieldCheckIcon, SparkleIcon, UsersIcon } from "@phosphor-icons/react";
|
||||
import { ALL_APPS, ALL_APP_TAGS, getParentAppId, type AppId } from "@hexclave/shared/dist/apps/apps-config";
|
||||
import { runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises";
|
||||
import Image from "next/image";
|
||||
@ -308,15 +308,6 @@ const PROJECT_SHORTCUTS: ProjectShortcutDefinition[] = [
|
||||
keywords: ["email themes", "themes", "branding", "style", "templates"],
|
||||
requiredApps: ["emails"],
|
||||
},
|
||||
{
|
||||
id: "emails/outbox",
|
||||
icon: EnvelopeSimpleIcon,
|
||||
label: "Email Outbox",
|
||||
description: "Emails",
|
||||
href: "/email-outbox",
|
||||
keywords: ["email outbox", "outbox", "delivery", "queue", "scheduled emails"],
|
||||
requiredApps: ["emails"],
|
||||
},
|
||||
{
|
||||
id: "data-vault/stores",
|
||||
icon: HardDriveIcon,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/dev-launchpad",
|
||||
"version": "1.0.28",
|
||||
"version": "1.0.30",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/e2e-tests",
|
||||
"version": "1.0.28",
|
||||
"version": "1.0.30",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@ -0,0 +1,407 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { Client } from "pg";
|
||||
import { describe } from "vitest";
|
||||
import { ITEM_IDS } from "@hexclave/shared/dist/plans";
|
||||
import { getEnvVariable } from "@hexclave/shared/dist/utils/env";
|
||||
import { HexclaveAssertionError, throwErr } from "@hexclave/shared/dist/utils/errors";
|
||||
import { wait } from "@hexclave/shared/dist/utils/promises";
|
||||
import { planUsageResponseSchema, type PlanUsageResponse } from "@hexclave/shared/dist/interface/plan-usage";
|
||||
import { it } from "../../../../../helpers";
|
||||
import { Auth, InternalProjectKeys, Project, backendContext, niceBackendFetch } from "../../../../backend-helpers";
|
||||
|
||||
type ProjectUsageContext = {
|
||||
projectId: string,
|
||||
tenancyId: string,
|
||||
};
|
||||
|
||||
type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };
|
||||
|
||||
function getInternalDatabaseConnectionString(): string {
|
||||
const connectionString = getEnvVariable(
|
||||
"HEXCLAVE_DATABASE_CONNECTION_STRING",
|
||||
getEnvVariable("STACK_DATABASE_CONNECTION_STRING", ""),
|
||||
);
|
||||
if (connectionString === "") {
|
||||
throw new HexclaveAssertionError("Plan usage E2E tests require a configured internal database connection string");
|
||||
}
|
||||
return connectionString;
|
||||
}
|
||||
|
||||
async function withInternalDatabase<T>(fn: (client: Client) => Promise<T>): Promise<T> {
|
||||
const client = new Client({
|
||||
connectionString: getInternalDatabaseConnectionString(),
|
||||
connectionTimeoutMillis: 10_000,
|
||||
query_timeout: 30_000,
|
||||
});
|
||||
await client.connect();
|
||||
try {
|
||||
return await fn(client);
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
async function getMainTenancyId(client: Client, projectId: string): Promise<string> {
|
||||
const tenancies = await client.query<{ id: string }>(
|
||||
`SELECT "id" FROM "Tenancy" WHERE "projectId" = $1 AND "branchId" = 'main' LIMIT 1`,
|
||||
[projectId],
|
||||
);
|
||||
return tenancies.rows[0]?.id ?? throwErr(`Could not find main tenancy for project ${projectId}`);
|
||||
}
|
||||
|
||||
async function getProjectUsageContext(client: Client, projectId: string): Promise<ProjectUsageContext> {
|
||||
return {
|
||||
projectId,
|
||||
tenancyId: await getMainTenancyId(client, projectId),
|
||||
};
|
||||
}
|
||||
|
||||
async function clearSeededUsageRows(client: Client, tenancies: readonly ProjectUsageContext[]): Promise<void> {
|
||||
const tenancyIds = tenancies.map((tenancy) => tenancy.tenancyId);
|
||||
await client.query(`DELETE FROM "SessionReplay" WHERE "tenancyId" = ANY($1::uuid[])`, [tenancyIds]);
|
||||
await client.query(`DELETE FROM "EmailOutbox" WHERE "tenancyId" = ANY($1::uuid[])`, [tenancyIds]);
|
||||
await client.query(`DELETE FROM "ProjectUser" WHERE "tenancyId" = ANY($1::uuid[])`, [tenancyIds]);
|
||||
}
|
||||
|
||||
function normalizeSubscriptionPeriodInJson(value: unknown, ownerTeamId: string, period: {
|
||||
start: Date,
|
||||
end: Date,
|
||||
}): JsonValue {
|
||||
if (value === null || typeof value === "boolean" || typeof value === "number" || typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => normalizeSubscriptionPeriodInJson(item, ownerTeamId, period));
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
const normalizedObject: { [key: string]: JsonValue } = {};
|
||||
for (const [key, entryValue] of Object.entries(value)) {
|
||||
normalizedObject[key] = normalizeSubscriptionPeriodInJson(entryValue, ownerTeamId, period);
|
||||
}
|
||||
if (
|
||||
normalizedObject.customerId === ownerTeamId
|
||||
&& typeof normalizedObject.currentPeriodStartMillis === "number"
|
||||
&& typeof normalizedObject.currentPeriodEndMillis === "number"
|
||||
) {
|
||||
return {
|
||||
...normalizedObject,
|
||||
currentPeriodStartMillis: period.start.getTime(),
|
||||
currentPeriodEndMillis: period.end.getTime(),
|
||||
};
|
||||
}
|
||||
return normalizedObject;
|
||||
}
|
||||
throw new HexclaveAssertionError("Unexpected non-JSON value in payment storage", { value });
|
||||
}
|
||||
|
||||
async function normalizeBillingTeamSubscriptionMapPeriod(client: Client, ownerTeamId: string, period: {
|
||||
start: Date,
|
||||
end: Date,
|
||||
}): Promise<void> {
|
||||
// The E2E seed data can create zero-length payment periods in the Bulldozer LFold output.
|
||||
// Plan usage reads that output directly, so normalize only this fresh test team's emitted
|
||||
// subscription-map rows to make the metered usage period deterministic.
|
||||
const rows = await client.query<{ id: string, value: unknown }>(
|
||||
`
|
||||
SELECT "id", "value"
|
||||
FROM "BulldozerStorageEngine"
|
||||
WHERE "keyPath"[1] = to_jsonb('table'::text)
|
||||
AND "keyPath"[2] = to_jsonb('external:payments-subscription-map-by-customer'::text)
|
||||
AND "keyPath"::text LIKE $1
|
||||
AND "value" <> 'null'::jsonb
|
||||
`,
|
||||
[`%${ownerTeamId}%`],
|
||||
);
|
||||
if (rows.rows.length === 0) {
|
||||
throw new HexclaveAssertionError("Expected payment subscription-map rows for billing team", { ownerTeamId });
|
||||
}
|
||||
for (const row of rows.rows) {
|
||||
await client.query(
|
||||
`UPDATE "BulldozerStorageEngine" SET "value" = $2::jsonb WHERE "id" = $1::uuid`,
|
||||
[row.id, JSON.stringify(normalizeSubscriptionPeriodInJson(row.value, ownerTeamId, period))],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function insertProjectUsers(client: Client, context: ProjectUsageContext, options: {
|
||||
nonAnonymousCount: number,
|
||||
anonymousCount: number,
|
||||
}): Promise<string[]> {
|
||||
const nonAnonymousUsers = await client.query<{ projectUserId: string }>(
|
||||
`
|
||||
INSERT INTO "ProjectUser"
|
||||
("tenancyId", "projectUserId", "mirroredProjectId", "mirroredBranchId",
|
||||
"displayName", "createdAt", "updatedAt", "isAnonymous",
|
||||
"signedUpAt", "signUpRiskScoreBot", "signUpRiskScoreFreeTrialAbuse")
|
||||
SELECT
|
||||
$1::uuid,
|
||||
gen_random_uuid(),
|
||||
$2,
|
||||
'main',
|
||||
'Plan Usage User ' || gs,
|
||||
now(),
|
||||
now(),
|
||||
false,
|
||||
now(),
|
||||
0,
|
||||
0
|
||||
FROM generate_series(1, $3::int) AS gs
|
||||
RETURNING "projectUserId"
|
||||
`,
|
||||
[context.tenancyId, context.projectId, options.nonAnonymousCount],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO "ProjectUser"
|
||||
("tenancyId", "projectUserId", "mirroredProjectId", "mirroredBranchId",
|
||||
"displayName", "createdAt", "updatedAt", "isAnonymous",
|
||||
"signedUpAt", "signUpRiskScoreBot", "signUpRiskScoreFreeTrialAbuse")
|
||||
SELECT
|
||||
$1::uuid,
|
||||
gen_random_uuid(),
|
||||
$2,
|
||||
'main',
|
||||
'Plan Usage Anonymous User ' || gs,
|
||||
now(),
|
||||
now(),
|
||||
true,
|
||||
now(),
|
||||
0,
|
||||
0
|
||||
FROM generate_series(1, $3::int) AS gs
|
||||
`,
|
||||
[context.tenancyId, context.projectId, options.anonymousCount],
|
||||
);
|
||||
|
||||
return nonAnonymousUsers.rows.map((row) => row.projectUserId);
|
||||
}
|
||||
|
||||
async function insertEmailOutboxRow(client: Client, tenancyId: string, startedSendingAt: Date | null): Promise<void> {
|
||||
const renderedAt = new Date();
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO "EmailOutbox"
|
||||
("tenancyId", "id", "createdAt", "updatedAt", "tsxSource", "isHighPriority", "to", "extraRenderVariables",
|
||||
"shouldSkipDeliverabilityCheck", "createdWith", "renderedByWorkerId", "startedRenderingAt",
|
||||
"finishedRenderingAt", "renderedHtml", "renderedSubject", "renderedIsTransactional",
|
||||
"scheduledAt", "isQueued", "startedSendingAt", "finishedSendingAt", "canHaveDeliveryInfo")
|
||||
VALUES
|
||||
($1::uuid, gen_random_uuid(), $4, $4, '', false, $2::jsonb, '{}'::jsonb,
|
||||
true, 'PROGRAMMATIC_CALL', $3::uuid, $4, $4, '<p>usage test</p>',
|
||||
'Plan usage test email', true, $4, true, $5, $5, $6)
|
||||
`,
|
||||
[
|
||||
tenancyId,
|
||||
JSON.stringify({ type: "custom-emails", emails: ["usage-test@example.com"] }),
|
||||
randomUUID(),
|
||||
renderedAt,
|
||||
startedSendingAt,
|
||||
startedSendingAt == null ? null : false,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
async function insertSessionReplayRow(client: Client, context: ProjectUsageContext, projectUserId: string, startedAt: Date): Promise<void> {
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO "SessionReplay"
|
||||
("tenancyId", "id", "projectUserId", "refreshTokenId", "startedAt", "lastEventAt", "createdAt", "updatedAt")
|
||||
VALUES
|
||||
($1::uuid, gen_random_uuid(), $2::uuid, $3::uuid, $4, $4, $4, $4)
|
||||
`,
|
||||
[context.tenancyId, projectUserId, randomUUID(), startedAt],
|
||||
);
|
||||
}
|
||||
|
||||
async function getPlanUsage(): Promise<PlanUsageResponse> {
|
||||
const response = await niceBackendFetch("/api/latest/internal/plan-usage", {
|
||||
accessType: "admin",
|
||||
});
|
||||
if (response.status !== 200) {
|
||||
throw new HexclaveAssertionError("Expected plan usage request to succeed", { response });
|
||||
}
|
||||
return await planUsageResponseSchema.validate(response.body);
|
||||
}
|
||||
|
||||
async function purchaseTeamPlanForBillingTeam(ownerTeamId: string): Promise<void> {
|
||||
const createUrlResponse = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", {
|
||||
method: "POST",
|
||||
accessType: "client",
|
||||
body: {
|
||||
customer_type: "team",
|
||||
customer_id: ownerTeamId,
|
||||
product_id: "team",
|
||||
},
|
||||
});
|
||||
if (createUrlResponse.status !== 200 || typeof createUrlResponse.body?.url !== "string") {
|
||||
throw new HexclaveAssertionError("Expected team plan purchase URL creation to succeed", { createUrlResponse });
|
||||
}
|
||||
|
||||
const fullCode = createUrlResponse.body.url.match(/\/purchase\/([a-z0-9_-]+)/)?.[1]
|
||||
?? throwErr("Could not parse purchase code from team plan purchase URL", { createUrlResponse });
|
||||
const purchaseResponse = await niceBackendFetch("/api/latest/internal/payments/test-mode-purchase-session", {
|
||||
method: "POST",
|
||||
accessType: "admin",
|
||||
body: {
|
||||
full_code: fullCode,
|
||||
price_id: "monthly",
|
||||
quantity: 1,
|
||||
},
|
||||
});
|
||||
if (purchaseResponse.status !== 200) {
|
||||
throw new HexclaveAssertionError("Expected test-mode team plan purchase to succeed", { purchaseResponse });
|
||||
}
|
||||
}
|
||||
|
||||
function getUsedUsageValue(usage: PlanUsageResponse, itemId: string): number {
|
||||
const row = usage.rows.find((candidate) => candidate.item_id === itemId) ?? throwErr(`Missing usage row for ${itemId}`);
|
||||
return row.used ?? throwErr(`Expected usage row ${itemId} to have a used value`);
|
||||
}
|
||||
|
||||
function getCalendarMonthBounds(now: Date): { start: Date, end: Date } {
|
||||
return {
|
||||
start: new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)),
|
||||
end: new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1)),
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForPlanUsageValues(expected: {
|
||||
authUsers: number,
|
||||
emails: number,
|
||||
sessionReplays: number,
|
||||
analyticsEvents: number,
|
||||
}): Promise<PlanUsageResponse> {
|
||||
const startedAt = performance.now();
|
||||
let latestUsage: PlanUsageResponse | undefined;
|
||||
while (performance.now() - startedAt < 15_000) {
|
||||
latestUsage = await getPlanUsage();
|
||||
if (
|
||||
getUsedUsageValue(latestUsage, ITEM_IDS.authUsers) === expected.authUsers
|
||||
&& getUsedUsageValue(latestUsage, ITEM_IDS.emailsPerMonth) === expected.emails
|
||||
&& getUsedUsageValue(latestUsage, ITEM_IDS.sessionReplays) === expected.sessionReplays
|
||||
&& getUsedUsageValue(latestUsage, ITEM_IDS.analyticsEvents) === expected.analyticsEvents
|
||||
) {
|
||||
return latestUsage;
|
||||
}
|
||||
await wait(250);
|
||||
}
|
||||
throw new HexclaveAssertionError("Timed out waiting for seeded plan usage to be visible", {
|
||||
latestUsage,
|
||||
expected,
|
||||
});
|
||||
}
|
||||
|
||||
describe("internal plan usage", () => {
|
||||
it("returns zero usage for a fresh owned project with no seeded usage rows", async ({ expect }) => {
|
||||
const { projectId } = await Project.createAndSwitch({
|
||||
display_name: "Plan Usage Empty Project",
|
||||
});
|
||||
|
||||
await withInternalDatabase(async (client) => {
|
||||
const context = await getProjectUsageContext(client, projectId);
|
||||
await clearSeededUsageRows(client, [context]);
|
||||
});
|
||||
|
||||
const usage = await getPlanUsage();
|
||||
|
||||
expect(usage.owner_team_id).toBeTruthy();
|
||||
expect(usage.plan_id).toBe("free");
|
||||
expect(getUsedUsageValue(usage, ITEM_IDS.authUsers)).toBe(0);
|
||||
expect(getUsedUsageValue(usage, ITEM_IDS.emailsPerMonth)).toBe(0);
|
||||
expect(getUsedUsageValue(usage, ITEM_IDS.sessionReplays)).toBe(0);
|
||||
expect(getUsedUsageValue(usage, ITEM_IDS.analyticsEvents)).toBe(0);
|
||||
});
|
||||
|
||||
it("rolls up metered usage across all projects owned by the billing team", async ({ expect }) => {
|
||||
backendContext.set({ projectKeys: InternalProjectKeys, userAuth: null });
|
||||
await Auth.fastSignUp();
|
||||
const internalUserAuth = backendContext.value.userAuth ?? throwErr("Expected internal user auth after sign-up");
|
||||
|
||||
const primaryProject = await Project.createAndSwitch({
|
||||
display_name: "Plan Usage Primary Project",
|
||||
}, true);
|
||||
const primaryProjectKeys = backendContext.value.projectKeys;
|
||||
const ownerTeamId = primaryProject.createProjectResponse.body.owner_team_id;
|
||||
if (typeof ownerTeamId !== "string") {
|
||||
throw new HexclaveAssertionError("Expected created project to include an owner team ID", { primaryProject });
|
||||
}
|
||||
|
||||
backendContext.set({ projectKeys: InternalProjectKeys, userAuth: internalUserAuth });
|
||||
await purchaseTeamPlanForBillingTeam(ownerTeamId);
|
||||
const secondaryProject = await Project.create({
|
||||
display_name: "Plan Usage Secondary Project",
|
||||
owner_team_id: ownerTeamId,
|
||||
});
|
||||
|
||||
backendContext.set({ projectKeys: InternalProjectKeys, userAuth: null });
|
||||
const unrelatedProject = await Project.createAndSwitch({
|
||||
display_name: "Plan Usage Unrelated Project",
|
||||
});
|
||||
|
||||
backendContext.set({ projectKeys: primaryProjectKeys, userAuth: null });
|
||||
|
||||
const { start, end } = getCalendarMonthBounds(new Date());
|
||||
const outsideBefore = new Date(start.getTime() - 2 * 24 * 60 * 60 * 1000);
|
||||
const insidePrimary = new Date(start.getTime() + 2 * 24 * 60 * 60 * 1000);
|
||||
const insideSecondaryA = new Date(start.getTime() + 3 * 24 * 60 * 60 * 1000);
|
||||
const insideSecondaryB = new Date(start.getTime() + 4 * 24 * 60 * 60 * 1000);
|
||||
const outsideAfter = new Date(end.getTime() + 2 * 24 * 60 * 60 * 1000);
|
||||
|
||||
await withInternalDatabase(async (client) => {
|
||||
const primary = await getProjectUsageContext(client, primaryProject.projectId);
|
||||
const secondary = await getProjectUsageContext(client, secondaryProject.projectId);
|
||||
const unrelated = await getProjectUsageContext(client, unrelatedProject.projectId);
|
||||
await clearSeededUsageRows(client, [primary, secondary, unrelated]);
|
||||
await normalizeBillingTeamSubscriptionMapPeriod(client, ownerTeamId, { start, end });
|
||||
|
||||
const primaryUserIds = await insertProjectUsers(client, primary, {
|
||||
nonAnonymousCount: 2,
|
||||
anonymousCount: 1,
|
||||
});
|
||||
const secondaryUserIds = await insertProjectUsers(client, secondary, {
|
||||
nonAnonymousCount: 1,
|
||||
anonymousCount: 0,
|
||||
});
|
||||
const unrelatedUserIds = await insertProjectUsers(client, unrelated, {
|
||||
nonAnonymousCount: 2,
|
||||
anonymousCount: 0,
|
||||
});
|
||||
const firstPrimaryUserId = primaryUserIds[0] ?? throwErr("Expected seeded primary project user");
|
||||
const secondPrimaryUserId = primaryUserIds[1] ?? throwErr("Expected second seeded primary project user");
|
||||
const firstSecondaryUserId = secondaryUserIds[0] ?? throwErr("Expected seeded secondary project user");
|
||||
const firstUnrelatedUserId = unrelatedUserIds[0] ?? throwErr("Expected seeded unrelated project user");
|
||||
|
||||
await insertEmailOutboxRow(client, primary.tenancyId, insidePrimary);
|
||||
await insertEmailOutboxRow(client, primary.tenancyId, insideSecondaryA);
|
||||
await insertEmailOutboxRow(client, secondary.tenancyId, insideSecondaryB);
|
||||
await insertEmailOutboxRow(client, primary.tenancyId, outsideBefore);
|
||||
await insertEmailOutboxRow(client, secondary.tenancyId, outsideAfter);
|
||||
await insertEmailOutboxRow(client, primary.tenancyId, null);
|
||||
await insertEmailOutboxRow(client, unrelated.tenancyId, insidePrimary);
|
||||
|
||||
await insertSessionReplayRow(client, primary, firstPrimaryUserId, insidePrimary);
|
||||
await insertSessionReplayRow(client, primary, secondPrimaryUserId, outsideBefore);
|
||||
await insertSessionReplayRow(client, secondary, firstSecondaryUserId, insideSecondaryA);
|
||||
await insertSessionReplayRow(client, secondary, firstSecondaryUserId, insideSecondaryB);
|
||||
await insertSessionReplayRow(client, secondary, firstSecondaryUserId, outsideAfter);
|
||||
await insertSessionReplayRow(client, unrelated, firstUnrelatedUserId, insidePrimary);
|
||||
});
|
||||
|
||||
const usage = await waitForPlanUsageValues({
|
||||
authUsers: 3,
|
||||
emails: 3,
|
||||
sessionReplays: 3,
|
||||
analyticsEvents: 0,
|
||||
});
|
||||
|
||||
expect(usage.owner_team_id).toBe(ownerTeamId);
|
||||
expect(usage.plan_id).toBe("team");
|
||||
expect(usage.period_start_millis).toBe(start.getTime());
|
||||
expect(usage.period_end_millis).toBe(end.getTime());
|
||||
expect(getUsedUsageValue(usage, ITEM_IDS.authUsers)).toBe(3);
|
||||
expect(getUsedUsageValue(usage, ITEM_IDS.emailsPerMonth)).toBe(3);
|
||||
expect(getUsedUsageValue(usage, ITEM_IDS.sessionReplays)).toBe(3);
|
||||
expect(getUsedUsageValue(usage, ITEM_IDS.analyticsEvents)).toBe(0);
|
||||
});
|
||||
});
|
||||
@ -1,9 +1,42 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { throwErr } from "@hexclave/shared/dist/utils/errors";
|
||||
import { wait } from "@hexclave/shared/dist/utils/promises";
|
||||
import { it } from "../../../../helpers";
|
||||
import { Auth, bumpEmailAddress, niceBackendFetch, Payments, Project, Team } from "../../../backend-helpers";
|
||||
import { getOutboxEmails } from "./emails/email-helpers";
|
||||
|
||||
// Stripe webhook events are now deduplicated globally by their `event.id` (see
|
||||
// the StripeWebhookEvent table). The dev DB is NOT reset between test runs, so
|
||||
// every claimed event needs a per-run unique id, otherwise a second run would
|
||||
// hit the dedupe path and skip processing.
|
||||
function uniqueEventId(prefix: string) {
|
||||
return `evt_${prefix}_${randomUUID()}`;
|
||||
}
|
||||
|
||||
// Webhook processing now happens in the background after a fast 200 ack, so DB
|
||||
// state is eventually-consistent from the test's perspective. Poll instead of
|
||||
// reading immediately after the webhook returns.
|
||||
async function waitForItemQuantity(
|
||||
args: { customerType: "user" | "team", customerId: string, itemId: string, expected: number },
|
||||
) {
|
||||
let last: number | undefined;
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const res = await niceBackendFetch(
|
||||
`/api/latest/payments/items/${args.customerType}/${args.customerId}/${args.itemId}`,
|
||||
{ accessType: "client" },
|
||||
);
|
||||
if (res.status !== 200) {
|
||||
throw new Error(`Unexpected ${res.status} reading item ${args.itemId}`);
|
||||
}
|
||||
last = res.body.quantity;
|
||||
if (last === args.expected) {
|
||||
return;
|
||||
}
|
||||
await wait(500);
|
||||
}
|
||||
throw new Error(`Item ${args.itemId} quantity never reached ${args.expected} (last seen: ${last})`);
|
||||
}
|
||||
|
||||
async function waitForOutboxEmail(subject: string) {
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const emails = await getOutboxEmails({ subject });
|
||||
@ -26,14 +59,20 @@ async function waitForNoOutboxEmail(subject: string) {
|
||||
}
|
||||
|
||||
|
||||
it("rejects signed mock_event.succeeded webhook", async ({ expect }) => {
|
||||
it("acks unknown signed webhook types (errors handled in background)", async ({ expect }) => {
|
||||
// We now persist + ack the event synchronously and process it in the
|
||||
// background, so an unknown type no longer surfaces a 500 to Stripe. The
|
||||
// "Unknown stripe webhook type" error is captured async and the event row is
|
||||
// marked FAILED (covered deterministically in stripe-webhook-events.test.ts).
|
||||
const payload = {
|
||||
id: "evt_test_1",
|
||||
id: uniqueEventId("mock_event_succeeded"),
|
||||
type: "mock_event.succeeded",
|
||||
account: "acct_test123",
|
||||
data: { object: { customer: "cus_test123", metadata: {} } },
|
||||
};
|
||||
await expect(Payments.sendStripeWebhook(payload)).rejects.toThrow(/Unknown stripe webhook type received/);
|
||||
const res = await Payments.sendStripeWebhook(payload);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ received: true });
|
||||
});
|
||||
|
||||
it("returns 400 on invalid signature", async ({ expect }) => {
|
||||
@ -53,15 +92,17 @@ it("returns 400 on invalid signature", async ({ expect }) => {
|
||||
`);
|
||||
});
|
||||
|
||||
it("returns 500 on unknown webhook type", async ({ expect }) => {
|
||||
it("acks unknown webhook types with 200 (errors handled in background)", async ({ expect }) => {
|
||||
const payload = {
|
||||
id: "evt_test_unknown",
|
||||
id: uniqueEventId("unknown_event"),
|
||||
type: "unknown.event",
|
||||
account: "acct_test123",
|
||||
data: { object: {} },
|
||||
};
|
||||
|
||||
await expect(Payments.sendStripeWebhook(payload)).rejects.toThrow(/Unknown stripe webhook type received/);
|
||||
const res = await Payments.sendStripeWebhook(payload);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ received: true });
|
||||
});
|
||||
|
||||
it("returns 400 when signature header is missing (schema validation)", async ({ expect }) => {
|
||||
@ -86,7 +127,7 @@ it("accepts chargeback webhooks", async ({ expect }) => {
|
||||
const accountId: string = accountInfo.body.account_id;
|
||||
|
||||
const payload = {
|
||||
id: "evt_chargeback_test",
|
||||
id: uniqueEventId("chargeback"),
|
||||
type: "charge.dispute.created",
|
||||
account: accountId,
|
||||
data: {
|
||||
@ -172,7 +213,7 @@ it("deduplicates one-time purchase on payment_intent.succeeded retry", async ({
|
||||
const fullCode = purchaseUrl.split("/purchase/")[1];
|
||||
const stackTestTenancyId = fullCode.split("_")[0];
|
||||
const payloadObj = {
|
||||
id: "evt_retry_test",
|
||||
id: uniqueEventId("retry"),
|
||||
type: "payment_intent.succeeded",
|
||||
account: accountId,
|
||||
data: {
|
||||
@ -198,18 +239,18 @@ it("deduplicates one-time purchase on payment_intent.succeeded retry", async ({
|
||||
};
|
||||
const res = await Payments.sendStripeWebhook(payloadObj);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ received: true });
|
||||
expect(res.body.received).toBe(true);
|
||||
|
||||
// First grant must land before we redeliver, so the duplicate deterministically
|
||||
// hits the event-dedupe path (PROCESSED) rather than racing in-flight work.
|
||||
await waitForItemQuantity({ customerType: "user", customerId: userId, itemId, expected: 1 });
|
||||
|
||||
const res2 = await Payments.sendStripeWebhook(payloadObj);
|
||||
expect(res2.status).toBe(200);
|
||||
expect(res2.body).toEqual({ received: true });
|
||||
expect(res2.body).toEqual({ received: true, deduplicated: true });
|
||||
|
||||
// After duplicate deliveries, quantity should reflect a single OneTimePurchase grant
|
||||
const getAfter = await niceBackendFetch(`/api/latest/payments/items/user/${userId}/${itemId}`, {
|
||||
accessType: "client",
|
||||
});
|
||||
expect(getAfter.status).toBe(200);
|
||||
expect(getAfter.body.quantity).toBe(1);
|
||||
// After the deduplicated redelivery, quantity stays at a single grant.
|
||||
await waitForItemQuantity({ customerType: "user", customerId: userId, itemId, expected: 1 });
|
||||
});
|
||||
|
||||
it("sends a payment receipt email for one-time purchases", async ({ expect }) => {
|
||||
@ -268,7 +309,7 @@ it("sends a payment receipt email for one-time purchases", async ({ expect }) =>
|
||||
const receiptLink = "https://example.com/receipt/pi_test_receipt_1";
|
||||
const paymentIntentId = "pi_test_receipt_1";
|
||||
const payloadObj = {
|
||||
id: "evt_receipt_test_1",
|
||||
id: uniqueEventId("receipt"),
|
||||
type: "payment_intent.succeeded",
|
||||
account: accountId,
|
||||
data: {
|
||||
@ -313,6 +354,117 @@ it("sends a payment receipt email for one-time purchases", async ({ expect }) =>
|
||||
`);
|
||||
});
|
||||
|
||||
it("sends exactly one receipt when Stripe redelivers the same event", async ({ expect }) => {
|
||||
// Regression test for the duplicate-receipt bug: Stripe delivers at-least-once,
|
||||
// and slow synchronous processing used to time out and trigger redeliveries,
|
||||
// each re-sending the receipt fan-out. The StripeWebhookEvent dedupe must keep
|
||||
// the fan-out to exactly once per event id.
|
||||
const projectDisplayName = `Receipt Idempotency ${randomUUID()}`;
|
||||
await Project.createAndSwitch({ display_name: projectDisplayName });
|
||||
await Payments.setup();
|
||||
|
||||
const itemId = "idem-receipt-credits";
|
||||
const productId = "idem-receipt-ot";
|
||||
const product = {
|
||||
displayName: "Idem Receipt Pack",
|
||||
customerType: "user",
|
||||
serverOnly: false,
|
||||
stackable: true,
|
||||
prices: { one: { USD: "500" } },
|
||||
includedItems: { [itemId]: { quantity: 1 } },
|
||||
};
|
||||
|
||||
await Project.updateConfig({
|
||||
payments: {
|
||||
items: {
|
||||
[itemId]: { displayName: "Credits", customerType: "user" },
|
||||
},
|
||||
products: {
|
||||
[productId]: product,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const mailbox = await bumpEmailAddress();
|
||||
const { userId } = await Auth.fastSignUp({
|
||||
primary_email: mailbox.emailAddress,
|
||||
primary_email_verified: true,
|
||||
});
|
||||
|
||||
const accountInfo = await niceBackendFetch("/api/latest/internal/payments/stripe/account-info", {
|
||||
accessType: "admin",
|
||||
});
|
||||
expect(accountInfo.status).toBe(200);
|
||||
const accountId: string = accountInfo.body.account_id;
|
||||
|
||||
const createUrlResponse = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", {
|
||||
method: "POST",
|
||||
accessType: "client",
|
||||
body: {
|
||||
customer_type: "user",
|
||||
customer_id: userId,
|
||||
product_id: productId,
|
||||
},
|
||||
});
|
||||
expect(createUrlResponse.status).toBe(200);
|
||||
const purchaseUrl = (createUrlResponse.body as { url: string }).url;
|
||||
const fullCode = purchaseUrl.split("/purchase/")[1];
|
||||
const stackTestTenancyId = fullCode.split("_")[0];
|
||||
|
||||
const receiptLink = "https://example.com/receipt/pi_idem_receipt";
|
||||
const eventId = uniqueEventId("idem_receipt");
|
||||
const payloadObj = {
|
||||
id: eventId,
|
||||
type: "payment_intent.succeeded",
|
||||
account: accountId,
|
||||
data: {
|
||||
object: {
|
||||
id: "pi_idem_receipt",
|
||||
customer: userId,
|
||||
amount_received: 500,
|
||||
currency: "usd",
|
||||
charges: { data: [{ receipt_url: receiptLink }] },
|
||||
stack_stripe_mock_data: {
|
||||
"accounts.retrieve": { metadata: { tenancyId: stackTestTenancyId } },
|
||||
"customers.retrieve": { metadata: { customerId: userId, customerType: "USER" } },
|
||||
"subscriptions.list": { data: [] },
|
||||
},
|
||||
metadata: {
|
||||
productId,
|
||||
product: JSON.stringify(product),
|
||||
customerId: userId,
|
||||
customerType: "user",
|
||||
purchaseQuantity: "1",
|
||||
purchaseKind: "ONE_TIME",
|
||||
priceId: "one",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const first = await Payments.sendStripeWebhook(payloadObj);
|
||||
expect(first.status).toBe(200);
|
||||
expect(first.body).toEqual({ received: true });
|
||||
|
||||
// Wait for the receipt to land, which proves the first event finished
|
||||
// processing (and is now PROCESSED), so the redeliveries deterministically
|
||||
// take the dedupe path.
|
||||
const subject = `Your receipt from ${projectDisplayName}`;
|
||||
await waitForOutboxEmail(subject);
|
||||
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const redelivery = await Payments.sendStripeWebhook(payloadObj);
|
||||
expect(redelivery.status).toBe(200);
|
||||
expect(redelivery.body).toEqual({ received: true, deduplicated: true });
|
||||
}
|
||||
|
||||
// Give any (incorrectly) re-triggered fan-out a chance to show up, then assert
|
||||
// there is still exactly one receipt email for this project.
|
||||
await wait(1500);
|
||||
const receipts = await getOutboxEmails({ subject });
|
||||
expect(receipts.length).toBe(1);
|
||||
});
|
||||
|
||||
it("sends a payment failed email for invoice.payment_failed", async ({ expect }) => {
|
||||
const projectDisplayName = "Payments Failed Email Test";
|
||||
await Project.createAndSwitch({ display_name: projectDisplayName });
|
||||
@ -365,7 +517,7 @@ it("sends a payment failed email for invoice.payment_failed", async ({ expect })
|
||||
const invoiceId = "in_test_failed_1";
|
||||
const invoiceUrl = "https://example.com/billing/update";
|
||||
const payloadObj = {
|
||||
id: "evt_invoice_failed_1",
|
||||
id: uniqueEventId("invoice_failed"),
|
||||
type: "invoice.payment_failed",
|
||||
account: accountId,
|
||||
data: {
|
||||
@ -458,7 +610,7 @@ it("skips payment failed email when invoice is not uncollectible", async ({ expe
|
||||
const invoiceId = "in_test_failed_open_1";
|
||||
const invoiceUrl = "https://example.com/billing/open";
|
||||
const payloadObj = {
|
||||
id: "evt_invoice_failed_open_1",
|
||||
id: uniqueEventId("invoice_failed_open"),
|
||||
type: "invoice.payment_failed",
|
||||
account: accountId,
|
||||
data: {
|
||||
@ -569,7 +721,7 @@ it("syncs subscriptions from webhook and is idempotent", async ({ expect }) => {
|
||||
};
|
||||
|
||||
const payloadObj = {
|
||||
id: "evt_sub_sync_1",
|
||||
id: uniqueEventId("sub_sync"),
|
||||
type: "invoice.paid",
|
||||
account: accountId,
|
||||
data: {
|
||||
@ -586,23 +738,16 @@ it("syncs subscriptions from webhook and is idempotent", async ({ expect }) => {
|
||||
|
||||
const res = await Payments.sendStripeWebhook(payloadObj);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ received: true });
|
||||
expect(res.body.received).toBe(true);
|
||||
|
||||
const getAfter1 = await niceBackendFetch(`/api/latest/payments/items/user/${userId}/${itemId}`, {
|
||||
accessType: "client",
|
||||
});
|
||||
expect(getAfter1.status).toBe(200);
|
||||
expect(getAfter1.body.quantity).toBe(1);
|
||||
await waitForItemQuantity({ customerType: "user", customerId: userId, itemId, expected: 1 });
|
||||
|
||||
// Redelivery of the same event id is deduplicated and leaves state untouched.
|
||||
const res2 = await Payments.sendStripeWebhook(payloadObj);
|
||||
expect(res2.status).toBe(200);
|
||||
expect(res2.body).toEqual({ received: true });
|
||||
expect(res2.body).toEqual({ received: true, deduplicated: true });
|
||||
|
||||
const getAfter2 = await niceBackendFetch(`/api/latest/payments/items/user/${userId}/${itemId}`, {
|
||||
accessType: "client",
|
||||
});
|
||||
expect(getAfter2.status).toBe(200);
|
||||
expect(getAfter2.body.quantity).toBe(1);
|
||||
await waitForItemQuantity({ customerType: "user", customerId: userId, itemId, expected: 1 });
|
||||
});
|
||||
|
||||
|
||||
@ -682,7 +827,7 @@ it("updates a user's subscriptions via webhook (add then remove)", async ({ expe
|
||||
};
|
||||
|
||||
const payloadAdd = {
|
||||
id: "evt_sub_add",
|
||||
id: uniqueEventId("sub_add"),
|
||||
type: "invoice.paid",
|
||||
account: accountId,
|
||||
data: {
|
||||
@ -701,11 +846,7 @@ it("updates a user's subscriptions via webhook (add then remove)", async ({ expe
|
||||
expect(resAdd.status).toBe(200);
|
||||
expect(resAdd.body).toEqual({ received: true });
|
||||
|
||||
const afterAdd = await niceBackendFetch(`/api/latest/payments/items/user/${userId}/${itemId}`, {
|
||||
accessType: "client",
|
||||
});
|
||||
expect(afterAdd.status).toBe(200);
|
||||
expect(afterAdd.body.quantity).toBe(1);
|
||||
await waitForItemQuantity({ customerType: "user", customerId: userId, itemId, expected: 1 });
|
||||
|
||||
const canceledSubscription = {
|
||||
...activeSubscription,
|
||||
@ -722,7 +863,7 @@ it("updates a user's subscriptions via webhook (add then remove)", async ({ expe
|
||||
};
|
||||
|
||||
const payloadRemove = {
|
||||
id: "evt_sub_remove",
|
||||
id: uniqueEventId("sub_remove"),
|
||||
type: "customer.subscription.updated",
|
||||
account: accountId,
|
||||
data: {
|
||||
@ -741,11 +882,7 @@ it("updates a user's subscriptions via webhook (add then remove)", async ({ expe
|
||||
expect(resRemove.status).toBe(200);
|
||||
expect(resRemove.body).toEqual({ received: true });
|
||||
|
||||
const afterRemove = await niceBackendFetch(`/api/latest/payments/items/user/${userId}/${itemId}`, {
|
||||
accessType: "client",
|
||||
});
|
||||
expect(afterRemove.status).toBe(200);
|
||||
expect(afterRemove.body.quantity).toBe(0);
|
||||
await waitForItemQuantity({ customerType: "user", customerId: userId, itemId, expected: 0 });
|
||||
});
|
||||
|
||||
|
||||
@ -796,7 +933,7 @@ it("does NOT auto-grant `free` when a non-internal tenancy's sub is canceled via
|
||||
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
const webhookResponse = await Payments.sendStripeWebhook({
|
||||
id: "evt_customer_cancel",
|
||||
id: uniqueEventId("customer_cancel"),
|
||||
type: "customer.subscription.deleted",
|
||||
account: accountId,
|
||||
data: {
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@hexclave/hosted-components",
|
||||
"private": true,
|
||||
"version": "1.0.28",
|
||||
"version": "1.0.30",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev --port ${NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX:-81}09",
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@hexclave/internal-tool",
|
||||
"private": true,
|
||||
"version": "1.0.28",
|
||||
"version": "1.0.30",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "node scripts/pre-dev.mjs && next dev --turbopack --port ${NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX:-81}41",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/mcp",
|
||||
"version": "1.0.28",
|
||||
"version": "1.0.30",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/mock-oauth-server",
|
||||
"version": "1.0.28",
|
||||
"version": "1.0.30",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"private": true,
|
||||
"main": "index.js",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/skills",
|
||||
"version": "1.0.28",
|
||||
"version": "1.0.30",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/docs-mintlify",
|
||||
"version": "1.0.28",
|
||||
"version": "1.0.30",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "mint dev --port ${NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX:-81}04 --no-open",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/docs",
|
||||
"version": "1.0.28",
|
||||
"version": "1.0.30",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/example-cjs-test",
|
||||
"version": "1.0.28",
|
||||
"version": "1.0.30",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/convex-example",
|
||||
"version": "1.0.28",
|
||||
"version": "1.0.30",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/example-demo-app",
|
||||
"version": "1.0.28",
|
||||
"version": "1.0.30",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"description": "",
|
||||
"private": true,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/docs-examples",
|
||||
"version": "1.0.28",
|
||||
"version": "1.0.30",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"description": "",
|
||||
"private": true,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/e-commerce-demo",
|
||||
"version": "1.0.28",
|
||||
"version": "1.0.30",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/js-example",
|
||||
"version": "1.0.28",
|
||||
"version": "1.0.30",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"private": true,
|
||||
"description": "",
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@hexclave/lovable-react-18-example",
|
||||
"private": true,
|
||||
"version": "1.0.28",
|
||||
"version": "1.0.30",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/example-middleware-demo",
|
||||
"version": "1.0.28",
|
||||
"version": "1.0.30",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "react-example",
|
||||
"private": true,
|
||||
"version": "1.0.28",
|
||||
"version": "1.0.30",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/example-supabase",
|
||||
"version": "1.0.28",
|
||||
"version": "1.0.30",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/example-tanstack-start-demo",
|
||||
"version": "1.0.28",
|
||||
"version": "1.0.30",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"description": "TanStack Start demo app for Hexclave",
|
||||
"private": true,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/cli",
|
||||
"version": "1.0.28",
|
||||
"version": "1.0.30",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"description": "The CLI for Hexclave. https://hexclave.com",
|
||||
"main": "dist/index.js",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/dashboard-ui-components",
|
||||
"version": "1.0.28",
|
||||
"version": "1.0.30",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"//": "THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY UNLESS YOU ALSO EDIT THE CORRESPONDING FILE IN packages/template (FOR package.json FILES, PLEASE EDIT package-template.json)",
|
||||
"name": "@hexclave/js",
|
||||
"version": "1.0.28",
|
||||
"version": "1.0.30",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"sideEffects": false,
|
||||
"main": "./dist/index.js",
|
||||
@ -76,7 +76,6 @@
|
||||
"@oslojs/otp": "^1.1.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"rrweb": "^1.1.3",
|
||||
"tsx": "^4.21.0",
|
||||
"yup": "^1.7.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
@ -97,6 +96,7 @@
|
||||
"rimraf": "^6.1.2",
|
||||
"tailwindcss": "^3.4.4",
|
||||
"tsdown": "^0.20.3",
|
||||
"tsx": "^4.21.0",
|
||||
"convex": "^1.27.0"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"//": "THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY UNLESS YOU ALSO EDIT THE CORRESPONDING FILE IN packages/template (FOR package.json FILES, PLEASE EDIT package-template.json)",
|
||||
"name": "@hexclave/next",
|
||||
"version": "1.0.28",
|
||||
"version": "1.0.30",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"sideEffects": false,
|
||||
"main": "./dist/index.js",
|
||||
@ -93,7 +93,6 @@
|
||||
"react-hook-form": "^7.70.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"rrweb": "^1.1.3",
|
||||
"tsx": "^4.21.0",
|
||||
"yup": "^1.7.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@ -132,6 +131,7 @@
|
||||
"rimraf": "^6.1.2",
|
||||
"tailwindcss": "^3.4.4",
|
||||
"tsdown": "^0.20.3",
|
||||
"tsx": "^4.21.0",
|
||||
"convex": "^1.27.0"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"//": "THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY UNLESS YOU ALSO EDIT THE CORRESPONDING FILE IN packages/template (FOR package.json FILES, PLEASE EDIT package-template.json)",
|
||||
"name": "@hexclave/react",
|
||||
"version": "1.0.28",
|
||||
"version": "1.0.30",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"sideEffects": false,
|
||||
"main": "./dist/index.js",
|
||||
@ -92,7 +92,6 @@
|
||||
"react-hook-form": "^7.70.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"rrweb": "^1.1.3",
|
||||
"tsx": "^4.21.0",
|
||||
"yup": "^1.7.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@ -129,6 +128,7 @@
|
||||
"rimraf": "^6.1.2",
|
||||
"tailwindcss": "^3.4.4",
|
||||
"tsdown": "^0.20.3",
|
||||
"tsx": "^4.21.0",
|
||||
"convex": "^1.27.0"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/sc",
|
||||
"version": "1.0.28",
|
||||
"version": "1.0.30",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"exports": {
|
||||
"./force-react-server": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/shared-backend",
|
||||
"version": "1.0.28",
|
||||
"version": "1.0.30",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/shared",
|
||||
"version": "1.0.28",
|
||||
"version": "1.0.30",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"scripts": {
|
||||
"build": "rimraf dist && tsdown",
|
||||
|
||||
@ -434,6 +434,71 @@ import.meta.vitest?.test("timeoutThrow", async ({ expect }) => {
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* Maps over `items` with `fn`, running at most `concurrency` invocations at a time.
|
||||
*
|
||||
* Unlike `Promise.all(items.map(fn))`, this bounds the number of in-flight
|
||||
* promises, which matters when `fn` hits a shared resource (e.g. a database) and
|
||||
* an unbounded fan-out could exhaust connections or overload a replica. Results
|
||||
* are returned in input order regardless of completion order, and the first
|
||||
* rejection aborts further scheduling — already in-flight workers still settle
|
||||
* but no new items are started.
|
||||
*/
|
||||
export async function mapWithConcurrency<T, R>(
|
||||
items: readonly T[],
|
||||
concurrency: number,
|
||||
fn: (item: T, index: number) => Promise<R>,
|
||||
): Promise<R[]> {
|
||||
if (!Number.isInteger(concurrency) || concurrency < 1) {
|
||||
throw new HexclaveAssertionError(`mapWithConcurrency requires a positive integer concurrency, got ${concurrency}`);
|
||||
}
|
||||
const results = new Array<R>(items.length);
|
||||
let nextIndex = 0;
|
||||
let aborted = false;
|
||||
const worker = async () => {
|
||||
while (!aborted) {
|
||||
// Claim an index synchronously before awaiting so workers never process the same item.
|
||||
const index = nextIndex++;
|
||||
if (index >= items.length) return;
|
||||
try {
|
||||
// Bounds-checked above; `?? throwErr(…)` is unsuitable because T may legitimately be null/undefined
|
||||
results[index] = await fn(items[index] as T, index);
|
||||
} catch (error) {
|
||||
aborted = true;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
const workerCount = Math.min(concurrency, items.length);
|
||||
await Promise.all(Array.from({ length: workerCount }, () => worker()));
|
||||
return results;
|
||||
}
|
||||
import.meta.vitest?.test("mapWithConcurrency", async ({ expect }) => {
|
||||
// Preserves input order regardless of completion order.
|
||||
const ordered = await mapWithConcurrency([30, 10, 20], 3, async (ms, index) => {
|
||||
await wait(ms);
|
||||
return `${index}:${ms}`;
|
||||
});
|
||||
expect(ordered).toEqual(["0:30", "1:10", "2:20"]);
|
||||
|
||||
// Never exceeds the configured concurrency.
|
||||
let inFlight = 0;
|
||||
let maxInFlight = 0;
|
||||
await mapWithConcurrency(Array.from({ length: 10 }, (_, i) => i), 3, async () => {
|
||||
inFlight++;
|
||||
maxInFlight = Math.max(maxInFlight, inFlight);
|
||||
await wait(5);
|
||||
inFlight--;
|
||||
});
|
||||
expect(maxInFlight).toBe(3);
|
||||
|
||||
// Empty input spawns no workers and returns an empty array.
|
||||
expect(await mapWithConcurrency([], 4, async () => 1)).toEqual([]);
|
||||
|
||||
// Invalid concurrency fails loudly.
|
||||
await expect(mapWithConcurrency([1], 0, async (x) => x)).rejects.toThrow("positive integer concurrency");
|
||||
});
|
||||
|
||||
export type RateLimitOptions = {
|
||||
/**
|
||||
* The number of requests to process in parallel. Currently only 1 is supported.
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"//": "THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY UNLESS YOU ALSO EDIT THE CORRESPONDING FILE IN packages/template (FOR package.json FILES, PLEASE EDIT package-template.json)",
|
||||
"name": "@hexclave/tanstack-start",
|
||||
"version": "1.0.28",
|
||||
"version": "1.0.30",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"sideEffects": false,
|
||||
"main": "./dist/index.js",
|
||||
@ -103,7 +103,6 @@
|
||||
"react-hook-form": "^7.70.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"rrweb": "^1.1.3",
|
||||
"tsx": "^4.21.0",
|
||||
"yup": "^1.7.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@ -144,6 +143,7 @@
|
||||
"rimraf": "^6.1.2",
|
||||
"tailwindcss": "^3.4.4",
|
||||
"tsdown": "^0.20.3",
|
||||
"tsx": "^4.21.0",
|
||||
"convex": "^1.27.0"
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,7 +13,7 @@
|
||||
|
||||
"//": "NEXT_LINE_PLATFORM template",
|
||||
"private": true,
|
||||
"version": "1.0.28",
|
||||
"version": "1.0.30",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"sideEffects": false,
|
||||
"main": "./dist/index.js",
|
||||
@ -146,7 +146,6 @@
|
||||
"//": "NEXT_LINE_PLATFORM react-like",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"rrweb": "^1.1.3",
|
||||
"tsx": "^4.21.0",
|
||||
"yup": "^1.7.1"
|
||||
},
|
||||
"//": "IF_PLATFORM react-like",
|
||||
@ -202,6 +201,7 @@
|
||||
"rimraf": "^6.1.2",
|
||||
"tailwindcss": "^3.4.4",
|
||||
"tsdown": "^0.20.3",
|
||||
"tsx": "^4.21.0",
|
||||
"convex": "^1.27.0"
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
"//": "THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY UNLESS YOU ALSO EDIT THE CORRESPONDING FILE IN packages/template (FOR package.json FILES, PLEASE EDIT package-template.json)",
|
||||
"name": "@hexclave/template",
|
||||
"private": true,
|
||||
"version": "1.0.28",
|
||||
"version": "1.0.30",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"sideEffects": false,
|
||||
"main": "./dist/index.js",
|
||||
@ -109,7 +109,6 @@
|
||||
"react-hook-form": "^7.70.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"rrweb": "^1.1.3",
|
||||
"tsx": "^4.21.0",
|
||||
"yup": "^1.7.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@ -152,6 +151,7 @@
|
||||
"rimraf": "^6.1.2",
|
||||
"tailwindcss": "^3.4.4",
|
||||
"tsdown": "^0.20.3",
|
||||
"tsx": "^4.21.0",
|
||||
"convex": "^1.27.0"
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,72 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { envVars } from "../generated/env";
|
||||
import { StackClientApp } from "../lib/hexclave-app";
|
||||
import { mountPushedConfigErrorOverlay } from ".";
|
||||
|
||||
function createMockElement() {
|
||||
return {
|
||||
style: {},
|
||||
appendChild: () => {},
|
||||
addEventListener: () => {},
|
||||
setAttribute: () => {},
|
||||
replaceChildren: () => {},
|
||||
remove: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
describe("pushed config error overlay", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("defers the first project refresh until after construction-time callers finish", async () => {
|
||||
const app = new StackClientApp({
|
||||
baseUrl: "http://localhost:12345",
|
||||
projectId: "00000000-0000-4000-8000-000000000000",
|
||||
publishableClientKey: "stack-pk-test",
|
||||
tokenStore: "memory",
|
||||
redirectMethod: "none",
|
||||
devTool: false,
|
||||
});
|
||||
const getProject = vi.fn(async () => ({
|
||||
pushedConfigError: null,
|
||||
configWarnings: [],
|
||||
}));
|
||||
Reflect.set(app, "getProject", getProject);
|
||||
const appendChild = vi.fn();
|
||||
vi.stubEnv("NODE_ENV", "development");
|
||||
expect(Reflect.get(envVars, "NODE_ENV")).toBe("development");
|
||||
|
||||
vi.stubGlobal("window", {
|
||||
"__hexclave-pushed-config-error-overlay": null,
|
||||
location: {
|
||||
href: "http://localhost:3000",
|
||||
},
|
||||
});
|
||||
vi.stubGlobal("document", {
|
||||
body: {
|
||||
appendChild,
|
||||
},
|
||||
createElement: () => createMockElement(),
|
||||
createTextNode: () => createMockElement(),
|
||||
});
|
||||
vi.stubGlobal("localStorage", {
|
||||
getItem: () => null,
|
||||
setItem: () => {},
|
||||
removeItem: () => {},
|
||||
});
|
||||
|
||||
const cleanup = mountPushedConfigErrorOverlay(app);
|
||||
try {
|
||||
expect(appendChild).toHaveBeenCalledOnce();
|
||||
expect(getProject).not.toHaveBeenCalled();
|
||||
|
||||
await Promise.resolve();
|
||||
|
||||
expect(getProject).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -528,7 +528,10 @@ export function mountPushedConfigErrorOverlay(app: StackClientApp<true>): () =>
|
||||
});
|
||||
};
|
||||
|
||||
refresh();
|
||||
// This is mounted from the base client-app constructor, which also runs
|
||||
// before subclass field initializers. Defer the first app call so overridden
|
||||
// methods like adminApp.getProject() can safely touch subclass caches.
|
||||
queueMicrotask(refresh);
|
||||
const interval = setInterval(refresh, REFRESH_INTERVAL_MS);
|
||||
|
||||
const cleanup = () => {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/ui",
|
||||
"version": "1.0.28",
|
||||
"version": "1.0.30",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
|
||||
@ -1981,9 +1981,6 @@ importers:
|
||||
rrweb:
|
||||
specifier: ^1.1.3
|
||||
version: 1.1.3
|
||||
tsx:
|
||||
specifier: ^4.21.0
|
||||
version: 4.21.0
|
||||
yup:
|
||||
specifier: ^1.7.1
|
||||
version: 1.7.1
|
||||
@ -2042,6 +2039,9 @@ importers:
|
||||
tsdown:
|
||||
specifier: ^0.20.3
|
||||
version: 0.20.3(typescript@5.9.3)
|
||||
tsx:
|
||||
specifier: ^4.21.0
|
||||
version: 4.21.0
|
||||
|
||||
packages/next:
|
||||
dependencies:
|
||||
@ -2117,9 +2117,6 @@ importers:
|
||||
tailwindcss-animate:
|
||||
specifier: ^1.0.7
|
||||
version: 1.0.7(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.0))
|
||||
tsx:
|
||||
specifier: ^4.21.0
|
||||
version: 4.21.0
|
||||
yup:
|
||||
specifier: ^1.7.1
|
||||
version: 1.7.1
|
||||
@ -2187,6 +2184,9 @@ importers:
|
||||
tsdown:
|
||||
specifier: ^0.20.3
|
||||
version: 0.20.3(typescript@5.9.3)
|
||||
tsx:
|
||||
specifier: ^4.21.0
|
||||
version: 4.21.0
|
||||
|
||||
packages/react:
|
||||
dependencies:
|
||||
@ -2259,9 +2259,6 @@ importers:
|
||||
tailwindcss-animate:
|
||||
specifier: ^1.0.7
|
||||
version: 1.0.7(tailwindcss@3.4.14)
|
||||
tsx:
|
||||
specifier: ^4.21.0
|
||||
version: 4.21.0
|
||||
yup:
|
||||
specifier: ^1.7.1
|
||||
version: 1.7.1
|
||||
@ -2326,6 +2323,9 @@ importers:
|
||||
tsdown:
|
||||
specifier: ^0.20.3
|
||||
version: 0.20.3(typescript@5.9.3)
|
||||
tsx:
|
||||
specifier: ^4.21.0
|
||||
version: 4.21.0
|
||||
|
||||
packages/sc:
|
||||
dependencies:
|
||||
@ -2539,9 +2539,6 @@ importers:
|
||||
tailwindcss-animate:
|
||||
specifier: ^1.0.7
|
||||
version: 1.0.7(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.0))
|
||||
tsx:
|
||||
specifier: ^4.21.0
|
||||
version: 4.21.0
|
||||
yup:
|
||||
specifier: ^1.7.1
|
||||
version: 1.7.1
|
||||
@ -2612,6 +2609,9 @@ importers:
|
||||
tsdown:
|
||||
specifier: ^0.20.3
|
||||
version: 0.20.3(typescript@5.9.3)
|
||||
tsx:
|
||||
specifier: ^4.21.0
|
||||
version: 4.21.0
|
||||
|
||||
packages/template:
|
||||
dependencies:
|
||||
@ -2687,9 +2687,6 @@ importers:
|
||||
tailwindcss-animate:
|
||||
specifier: ^1.0.7
|
||||
version: 1.0.7(tailwindcss@3.4.14)
|
||||
tsx:
|
||||
specifier: ^4.21.0
|
||||
version: 4.21.0
|
||||
yup:
|
||||
specifier: ^1.7.1
|
||||
version: 1.7.1
|
||||
@ -2763,6 +2760,9 @@ importers:
|
||||
tsdown:
|
||||
specifier: ^0.20.3
|
||||
version: 0.20.3(typescript@5.9.3)
|
||||
tsx:
|
||||
specifier: ^4.21.0
|
||||
version: 4.21.0
|
||||
|
||||
packages/ui:
|
||||
dependencies:
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/swift-sdk",
|
||||
"version": "1.0.28",
|
||||
"version": "1.0.30",
|
||||
"private": true,
|
||||
"description": "Hexclave Swift SDK",
|
||||
"scripts": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/sdk-spec",
|
||||
"version": "1.0.28",
|
||||
"version": "1.0.30",
|
||||
"private": true,
|
||||
"description": "Hexclave SDK specification files",
|
||||
"scripts": {}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user