From 895290d88fd71dd8eb657db8c5192f156aa682a8 Mon Sep 17 00:00:00 2001
From: Furkan Akbulutlar
Date: Fri, 16 Oct 2026 14:30:06 +0200
Subject: [PATCH] fix: allow one workspace export every 24 hours
---
.../organization/[organizationId]/export/route.ts | 7 ++++---
.../team-manage/WorkspaceExportSection.tsx | 4 ++--
lib/data/organization-portability.ts | 12 ++++++------
lib/organization-portability/archive.ts | 2 +-
lib/organization-portability/client.ts | 2 +-
tests/api/organization-portability.test.ts | 9 +++++----
6 files changed, 19 insertions(+), 17 deletions(-)
diff --git a/app/api/organization/[organizationId]/export/route.ts b/app/api/organization/[organizationId]/export/route.ts
index 1cb57589..6879f200 100644
--- a/app/api/organization/[organizationId]/export/route.ts
+++ b/app/api/organization/[organizationId]/export/route.ts
@@ -10,7 +10,7 @@ import {
} from "@/lib/data/organization-portability";
import {
ORGANIZATION_ARCHIVE_MEDIA_TYPE,
- ORGANIZATION_EXPORT_COOLDOWN_DAYS,
+ ORGANIZATION_EXPORT_COOLDOWN_HOURS,
ORGANIZATION_EXPORT_INTENT_HEADER,
ORGANIZATION_EXPORT_INTENT_VALUE,
OrganizationArchiveError,
@@ -53,7 +53,7 @@ function jsonError(
}
/**
- * Verify browser intent before a request can consume the monthly export slot.
+ * Verify browser intent before a request can consume the export slot.
*
* @param request - Candidate workspace export request.
* @returns True for the application's non-simple same-origin POST.
@@ -127,9 +127,10 @@ export async function POST(
});
} catch (error) {
if (error instanceof OrganizationExportLimitError) {
+ const hoursLeft = Math.ceil(error.retryAfterSeconds / 3600);
return jsonError(
"export_limit_reached",
- `You can generate one workspace archive every ${ORGANIZATION_EXPORT_COOLDOWN_DAYS} days.`,
+ `You can generate one workspace archive every ${ORGANIZATION_EXPORT_COOLDOWN_HOURS} hours. Try again in ${hoursLeft === 1 ? "1 hour" : `${hoursLeft} hours`}.`,
429,
{ "retry-after": String(error.retryAfterSeconds) },
);
diff --git a/app/settings/_components/team-manage/WorkspaceExportSection.tsx b/app/settings/_components/team-manage/WorkspaceExportSection.tsx
index 665d13e9..bcaec8a0 100644
--- a/app/settings/_components/team-manage/WorkspaceExportSection.tsx
+++ b/app/settings/_components/team-manage/WorkspaceExportSection.tsx
@@ -4,7 +4,7 @@ import { useRef, useState, useTransition } from "react";
import { Button } from "@/components/shared/Button";
import { IconCheck, IconDoc } from "@/components/shared/icons";
import {
- ORGANIZATION_EXPORT_COOLDOWN_DAYS,
+ ORGANIZATION_EXPORT_COOLDOWN_HOURS,
ORGANIZATION_EXPORT_INTENT_HEADER,
ORGANIZATION_EXPORT_INTENT_VALUE,
organizationArchiveFilename,
@@ -108,7 +108,7 @@ export function WorkspaceExportSection({
activity history as one JSON archive. Member accounts and other
members' private notes are not included. Your own private
notes are included. You can generate one archive every{" "}
- {ORGANIZATION_EXPORT_COOLDOWN_DAYS} days.
+ {ORGANIZATION_EXPORT_COOLDOWN_HOURS} hours.
The archive can include confidential content, deleted notes, and
diff --git a/lib/data/organization-portability.ts b/lib/data/organization-portability.ts
index 4662b53a..17aef09d 100644
--- a/lib/data/organization-portability.ts
+++ b/lib/data/organization-portability.ts
@@ -36,7 +36,7 @@ import { deriveIdentifier } from "@/lib/graph/identifier";
import {
MAX_ORGANIZATION_ARCHIVE_BYTES,
MAX_ORGANIZATION_ARCHIVE_ROWS,
- ORGANIZATION_EXPORT_COOLDOWN_DAYS,
+ ORGANIZATION_EXPORT_COOLDOWN_HOURS,
OrganizationArchiveError,
decodeOrganizationArchive,
serializeOrganizationArchiveRow,
@@ -155,16 +155,16 @@ async function reserveOrganizationExport(
target: organizationExportLimits.userId,
set: { lastStartedAt: sql`clock_timestamp()` },
setWhere: sql`${organizationExportLimits.lastStartedAt} <= clock_timestamp() - interval '${sql.raw(
- String(ORGANIZATION_EXPORT_COOLDOWN_DAYS),
- )} days'`,
+ String(ORGANIZATION_EXPORT_COOLDOWN_HOURS),
+ )} hours'`,
})
.returning({ startedAt: organizationExportLimits.lastStartedAt });
if (reservations.length === 0) {
const [limit] = await tx
.select({
retryAfterSeconds: sql`GREATEST(1, CEIL(EXTRACT(EPOCH FROM (${organizationExportLimits.lastStartedAt} + interval '${sql.raw(
- String(ORGANIZATION_EXPORT_COOLDOWN_DAYS),
- )} days' - clock_timestamp()))))::integer`,
+ String(ORGANIZATION_EXPORT_COOLDOWN_HOURS),
+ )} hours' - clock_timestamp()))))::integer`,
})
.from(organizationExportLimits)
.where(eq(organizationExportLimits.userId, userId));
@@ -209,7 +209,7 @@ function assertOrganizationExportWithinBounds(raw: RawReadRows): void {
}
/**
- * Verify ownership and size before the monthly export slot is consumed.
+ * Verify ownership and size before the export slot is consumed.
*
* @param userId - Authenticated exporting user id.
* @param organizationId - Organization whose rows should be measured.
diff --git a/lib/organization-portability/archive.ts b/lib/organization-portability/archive.ts
index eba2558a..fb2a70f3 100644
--- a/lib/organization-portability/archive.ts
+++ b/lib/organization-portability/archive.ts
@@ -16,7 +16,7 @@ export {
MAX_ORGANIZATION_ARCHIVE_MIB,
MAX_ORGANIZATION_IMPORT_BYTES,
MAX_ORGANIZATION_IMPORT_MIB,
- ORGANIZATION_EXPORT_COOLDOWN_DAYS,
+ ORGANIZATION_EXPORT_COOLDOWN_HOURS,
ORGANIZATION_EXPORT_INTENT_HEADER,
ORGANIZATION_EXPORT_INTENT_VALUE,
ORGANIZATION_ARCHIVE_MEDIA_TYPE,
diff --git a/lib/organization-portability/client.ts b/lib/organization-portability/client.ts
index dfa5c0f0..d5759f34 100644
--- a/lib/organization-portability/client.ts
+++ b/lib/organization-portability/client.ts
@@ -18,7 +18,7 @@ export const MAX_ORGANIZATION_IMPORT_BYTES =
MAX_ORGANIZATION_IMPORT_MIB * 1024 * 1024;
/** Rolling cooldown between admitted workspace archive generations. */
-export const ORGANIZATION_EXPORT_COOLDOWN_DAYS = 30;
+export const ORGANIZATION_EXPORT_COOLDOWN_HOURS = 24;
/** Header required on the state-changing workspace export request. */
export const ORGANIZATION_EXPORT_INTENT_HEADER = "x-piyaz-export";
diff --git a/tests/api/organization-portability.test.ts b/tests/api/organization-portability.test.ts
index 3594fe93..fb400a68 100644
--- a/tests/api/organization-portability.test.ts
+++ b/tests/api/organization-portability.test.ts
@@ -294,7 +294,7 @@ describe("POST /api/organization/[organizationId]/export", () => {
expect(archive.projects).toHaveLength(1);
});
- test("enforces one workspace export per user every 30 days", async () => {
+ test("enforces one workspace export per user every 24 hours", async () => {
const fixture = await seedUserOrgProject("route-monthly-limit");
const [otherOrganization] = await superuserPool()<{ id: string }[]>`
INSERT INTO piyaz_auth."organization" ("name", "slug", "createdAt")
@@ -319,15 +319,16 @@ describe("POST /api/organization/[organizationId]/export", () => {
expect(limited.status).toBe(429);
expect(await limited.json()).toEqual({
code: "export_limit_reached",
- error: "You can generate one workspace archive every 30 days.",
+ error:
+ "You can generate one workspace archive every 24 hours. Try again in 24 hours.",
});
const retryAfter = Number(limited.headers.get("retry-after"));
expect(retryAfter).toBeGreaterThan(0);
- expect(retryAfter).toBeLessThanOrEqual(30 * 24 * 60 * 60);
+ expect(retryAfter).toBeLessThanOrEqual(24 * 60 * 60);
await superuserPool()`
UPDATE organization_export_limits
- SET last_started_at = clock_timestamp() - interval '31 days'
+ SET last_started_at = clock_timestamp() - interval '25 hours'
WHERE user_id = ${fixture.userId}
`;
const retried = await request(otherOrganization.id);