Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions apps/api/src/usage/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Context } from "effect";
import { Effect } from "effect";

import { DeviceMissing } from "@tokenmaxxing/api-contract";
import type { InvalidUsage } from "@tokenmaxxing/api-contract";
import type {
CliIdentity,
RawUsageReportInput,
Expand All @@ -25,6 +26,7 @@ import {
} from "./ccusage";
import { normalizeUsageDays } from "./models";
import type { RawUsageStorageError } from "./raw-store";
import { sanitizeUsageDays } from "./validation";

/**
* Usage ingestion: normalized daily reports are stored first, then current
Expand Down Expand Up @@ -64,13 +66,13 @@ interface UsageServiceShape {
device: UsageDevice,
reports: readonly RawUsageReportInput[],
sourceStats?: readonly SourceUsageStatsInput[],
): Effect.Effect<SyncResult, DeviceMissing, any>;
): Effect.Effect<SyncResult, DeviceMissing | InvalidUsage, any>;
syncBatch(
identity: typeof CliIdentity.Type,
device: UsageDevice,
days: readonly UsageDayInput[],
sourceStats?: readonly SourceUsageStatsInput[],
): Effect.Effect<SyncResult, DeviceMissing, any>;
): Effect.Effect<SyncResult, DeviceMissing | InvalidUsage, any>;
}

interface UsageDevice {
Expand Down Expand Up @@ -341,7 +343,8 @@ function writeStructuredUsage(
coveredDays: readonly CoveredUsageDay[] = [],
) {
return Effect.gen(function* () {
const normalizedDays = normalizeUsageDays(days);
const sanitizedDays = yield* sanitizeUsageDays(days);
const normalizedDays = normalizeUsageDays(sanitizedDays);
for (let offset = 0; offset < normalizedDays.length; offset += UPSERT_CHUNK_SIZE) {
yield* repository
.upsertChunk(
Expand Down
103 changes: 103 additions & 0 deletions apps/api/src/usage/validation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { describe, expect, it } from "vitest";
import { Effect } from "effect";

import { InvalidUsage } from "@tokenmaxxing/api-contract";
import type { UsageDayInput } from "@tokenmaxxing/api-contract";

import { MAX_MODEL_NAME_LENGTH, sanitizeUsageDays } from "./validation";

describe("sanitizeUsageDays", () => {
it("passes through plausible rows and trims model/source whitespace", async () => {
const result = await Effect.runPromise(
sanitizeUsageDays([usageDay({ model: " gpt-5.5 ", source: " codex " })]),
);

expect(result).toEqual([usageDay({ model: "gpt-5.5", source: "codex" })]);
});

it("rejects a fabricated token total above the daily ceiling", async () => {
await expect(
Effect.runPromise(
sanitizeUsageDays([
usageDay({
model: "totally fake model",
totalTokens: 69_000_000_000_000,
inputTokens: 46_000_000_000_000,
outputTokens: 23_000_000_000_000,
costUsd: 420_000_000,
}),
]),
),
).rejects.toBeInstanceOf(InvalidUsage);
});

it("rejects a fabricated cost above the daily ceiling", async () => {
await expect(
Effect.runPromise(sanitizeUsageDays([usageDay({ costUsd: 420_000_000 })])),
).rejects.toBeInstanceOf(InvalidUsage);
});

it("rejects negative and non-finite token counts", async () => {
await expect(
Effect.runPromise(sanitizeUsageDays([usageDay({ totalTokens: -1 })])),
).rejects.toBeInstanceOf(InvalidUsage);

await expect(
Effect.runPromise(sanitizeUsageDays([usageDay({ inputTokens: Number.NaN })])),
).rejects.toBeInstanceOf(InvalidUsage);
});

it("rejects empty, oversized, and control-character model names", async () => {
await expect(
Effect.runPromise(sanitizeUsageDays([usageDay({ model: " " })])),
).rejects.toBeInstanceOf(InvalidUsage);

await expect(
Effect.runPromise(
sanitizeUsageDays([usageDay({ model: "a".repeat(MAX_MODEL_NAME_LENGTH + 1) })]),
),
).rejects.toBeInstanceOf(InvalidUsage);

await expect(
Effect.runPromise(sanitizeUsageDays([usageDay({ model: "bad\u0007name" })])),
).rejects.toBeInstanceOf(InvalidUsage);
});

it("rejects malformed dates", async () => {
await expect(
Effect.runPromise(sanitizeUsageDays([usageDay({ date: "not-a-date" })])),
).rejects.toBeInstanceOf(InvalidUsage);
});

it("accepts a realistic heavy usage day", async () => {
const result = await Effect.runPromise(
sanitizeUsageDays([
usageDay({
costUsd: 42.5,
inputTokens: 10_000_000,
model: "claude-opus-4",
outputTokens: 2_000_000,
source: "claude",
totalTokens: 12_000_000,
}),
]),
);

expect(result).toHaveLength(1);
});
});

function usageDay(overrides: Partial<UsageDayInput> = {}): UsageDayInput {
return {
cacheCreationTokens: 0,
cacheReadTokens: 0,
costUsd: 0,
date: "2026-09-08",
inputTokens: 0,
model: "gpt-5.5",
outputTokens: 0,
source: "codex",
totalTokens: 0,
...overrides,
};
}
101 changes: 101 additions & 0 deletions apps/api/src/usage/validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { Effect } from "effect";

import { InvalidUsage } from "@tokenmaxxing/api-contract";
import type { UsageDayInput } from "@tokenmaxxing/api-contract";

/**
* Server-side sanity ceilings for a single `(date, source, model)` usage row.
* The CLI computes and uploads these aggregates locally; the server previously
* stored them verbatim, so a client could forge arbitrary spend/tokens. These
* bounds reject obviously fabricated rows while leaving generous headroom for
* legitimate (even heavy) coding-agent usage.
*/
const MAX_USAGE_DAY_TOKENS = 1_000_000_000;
const MAX_USAGE_DAY_COST_USD = 100_000;
const MAX_MODEL_NAME_LENGTH = 200;
const MAX_SOURCE_LENGTH = 64;
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;

function sanitizeUsageDays(
days: readonly UsageDayInput[],
): Effect.Effect<UsageDayInput[], InvalidUsage> {
return Effect.gen(function* () {
const sanitized: UsageDayInput[] = [];

for (let index = 0; index < days.length; index += 1) {
const day = days[index]!;
const model = day.model.trim();
const source = day.source.trim();

if (model.length === 0) {
yield* reject(index, "model name is empty");
}
if (model.length > MAX_MODEL_NAME_LENGTH) {
yield* reject(index, `model name exceeds ${MAX_MODEL_NAME_LENGTH} characters`);
}
if (hasControlCharacters(model)) {
yield* reject(index, "model name contains control characters");
}
if (
source.length === 0 ||
source.length > MAX_SOURCE_LENGTH ||
hasControlCharacters(source)
) {
yield* reject(index, "source is empty, oversized, or contains control characters");
}
if (!DATE_PATTERN.test(day.date)) {
yield* reject(index, `date must be YYYY-MM-DD, got ${JSON.stringify(day.date)}`);
}

for (const [label, value] of tokenFields(day)) {
if (!Number.isFinite(value) || value < 0) {
yield* reject(index, `${label} must be finite and non-negative`);
}
if (value > MAX_USAGE_DAY_TOKENS) {
yield* reject(index, `${label} ${value} exceeds daily ceiling ${MAX_USAGE_DAY_TOKENS}`);
}
}

if (!Number.isFinite(day.costUsd) || day.costUsd < 0) {
yield* reject(index, "costUsd must be finite and non-negative");
}
if (day.costUsd > MAX_USAGE_DAY_COST_USD) {
yield* reject(
index,
`costUsd ${day.costUsd} exceeds daily ceiling ${MAX_USAGE_DAY_COST_USD}`,
);
}

sanitized.push({ ...day, model, source });
}

return sanitized;
});
}

function tokenFields(day: UsageDayInput): ReadonlyArray<[string, number]> {
return [
["inputTokens", day.inputTokens],
["outputTokens", day.outputTokens],
["cacheCreationTokens", day.cacheCreationTokens],
["cacheReadTokens", day.cacheReadTokens],
["totalTokens", day.totalTokens],
];
}

function reject(index: number, reason: string): Effect.Effect<never, InvalidUsage> {
return Effect.fail(new InvalidUsage({ message: `usage day ${index}: ${reason}` }));
}

function hasControlCharacters(value: string): boolean {
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index);
if (code <= 0x1f || code === 0x7f) {
return true;
}
}

return false;
}

export { MAX_MODEL_NAME_LENGTH, MAX_USAGE_DAY_COST_USD, MAX_USAGE_DAY_TOKENS, sanitizeUsageDays };
3 changes: 2 additions & 1 deletion apps/cli/src/commands/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,7 @@ describe("servicePaths", () => {
backend: "launchd",
configDir: "/tmp/tokenmaxxing",
definitionPath: "/Users/alex/Library/LaunchAgents/sh.tokenmaxxing.sync.plist",
launcherPath: null,
lockPath: "/tmp/tokenmaxxing/service.lock",
logPath: "/tmp/tokenmaxxing/service.log",
metadataPath: "/tmp/tokenmaxxing/service.json",
Expand Down Expand Up @@ -600,7 +601,7 @@ describe("native scheduler templates", () => {
"/MO",
"5",
"/TR",
'"C:\\Users\\alex\\AppData\\Roaming\\tokenmaxxing/service-sync.cmd"',
'"wscript.exe \\"C:\\Users\\alex\\AppData\\Roaming\\tokenmaxxing/service-sync.vbs\\""',
"/F",
]);
});
Expand Down
26 changes: 25 additions & 1 deletion apps/cli/src/commands/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ const WINDOWS_TASK_NAME = "tokenmaxxing-sync";
const POSIX_WRAPPER_NAME = "tokenmaxxing.sh";
const LEGACY_POSIX_WRAPPER_NAME = "service-sync.sh";
const WINDOWS_WRAPPER_NAME = "service-sync.cmd";
const WINDOWS_LAUNCHER_NAME = "service-sync.vbs";
const PACKAGE_NAME = "@851-labs/tokenmaxxing";
const SERVICE_RUNNER_DIR_NAME = "service-runners";
const SERVICE_RUNNER_POINTER_NAME = "service-runner-current";
Expand Down Expand Up @@ -120,6 +121,7 @@ interface ServicePaths {
backend: ServiceBackend;
configDir: string;
definitionPath: string | null;
launcherPath: string | null;
lockPath: string;
logPath: string;
metadataPath: string;
Expand Down Expand Up @@ -3418,6 +3420,7 @@ function servicePaths({
backend,
configDir,
definitionPath: join(home, "Library", "LaunchAgents", `${SERVICE_LABEL}.plist`),
launcherPath: null,
lockPath,
logPath,
metadataPath,
Expand All @@ -3435,6 +3438,7 @@ function servicePaths({
backend,
configDir,
definitionPath: join(systemdDir, `${SYSTEMD_NAME}.service`),
launcherPath: null,
lockPath,
logPath,
metadataPath,
Expand All @@ -3450,6 +3454,7 @@ function servicePaths({
backend,
configDir,
definitionPath: null,
launcherPath: join(configDir, WINDOWS_LAUNCHER_NAME),
lockPath,
logPath,
metadataPath,
Expand Down Expand Up @@ -3905,6 +3910,14 @@ exit /b %ERRORLEVEL%\r
`;
}

function renderWindowsLauncher(wrapperPath: string): string {
return `CreateObject("WScript.Shell").Run ${vbsString(wrapperPath)}, 0, True\r\n`;
}

function vbsString(value: string): string {
return `"${value.replaceAll('"', '""')}"`;
}

function renderWindowsLogRotation(logPath: string): string {
const quotedLogPath = cmdQuote(logPath);
const moves = Array.from({ length: SERVICE_LOG_ROTATIONS - 1 }, (_, index) => {
Expand Down Expand Up @@ -3986,6 +3999,9 @@ function writeServiceFiles(
if (paths.backend !== "windows-task-scheduler") {
await chmod(paths.wrapperPath, 0o755);
}
if (paths.backend === "windows-task-scheduler" && paths.launcherPath !== null) {
await writeFileAtomic(paths.launcherPath, renderWindowsLauncher(paths.wrapperPath));
}
await writeFileAtomic(paths.metadataPath, `${JSON.stringify(metadata, null, 2)}\n`);

if (paths.backend === "launchd" && paths.definitionPath !== null) {
Expand All @@ -4004,6 +4020,9 @@ function removeServiceFiles(paths: ServicePaths): Effect.Effect<void, unknown> {
return Effect.tryPromise({
try: async () => {
await rm(paths.wrapperPath, { force: true });
if (paths.launcherPath !== null) {
await rm(paths.launcherPath, { force: true });
}
for (const legacyWrapperPath of legacyServiceWrapperPaths(paths)) {
await rm(legacyWrapperPath, { force: true });
}
Expand Down Expand Up @@ -4062,6 +4081,11 @@ function installNativeScheduler(paths: ServicePaths): Effect.Effect<void, unknow
}

function windowsTaskCreateArgs(paths: ServicePaths): string[] {
const taskRun =
paths.launcherPath === null
? cmdQuote(paths.wrapperPath)
: cmdQuote(`wscript.exe ${cmdQuote(paths.launcherPath)}`);

return [
"/Create",
"/TN",
Expand All @@ -4071,7 +4095,7 @@ function windowsTaskCreateArgs(paths: ServicePaths): string[] {
"/MO",
String(SERVICE_INTERVAL_MINUTES),
"/TR",
cmdQuote(paths.wrapperPath),
taskRun,
"/F",
];
}
Expand Down
5 changes: 3 additions & 2 deletions packages/api-contract/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
DeviceNotFound,
DeviceMissing,
Forbidden,
InvalidUsage,
LoginCodeExpired,
LoginCodeNotFound,
TokenNotFound,
Expand Down Expand Up @@ -132,7 +133,7 @@ class UsageGroup extends HttpApiGroup.make("usage")
HttpApiEndpoint.post("ingest", "/usage/ingest", {
payload: IngestUsageInput,
success: SyncUsageResponse,
error: DeviceMissing,
error: [DeviceMissing, InvalidUsage],
}),
)
.add(
Expand All @@ -141,7 +142,7 @@ class UsageGroup extends HttpApiGroup.make("usage")
HttpApiEndpoint.post("sync", "/usage/sync", {
payload: SyncUsageInput,
success: SyncUsageResponse,
error: DeviceMissing,
error: [DeviceMissing, InvalidUsage],
}),
)
.add(
Expand Down
Loading