fix: hide exact quota values from account response #16

Merged
sirily merged 4 commits from fix/account-quota-contract into master 2026-03-10 15:52:16 +03:00
7 changed files with 162 additions and 70 deletions

View File

@@ -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 #<issue-number>` (or `Refs #<issue-number>` 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.

View File

@@ -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 #<issue-number>` or `Refs #<issue-number>`.
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.

View File

@@ -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": {

View File

@@ -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);
});

View File

@@ -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,
};
}

View File

@@ -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;

View File

@@ -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,
};
}