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 <sirily@git.shararam.party> Reviewed-on: #16
This commit was merged in pull request #16.
This commit is contained in:
12
AGENTS.md
12
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 #<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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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": {
|
||||
|
||||
65
apps/web/src/account-response.test.ts
Normal file
65
apps/web/src/account-response.test.ts
Normal 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);
|
||||
});
|
||||
71
apps/web/src/account-response.ts
Normal file
71
apps/web/src/account-response.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user