Add per-user daily token quota with persistent usage tracking

This commit is contained in:
2026-08-21 12:25:57 -07:00
parent 2c3dc66878
commit f1200b7d79
6 changed files with 199 additions and 45 deletions

View File

@@ -8,12 +8,26 @@ MOCK_AI=false
# Required for the AI draft button, unless MOCK_AI=true.
ANTHROPIC_API_KEY=your-anthropic-api-key-here
# Required for company deployment. Leave both blank for solo local use with
# no login prompt. Set both to require a login for anyone reaching this tool.
# Named users. Comma-separated user:password pairs. Each name doubles as the
# identity used for the per-user token quota below.
# Example: APP_USERS=alice:pass1,bob:pass2,carol:pass3
# Leave blank for solo local use with no login prompt (usage is then tracked
# under the identity "local").
APP_USERS=
# Legacy single shared login. Only used if APP_USERS is blank. Everyone who
# logs in with this pair shares one identity and one token quota.
APP_USERNAME=
APP_PASSWORD=
# Optional. Caps AI draft calls per source IP, to limit cost from the shared
# ANTHROPIC_API_KEY. Defaults: 20 requests per 5 minutes.
# Optional. Caps AI draft calls per source IP, to catch a runaway script
# fast. Defaults: 20 requests per 5 minutes.
RATE_LIMIT_MAX=20
RATE_LIMIT_WINDOW_MS=300000
# Caps combined input+output tokens per named user, per UTC calendar day.
# Default: 50000 tokens/day (roughly 100-200 AI drafts with this tool's
# prompt size). Usage is written to TOKEN_USAGE_FILE and persists across
# restarts if that file's folder is a mounted volume.
TOKEN_LIMIT_PER_USER=50000
TOKEN_USAGE_FILE=./data/token-usage.json

1
.gitignore vendored
View File

@@ -3,3 +3,4 @@ node_modules/
Thumbs.db
*.log
.env
/data/

View File

@@ -26,14 +26,15 @@ If either command fails, install Docker before you continue.
3. Set a test value for each line:
```
ANTHROPIC_API_KEY=<a personal or trial key, for testing only>
APP_USERNAME=tester
APP_PASSWORD=test-password-123
APP_USERS=tester:test-password-123,tester2:test-password-456
RATE_LIMIT_MAX=5
RATE_LIMIT_WINDOW_MS=60000
TOKEN_LIMIT_PER_USER=2000
TOKEN_USAGE_FILE=./data/token-usage.json
```
4. Save the file.
Use a personal or trial API key here, not the company-billed key. Keep the company key for the real deployment. Set `APP_USERNAME` and `APP_PASSWORD` so you can confirm the login gate works. `RATE_LIMIT_MAX=5` with a 60 second window makes the rate limit easy to trigger on purpose, for testing.
Use a personal or trial API key here, not the company-billed key. Keep the company key for the real deployment. `APP_USERS` sets up two logins so you can confirm each person gets a separate quota. `RATE_LIMIT_MAX=5` with a 60 second window makes the rate limit easy to trigger on purpose. `TOKEN_LIMIT_PER_USER=2000` is deliberately low, so you can hit the daily quota in a couple of clicks instead of a couple hundred.
`.env` is not tracked by Git. Docker Compose reads it automatically because `docker-compose.yml` lists it under `env_file`.
@@ -59,14 +60,14 @@ Use a personal or trial API key here, not the company-billed key. Keep the compa
```
docker compose logs
```
4. Confirm the log reports whether it found `ANTHROPIC_API_KEY` and whether a login is required.
4. Confirm the log reports whether it found `ANTHROPIC_API_KEY`, how many named users are configured, and the per-user token quota.
## Step 5: Test in a browser
1. Open a browser.
2. Go to `http://localhost:5173`.
3. Confirm the browser asks for a username and password.
4. Enter the `APP_USERNAME` and `APP_PASSWORD` values from your `.env` file.
4. Enter one of the `APP_USERS` pairs from your `.env` file, for example `tester` / `test-password-123`.
5. Confirm the toolkit's landing page loads, with links to both tools.
6. Open each tool link and confirm it loads.
@@ -91,13 +92,26 @@ Use a personal or trial API key here, not the company-billed key. Keep the compa
2. Confirm the next click returns a rate-limit message instead of a normal draft or a silent failure.
3. Wait for the time window to pass, then confirm the button works again.
## Step 9: Stop the container
## Step 9: Test the per-user token quota
1. Log in as `tester` and click the AI draft button once or twice, until the response reports a quota error instead of a draft. With `TOKEN_LIMIT_PER_USER=2000`, this takes one or two clicks.
2. Confirm the error names `tester`, the tokens used, the limit, and a countdown to the reset.
3. Open a new private or incognito browser window and log in as `tester2` instead.
4. Confirm `tester2` can still click the AI draft button. Each named user has a separate quota.
5. Run this command to view the usage file directly:
```
cat data/token-usage.json
```
6. Confirm it lists a separate entry for each user who made a call, with today's date and a token count.
7. Run `docker compose restart`, then confirm `tester` is still blocked. The quota survives a restart because `data` is a mounted volume.
## Step 10: Stop the container
1. Run this command:
```
docker compose down
```
2. This stops and removes the container. It does not delete your `.env` file or the project folder.
2. This stops and removes the container. It does not delete your `.env` file, your `data` folder, or the project folder.
## Rebuild after a code change
@@ -114,7 +128,10 @@ Use a personal or trial API key here, not the company-billed key. Keep the compa
Stop whatever else is using that port, or change the port mapping in `docker-compose.yml` from `"5173:5173"` to, for example, `"5180:5173"`. Then open `http://localhost:5180` instead.
**The browser does not ask for a login.**
Check that both `APP_USERNAME` and `APP_PASSWORD` are set in `.env`, with no typos in the variable names. Restart the container after any `.env` change: `docker compose up -d --build`.
Check that `APP_USERS` is set in `.env` (or the legacy `APP_USERNAME`/`APP_PASSWORD` pair), with no typos in the variable names. Restart the container after any `.env` change: `docker compose up -d --build`.
**Everyone seems to share one quota, or a user's quota did not reset the next day.**
Check that each person has their own entry in `APP_USERS`, not one shared `APP_USERNAME`/`APP_PASSWORD`. Quota resets happen on UTC calendar days, which may be a few hours off from your local midnight.
**The AI draft button reports a missing key.**
Check that `ANTHROPIC_API_KEY` is set in `.env` and is a real key. Restart the container after the change.

View File

@@ -128,32 +128,41 @@ The `.gitignore` file excludes `node_modules` and common system files from commi
## Deploy for the whole company
Use this when the tool needs to be reachable by anyone on the internal network or VPN, not just on one person's machine. This uses a company-owned Anthropic API key shared by everyone who reaches the tool, so it adds a login gate and a request cap that a solo local setup does not need.
Use this when the tool needs to be reachable by anyone on the internal network or VPN, not just on one person's machine. This uses a company-owned Anthropic API key shared by everyone who reaches the tool, so it adds a per-person login, a per-person daily token quota, and a request cap that a solo local setup does not need.
1. Get an Anthropic API key billed to a company account, not a personal one. IT or finance should provision this, since it is billed like any other company vendor cost.
2. Pick a shared username and password for this tool. This is a simple login gate, not a full identity system. It exists so the tool is not reachable by anyone who is merely on the same network segment, but treat the VPN and internal network as the primary control, not this login.
2. Decide who needs their own login. Each name gets its own password and its own daily token quota, tracked separately.
3. On the host or container platform, set these values as environment variables, or in a `.env` file next to `docker-compose.yml`:
```
ANTHROPIC_API_KEY=<company key>
APP_USERNAME=<shared username>
APP_PASSWORD=<shared password>
APP_USERS=alice:pass1,bob:pass2,carol:pass3
TOKEN_LIMIT_PER_USER=50000
```
4. Build and run the container:
```
docker compose up -d --build
```
5. To run this alongside the existing Work Package Suite container instead of on its own, copy the `sde-meeting-toolkit` service block from `docker-compose.yml` into that stack's compose file, and apply the same environment variables there.
6. Confirm the login prompt appears when you open the tool's URL from another machine on the network.
5. To run this alongside the existing Work Package Suite container instead of on its own, copy the `sde-meeting-toolkit` service block from `docker-compose.yml` into that stack's compose file, including its `volumes` entry, and apply the same environment variables there.
6. Confirm the login prompt appears when you open the tool's URL from another machine on the network, and that it accepts one of the named user/password pairs.
### What the login gate does and does not do
- It requires a username and password before any page or API call on this tool succeeds.
- It requires a username and password before any page or API call on this tool succeeds, and identifies which named user made each AI draft call.
- It does not encrypt traffic on its own. Run this behind the same network and VPN protections used for the Work Package Suite, and add TLS at the reverse proxy or load balancer if one is already in place for that stack.
- It does not track who made which AI draft request. Every user shares one login and one API key. If per-person attribution matters later, that needs a real identity integration, which is a larger change than this tool currently supports.
- It is a shared-credential list, not a real identity system. Anyone who has a name's password can use that name's quota. If real single-sign-on attribution matters later, that needs a larger integration than this tool currently supports.
- Old single-shared-login setups still work: set `APP_USERNAME` and `APP_PASSWORD` instead of `APP_USERS` if you want everyone to share one login and one quota, unchanged from before this feature existed.
### Per-user token quota
`TOKEN_LIMIT_PER_USER` caps combined input and output tokens per named user, per UTC calendar day. Default: 50000 tokens/day, which is roughly 100 to 200 AI drafts with this tool's prompt size. A user who hits the cap gets a clear error naming their usage and the time until reset, instead of a silent failure or an unexplained cost.
Usage is written to `TOKEN_USAGE_FILE` (default `./data/token-usage.json`) after every AI call. In Docker, `docker-compose.yml` mounts `./data` as a volume so this file survives a restart or redeploy. If you remove that volume mount, usage resets to zero every time the container restarts, which defeats the point of a daily cap.
To reset one person's quota early, stop the container, edit their entry out of the usage file (or set its `date` to any past date), and restart. To raise or lower the cap for everyone, change `TOKEN_LIMIT_PER_USER` and restart; the change applies from that point on, not retroactively.
### Rate limit
`RATE_LIMIT_MAX` and `RATE_LIMIT_WINDOW_MS` cap AI draft requests per source IP address, to prevent a leaked link or a stuck script from running up cost on the shared key. Defaults: 20 requests per 5 minutes. Raise these in `.env` if real usage hits the limit; the tool returns a clear rate-limit error rather than failing silently.
`RATE_LIMIT_MAX` and `RATE_LIMIT_WINDOW_MS` cap AI draft requests per source IP address, independent of the token quota. This catches a runaway script in the first few seconds, before it could burn through a whole day's token quota. Defaults: 20 requests per 5 minutes.
## Relation to the Work Package Suite

View File

@@ -14,3 +14,7 @@ services:
- "5173:5173"
env_file:
- .env
volumes:
# Persists the per-user token quota file across restarts and rebuilds.
# Without this, everyone's daily usage silently resets on every deploy.
- ./data:/app/data

157
server.js
View File

@@ -3,6 +3,7 @@
* Serves the static tool files.
* Proxies AI draft requests to the Anthropic API, so the API key
* stays on the server and never appears in the browser.
* Identifies each caller by login and enforces a daily token quota per person.
*/
const http = require('http');
const fs = require('fs');
@@ -49,17 +50,52 @@ function sendJson(res, status, obj) {
}
/* ===== Access control =====
* Set APP_USERNAME and APP_PASSWORD (in .env or the container environment)
* to require a login for the whole app. Leave both unset for solo local use
* with no login prompt. This is a simple shared-credential gate, meant to sit
* behind the company VPN and internal network, not to replace them. */
* Named users. Set APP_USERS as a comma-separated list of user:password
* pairs, for example: APP_USERS=alice:pass1,bob:pass2
* Each name is also the identity used for the daily token quota below.
*
* Legacy single-user mode: set APP_USERNAME and APP_PASSWORD instead. That
* name becomes the one identity everyone shares (no per-person quota).
*
* Leave all of the above unset for solo local use: no login prompt, and
* usage is tracked under the identity "local".
*
* This is a simple shared-credential gate, meant to sit behind the company
* VPN and internal network, not to replace them. */
function loadUserMap() {
const map = {};
if (process.env.APP_USERS) {
process.env.APP_USERS.split(',').forEach((pair) => {
const idx = pair.indexOf(':');
if (idx === -1) return;
const name = pair.slice(0, idx).trim();
const pass = pair.slice(idx + 1).trim();
if (name && pass) map[name] = pass;
});
} else if (process.env.APP_USERNAME && process.env.APP_PASSWORD) {
map[process.env.APP_USERNAME] = process.env.APP_PASSWORD;
}
return map;
}
const USER_MAP = loadUserMap();
const AUTH_REQUIRED = Object.keys(USER_MAP).length > 0;
// Returns the identified username on success, or null on failure.
function checkBasicAuth(req) {
const user = process.env.APP_USERNAME;
const pass = process.env.APP_PASSWORD;
if (!user || !pass) return true;
if (!AUTH_REQUIRED) return 'local';
const header = req.headers['authorization'] || '';
const expected = 'Basic ' + Buffer.from(`${user}:${pass}`).toString('base64');
return header === expected;
if (!header.startsWith('Basic ')) return null;
let decoded;
try {
decoded = Buffer.from(header.slice(6), 'base64').toString('utf8');
} catch (err) {
return null;
}
const idx = decoded.indexOf(':');
if (idx === -1) return null;
const name = decoded.slice(0, idx);
const pass = decoded.slice(idx + 1);
return USER_MAP[name] === pass ? name : null;
}
function requireAuth(res) {
res.writeHead(401, {
@@ -70,9 +106,9 @@ function requireAuth(res) {
}
/* ===== Rate limiting for the AI proxy =====
* A shared company API key means one leaked link or one runaway script can
* generate real cost. This caps AI draft calls per source IP. It resets if
* the process restarts; that is an accepted tradeoff for a small internal tool. */
* Caps AI draft calls per source IP, independent of the token quota below.
* This catches a runaway script quickly, before it burns through a whole
* day's token quota in a few seconds. Resets if the process restarts. */
const RATE_LIMIT_MAX = parseInt(process.env.RATE_LIMIT_MAX || '20', 10);
const RATE_LIMIT_WINDOW_MS = parseInt(process.env.RATE_LIMIT_WINDOW_MS || String(5 * 60 * 1000), 10);
const rateLimitHits = new Map();
@@ -84,13 +120,66 @@ function isRateLimited(ip) {
return hits.length > RATE_LIMIT_MAX;
}
/* ===== Per-user daily token quota =====
* TOKEN_LIMIT_PER_USER caps combined input+output tokens per identified user,
* per UTC calendar day. Usage is written to TOKEN_USAGE_FILE after every AI
* call, so it survives a container restart. Mount that file's folder as a
* volume in Docker or it resets on every redeploy.
*
* The check runs before the API call using the day's tally so far: if the
* user is already at or over the limit, the call is refused with no cost.
* A single call in progress when the limit is reached can still push the
* tally past the cap by that one call's tokens; the next call is blocked.
* That is an accepted tradeoff, since real token cost of a call is only
* known once its response returns. */
const TOKEN_LIMIT_PER_USER = parseInt(process.env.TOKEN_LIMIT_PER_USER || '50000', 10);
const TOKEN_USAGE_FILE = process.env.TOKEN_USAGE_FILE || path.join(ROOT, 'data', 'token-usage.json');
function todayUTC() {
return new Date().toISOString().slice(0, 10); // "YYYY-MM-DD"
}
function loadUsage() {
try {
return JSON.parse(fs.readFileSync(TOKEN_USAGE_FILE, 'utf8'));
} catch (err) {
return {};
}
}
function saveUsage(usage) {
try {
fs.mkdirSync(path.dirname(TOKEN_USAGE_FILE), { recursive: true });
fs.writeFileSync(TOKEN_USAGE_FILE, JSON.stringify(usage));
} catch (err) {
console.error('Could not write token usage file:', err.message);
}
}
function usedTokensToday(usage, user) {
const rec = usage[user];
if (!rec || rec.date !== todayUTC()) return 0;
return rec.tokens;
}
function addTokens(user, tokens) {
const usage = loadUsage();
const today = todayUTC();
const rec = usage[user] && usage[user].date === today ? usage[user] : { date: today, tokens: 0 };
rec.tokens += tokens;
usage[user] = rec;
saveUsage(usage);
return rec.tokens;
}
function secondsUntilUTCMidnight() {
const now = new Date();
const midnight = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1));
return Math.round((midnight - now) / 1000);
}
/* ===== Mock mode =====
* Set MOCK_AI=true to test the whole app, including the AI draft button,
* with no API key and no real API call. Returns a canned breadcrumb draft
* shaped like a real Anthropic response, so the frontend parsing and gate
* checks run exactly as they would against the live API. Use this for
* testing the login gate, rate limit, and Docker setup without cost. */
function mockClaudeResponse(res) {
* Set MOCK_AI=true to test the whole app, including the AI draft button and
* the token quota above, with no API key and no real API call. Returns a
* canned breadcrumb draft with a synthetic usage count, shaped like a real
* Anthropic response, so the frontend parsing, gate checks, and quota
* accounting all run exactly as they would against the live API. */
function mockClaudeResponse(res, user) {
const draft = {
domain: 'MOCK: Progress Visibility',
driver: 'MOCK driver: schedule forecasts drift from field reality.',
@@ -99,16 +188,19 @@ function mockClaudeResponse(res) {
method: 'MOCK method: monthly spot audit of 30+ sampled assets.',
baseline: 'MOCK baseline: first audit result.'
};
const usage = { input_tokens: 950, output_tokens: 280 };
const body = {
content: [{ type: 'text', text: JSON.stringify(draft) }]
content: [{ type: 'text', text: JSON.stringify(draft) }],
usage
};
addTokens(user, usage.input_tokens + usage.output_tokens);
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify(body));
}
function proxyToClaude(req, res) {
function proxyToClaude(req, res, user) {
if (String(process.env.MOCK_AI).toLowerCase() === 'true') {
mockClaudeResponse(res);
mockClaudeResponse(res, user);
return;
}
if (!process.env.ANTHROPIC_API_KEY) {
@@ -133,6 +225,14 @@ function proxyToClaude(req, res) {
const text = await upstream.text();
res.writeHead(upstream.status, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(text);
try {
const parsed = JSON.parse(text);
if (parsed.usage) {
addTokens(user, (parsed.usage.input_tokens || 0) + (parsed.usage.output_tokens || 0));
}
} catch (err) {
// Response was not JSON, or had no usage field. Nothing to record.
}
} catch (err) {
sendJson(res, 502, { error: 'Could not reach the Anthropic API: ' + err.message });
}
@@ -172,7 +272,8 @@ function serveStatic(req, res) {
}
const server = http.createServer((req, res) => {
if (!checkBasicAuth(req)) {
const user = checkBasicAuth(req);
if (!user) {
requireAuth(res);
return;
}
@@ -182,7 +283,14 @@ const server = http.createServer((req, res) => {
sendJson(res, 429, { error: `Rate limit reached (${RATE_LIMIT_MAX} AI drafts per ${Math.round(RATE_LIMIT_WINDOW_MS / 60000)} min). Wait a bit and try again.` });
return;
}
proxyToClaude(req, res);
const usedToday = usedTokensToday(loadUsage(), user);
if (usedToday >= TOKEN_LIMIT_PER_USER) {
sendJson(res, 429, {
error: `Daily token quota reached (${usedToday}/${TOKEN_LIMIT_PER_USER} tokens for "${user}"). Resets in ${secondsUntilUTCMidnight()}s, at UTC midnight.`
});
return;
}
proxyToClaude(req, res, user);
return;
}
if (req.method === 'GET') {
@@ -200,5 +308,6 @@ server.listen(PORT, () => {
} else {
console.log(process.env.ANTHROPIC_API_KEY ? 'ANTHROPIC_API_KEY loaded: AI draft button is live.' : 'No ANTHROPIC_API_KEY found: AI draft button will return an error until you add one to .env.');
}
console.log((process.env.APP_USERNAME && process.env.APP_PASSWORD) ? 'Login required: APP_USERNAME/APP_PASSWORD are set.' : 'No login required: APP_USERNAME/APP_PASSWORD are not set.');
console.log(AUTH_REQUIRED ? `Login required: ${Object.keys(USER_MAP).length} named user(s) configured.` : 'No login required: no APP_USERS or APP_USERNAME/APP_PASSWORD are set.');
console.log(`Per-user daily token quota: ${TOKEN_LIMIT_PER_USER} tokens. Usage file: ${TOKEN_USAGE_FILE}`);
});