diff --git a/apps/api/src/usage/service.ts b/apps/api/src/usage/service.ts index 2b34ff4..6907401 100644 --- a/apps/api/src/usage/service.ts +++ b/apps/api/src/usage/service.ts @@ -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, @@ -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 @@ -64,13 +66,13 @@ interface UsageServiceShape { device: UsageDevice, reports: readonly RawUsageReportInput[], sourceStats?: readonly SourceUsageStatsInput[], - ): Effect.Effect; + ): Effect.Effect; syncBatch( identity: typeof CliIdentity.Type, device: UsageDevice, days: readonly UsageDayInput[], sourceStats?: readonly SourceUsageStatsInput[], - ): Effect.Effect; + ): Effect.Effect; } interface UsageDevice { @@ -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( diff --git a/apps/api/src/usage/validation.test.ts b/apps/api/src/usage/validation.test.ts new file mode 100644 index 0000000..a3d15c7 --- /dev/null +++ b/apps/api/src/usage/validation.test.ts @@ -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 { + return { + cacheCreationTokens: 0, + cacheReadTokens: 0, + costUsd: 0, + date: "2026-09-08", + inputTokens: 0, + model: "gpt-5.5", + outputTokens: 0, + source: "codex", + totalTokens: 0, + ...overrides, + }; +} diff --git a/apps/api/src/usage/validation.ts b/apps/api/src/usage/validation.ts new file mode 100644 index 0000000..e36f112 --- /dev/null +++ b/apps/api/src/usage/validation.ts @@ -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 { + 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 { + 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 }; diff --git a/apps/cli/src/commands/service.test.ts b/apps/cli/src/commands/service.test.ts index ae5c288..5ac448c 100644 --- a/apps/cli/src/commands/service.test.ts +++ b/apps/cli/src/commands/service.test.ts @@ -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", @@ -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", ]); }); diff --git a/apps/cli/src/commands/service.ts b/apps/cli/src/commands/service.ts index 84cce4e..acf366a 100644 --- a/apps/cli/src/commands/service.ts +++ b/apps/cli/src/commands/service.ts @@ -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"; @@ -120,6 +121,7 @@ interface ServicePaths { backend: ServiceBackend; configDir: string; definitionPath: string | null; + launcherPath: string | null; lockPath: string; logPath: string; metadataPath: string; @@ -3418,6 +3420,7 @@ function servicePaths({ backend, configDir, definitionPath: join(home, "Library", "LaunchAgents", `${SERVICE_LABEL}.plist`), + launcherPath: null, lockPath, logPath, metadataPath, @@ -3435,6 +3438,7 @@ function servicePaths({ backend, configDir, definitionPath: join(systemdDir, `${SYSTEMD_NAME}.service`), + launcherPath: null, lockPath, logPath, metadataPath, @@ -3450,6 +3454,7 @@ function servicePaths({ backend, configDir, definitionPath: null, + launcherPath: join(configDir, WINDOWS_LAUNCHER_NAME), lockPath, logPath, metadataPath, @@ -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) => { @@ -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) { @@ -4004,6 +4020,9 @@ function removeServiceFiles(paths: ServicePaths): Effect.Effect { 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 }); } @@ -4062,6 +4081,11 @@ function installNativeScheduler(paths: ServicePaths): Effect.Effect()( { httpApiStatus: 400 }, ) {} +class InvalidUsage extends Schema.TaggedErrorClass()( + "InvalidUsage", + { message: Schema.String }, + { httpApiStatus: 400 }, +) {} + export { AdminUserNotFound, DeviceNotFound, DeviceMissing, Forbidden, + InvalidUsage, LoginCodeExpired, LoginCodeNotFound, TokenNotFound,