From 431a60f9c8f29bd9e83404dfb231b7677204c9c8 Mon Sep 17 00:00:00 2001 From: sirily Date: Tue, 10 Mar 2026 15:52:16 +0300 Subject: [PATCH] fix: hide exact quota values from account response (#16) Closes #1 - hide exact quota values from GET /api/account - keep only the approximate quota bucket in the public account payload - add a regression test for the public account response contract - document that completed tasks should end with a PR Co-authored-by: sirily Reviewed-on: http://git.shararam.party/sirily/nroxy/pulls/16 --- AGENTS.md | 12 +++++ CONTRIBUTING.md | 16 +++--- apps/web/package.json | 2 + apps/web/src/account-response.test.ts | 65 ++++++++++++++++++++++++ apps/web/src/account-response.ts | 71 +++++++++++++++++++++++++++ apps/web/src/main.ts | 60 +--------------------- packages/db/src/account-store.ts | 6 --- 7 files changed, 162 insertions(+), 70 deletions(-) create mode 100644 apps/web/src/account-response.test.ts create mode 100644 apps/web/src/account-response.ts diff --git a/AGENTS.md b/AGENTS.md index 85c3dd6..d8e7dd5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,3 +44,15 @@ This repository is a TypeScript monorepo for `nproxy`, a crypto-subscription ima - If you change deployment assumptions, update `docs/ops/deployment.md`. - If you change Telegram admin auth, update `docs/ops/telegram-pairing.md`. - If you change failover, cooldown, or balance logic, update `docs/ops/provider-key-pool.md`. + +## Workflow +- At the end of each completed task, create a PR for the changes unless the user explicitly says not to. + +## Gitea Workflow +- Treat the remote Gitea repository as the source of truth for task tracking when the user refers to `issues`. +- Before starting implementation, open the relevant Gitea issue and read its full problem statement and acceptance criteria instead of guessing from local notes. +- If the user asks to "continue work" or pick the next task, inspect the open Gitea issues and choose the first one that is logically ready to implement. +- After finishing the task, push the branch and create a Gitea PR linked to the issue. +- Every task PR must include an issue reference in the PR body, usually `Closes #` (or `Refs #` when it should not auto-close on merge). +- If the issue-to-PR link is not clearly visible in Gitea, add an explicit comment in the issue with the PR number and URL. +- If Gitea access requires credentials or network escalation, use the configured repository environment and approved escalation flow instead of skipping the issue lookup. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1f5728e..22981d9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,12 +8,15 @@ ## Standard Flow 1. Update local `master`. 2. Create a task branch from `master`. -3. Implement the change in that branch. -4. Run the relevant verification for the change. -5. Commit the work in that branch. -6. Push the branch to Gitea. -7. Open a merge request into `master`. -8. Merge only after review or explicit approval. +3. Pick or confirm the target issue in Gitea before implementation. +4. Implement the change in that branch. +5. Run the relevant verification for the change. +6. Commit the work in that branch. +7. Push the branch to Gitea. +8. Open a merge request into `master`. +9. Link the merge request to the issue in the MR body using `Closes #` or `Refs #`. +10. If the Gitea UI does not show the link clearly, add an explicit comment in the issue with the MR number and URL. +11. Merge only after review or explicit approval. ## Branch Naming Use short, purpose-driven names, for example: @@ -31,3 +34,4 @@ Use short, purpose-driven names, for example: ## Agent Workflow - Codex or any other coding agent must create and use a dedicated branch per task. - After task completion, the agent should push that branch and prepare it for a merge request instead of pushing directly to `master`. +- The agent must ensure the issue is explicitly linked to the merge request before handing off for merge. diff --git a/apps/web/package.json b/apps/web/package.json index 038d206..d0553d3 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -5,6 +5,8 @@ "type": "module", "scripts": { "build": "tsc -p tsconfig.json", + "pretest": "pnpm build", + "test": "node --test dist/**/*.test.js", "start": "node dist/main.js" }, "dependencies": { diff --git a/apps/web/src/account-response.test.ts b/apps/web/src/account-response.test.ts new file mode 100644 index 0000000..ff56082 --- /dev/null +++ b/apps/web/src/account-response.test.ts @@ -0,0 +1,65 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { serializePublicAccountOverview } from "./account-response.js"; + +test("serializePublicAccountOverview exposes only approximate quota fields", () => { + const response = serializePublicAccountOverview({ + user: { + id: "user_1", + email: "user@example.com", + isAdmin: false, + createdAt: new Date("2026-03-10T12:00:00.000Z"), + }, + subscription: { + id: "sub_1", + status: "active", + renewsManually: true, + activatedAt: new Date("2026-03-10T12:00:00.000Z"), + currentPeriodStart: new Date("2026-03-10T12:00:00.000Z"), + currentPeriodEnd: new Date("2026-04-09T12:00:00.000Z"), + plan: { + id: "plan_1", + code: "basic", + displayName: "Basic", + monthlyPriceUsd: 29, + billingCurrency: "USDT", + isActive: true, + }, + }, + quota: { + approximateBucket: 80, + }, + }); + + assert.deepEqual(response, { + user: { + id: "user_1", + email: "user@example.com", + isAdmin: false, + createdAt: "2026-03-10T12:00:00.000Z", + }, + subscription: { + id: "sub_1", + status: "active", + renewsManually: true, + activatedAt: "2026-03-10T12:00:00.000Z", + currentPeriodStart: "2026-03-10T12:00:00.000Z", + currentPeriodEnd: "2026-04-09T12:00:00.000Z", + plan: { + id: "plan_1", + code: "basic", + displayName: "Basic", + monthlyPriceUsd: 29, + billingCurrency: "USDT", + isActive: true, + }, + }, + quota: { + approximateBucket: 80, + }, + }); + + assert.equal("usedSuccessfulRequests" in (response.quota ?? {}), false); + assert.equal("monthlyRequestLimit" in (response.quota ?? {}), false); + assert.equal("monthlyRequestLimit" in (response.subscription?.plan ?? {}), false); +}); diff --git a/apps/web/src/account-response.ts b/apps/web/src/account-response.ts new file mode 100644 index 0000000..255eea9 --- /dev/null +++ b/apps/web/src/account-response.ts @@ -0,0 +1,71 @@ +export interface PublicAccountOverviewLike { + user: { + id: string; + email: string; + isAdmin: boolean; + createdAt: Date; + }; + subscription: { + id: string; + status: string; + renewsManually: boolean; + activatedAt?: Date; + currentPeriodStart?: Date; + currentPeriodEnd?: Date; + canceledAt?: Date; + plan: { + id: string; + code: string; + displayName: string; + monthlyPriceUsd: number; + billingCurrency: string; + isActive: boolean; + }; + } | null; + quota: { + approximateBucket: number; + } | null; +} + +export function serializePublicAccountOverview(overview: PublicAccountOverviewLike) { + return { + user: { + id: overview.user.id, + email: overview.user.email, + isAdmin: overview.user.isAdmin, + createdAt: overview.user.createdAt.toISOString(), + }, + subscription: overview.subscription + ? { + id: overview.subscription.id, + status: overview.subscription.status, + renewsManually: overview.subscription.renewsManually, + ...(overview.subscription.activatedAt + ? { activatedAt: overview.subscription.activatedAt.toISOString() } + : {}), + ...(overview.subscription.currentPeriodStart + ? { currentPeriodStart: overview.subscription.currentPeriodStart.toISOString() } + : {}), + ...(overview.subscription.currentPeriodEnd + ? { currentPeriodEnd: overview.subscription.currentPeriodEnd.toISOString() } + : {}), + ...(overview.subscription.canceledAt + ? { canceledAt: overview.subscription.canceledAt.toISOString() } + : {}), + plan: { + id: overview.subscription.plan.id, + code: overview.subscription.plan.code, + displayName: overview.subscription.plan.displayName, + monthlyPriceUsd: overview.subscription.plan.monthlyPriceUsd, + billingCurrency: overview.subscription.plan.billingCurrency, + isActive: overview.subscription.plan.isActive, + }, + } + : null, + quota: overview.quota + ? { + approximateBucket: overview.quota.approximateBucket, + } + : null, + }; +} diff --git a/apps/web/src/main.ts b/apps/web/src/main.ts index 8355fa4..2fa1422 100644 --- a/apps/web/src/main.ts +++ b/apps/web/src/main.ts @@ -18,6 +18,7 @@ import { createGenerationRequest, type CreateGenerationRequestInput, } from "@nproxy/domain"; +import { serializePublicAccountOverview } from "./account-response.js"; const config = loadConfig(); const port = Number.parseInt(process.env.PORT ?? "3000", 10); @@ -159,7 +160,7 @@ const server = createServer(async (request, response) => { return; } - sendJson(response, 200, serializeAccountOverview(overview)); + sendJson(response, 200, serializePublicAccountOverview(overview)); return; } @@ -493,63 +494,6 @@ function serializeUserSession( }; } -function serializeAccountOverview(overview: { - user: { - id: string; - email: string; - isAdmin: boolean; - createdAt: Date; - }; - subscription: { - id: string; - status: string; - renewsManually: boolean; - activatedAt?: Date; - currentPeriodStart?: Date; - currentPeriodEnd?: Date; - canceledAt?: Date; - plan: { - id: string; - code: string; - displayName: string; - monthlyRequestLimit: number; - monthlyPriceUsd: number; - billingCurrency: string; - isActive: boolean; - }; - } | null; - quota: { - approximateBucket: number; - usedSuccessfulRequests: number; - monthlyRequestLimit: number; - } | null; -}) { - return { - user: serializeAuthenticatedUser(overview.user), - subscription: overview.subscription - ? { - id: overview.subscription.id, - status: overview.subscription.status, - renewsManually: overview.subscription.renewsManually, - ...(overview.subscription.activatedAt - ? { activatedAt: overview.subscription.activatedAt.toISOString() } - : {}), - ...(overview.subscription.currentPeriodStart - ? { currentPeriodStart: overview.subscription.currentPeriodStart.toISOString() } - : {}), - ...(overview.subscription.currentPeriodEnd - ? { currentPeriodEnd: overview.subscription.currentPeriodEnd.toISOString() } - : {}), - ...(overview.subscription.canceledAt - ? { canceledAt: overview.subscription.canceledAt.toISOString() } - : {}), - plan: overview.subscription.plan, - } - : null, - quota: overview.quota, - }; -} - function serializeBillingInvoice(invoice: { id: string; subscriptionId?: string; diff --git a/packages/db/src/account-store.ts b/packages/db/src/account-store.ts index ce8d8b8..f0880b4 100644 --- a/packages/db/src/account-store.ts +++ b/packages/db/src/account-store.ts @@ -22,7 +22,6 @@ export interface UserAccountOverview { id: string; code: string; displayName: string; - monthlyRequestLimit: number; monthlyPriceUsd: number; billingCurrency: string; isActive: boolean; @@ -30,8 +29,6 @@ export interface UserAccountOverview { } | null; quota: { approximateBucket: QuotaBucket; - usedSuccessfulRequests: number; - monthlyRequestLimit: number; } | null; } @@ -95,7 +92,6 @@ export function createPrismaAccountStore(database: PrismaClient = defaultPrisma) id: subscription.plan.id, code: subscription.plan.code, displayName: subscription.plan.displayName, - monthlyRequestLimit: subscription.plan.monthlyRequestLimit, monthlyPriceUsd: decimalToNumber(subscription.plan.monthlyPriceUsd), billingCurrency: subscription.plan.billingCurrency, isActive: subscription.plan.isActive, @@ -140,7 +136,5 @@ async function buildQuotaSnapshot( used: usedSuccessfulRequests, limit: input.monthlyRequestLimit, }), - usedSuccessfulRequests, - monthlyRequestLimit: input.monthlyRequestLimit, }; }