diff --git a/apps/sdp-api/scripts/build-node.mjs b/apps/sdp-api/scripts/build-node.mjs index 05324da5e4..9fe6ec2158 100644 --- a/apps/sdp-api/scripts/build-node.mjs +++ b/apps/sdp-api/scripts/build-node.mjs @@ -78,6 +78,8 @@ const entryPoints = { configure: "scripts/configure.ts", // ephemeral-db.js creates/drops per-PR databases from the prebuilt image (PRO-1767). "ephemeral-db": "scripts/ephemeral-db.mjs", + // sponsorship-budget.js is the operator CLI for sponsorship budget policies, run as a Cloud Run job. + "sponsorship-budget": "scripts/sponsorship-budget.ts", }; const zkSdkNodeEntries = new Set(); diff --git a/apps/sdp-api/scripts/lib/sponsorship-budget-operator.ts b/apps/sdp-api/scripts/lib/sponsorship-budget-operator.ts new file mode 100644 index 0000000000..0eaeec6c77 --- /dev/null +++ b/apps/sdp-api/scripts/lib/sponsorship-budget-operator.ts @@ -0,0 +1,285 @@ +import { SDP_ENVIRONMENTS } from "@sdp/types/api-keys"; +import { CLUSTER_BY_SDP_ENVIRONMENT } from "@sdp/types/well-known-tokens"; +import { z } from "zod"; +import { + type SponsorshipNetwork, + sponsorshipNetworkForCluster, +} from "@/db/repositories/sponsorship-budget.repository"; + +const MAX_REDIS_LUA_SAFE_LAMPORTS = BigInt(Number.MAX_SAFE_INTEGER); +const CANONICAL_NON_NEGATIVE_INTEGER = /^(0|[1-9][0-9]*)$/; + +export interface SponsorshipPolicyLimitsBigInt { + perTransactionLamports: bigint; + hourlyLamports: bigint; + dailyLamports: bigint; +} + +export function parseLamportAmount(value: string | undefined, flagName: string): bigint { + if (!value || !CANONICAL_NON_NEGATIVE_INTEGER.test(value)) { + throw new Error(`--${flagName} must be a canonical non-negative integer`); + } + return BigInt(value); +} + +export function parseSponsorshipPolicyLimits(input: { + perTransactionLamports: string | undefined; + hourlyLamports: string | undefined; + dailyLamports: string | undefined; +}): SponsorshipPolicyLimitsBigInt { + const limits = { + perTransactionLamports: parseLamportAmount(input.perTransactionLamports, "per-tx-lamports"), + hourlyLamports: parseLamportAmount(input.hourlyLamports, "hourly-lamports"), + dailyLamports: parseLamportAmount(input.dailyLamports, "daily-lamports"), + }; + if (limits.hourlyLamports < limits.perTransactionLamports) { + throw new Error("--hourly-lamports must be at least --per-tx-lamports"); + } + if (limits.dailyLamports < limits.hourlyLamports) { + throw new Error("--daily-lamports must be at least --hourly-lamports"); + } + return limits; +} + +/** Convert only at the DB/Redis-Lua boundary, after all bigint validation. */ +export function toRedisLuaSafePolicyLimits(limits: SponsorshipPolicyLimitsBigInt): { + perTransactionLamports: number; + hourlyLamports: number; + dailyLamports: number; +} { + for (const [name, value] of Object.entries(limits)) { + if (value > MAX_REDIS_LUA_SAFE_LAMPORTS) { + throw new Error(`${name} exceeds the Redis-Lua safe integer range`); + } + } + return { + perTransactionLamports: Number(limits.perTransactionLamports), + hourlyLamports: Number(limits.hourlyLamports), + dailyLamports: Number(limits.dailyLamports), + }; +} + +export const SPONSORSHIP_BUDGET_COMMANDS = [ + "status", + "set-project", + "set-project-default", + "set-global", + "kill", + "resume", +] as const; + +/** `preview` resolves the target and prints the change without writing; `apply` writes it. */ +export const SPONSORSHIP_BUDGET_WRITE_MODES = ["preview", "apply"] as const; + +export type SponsorshipBudgetWriteMode = (typeof SPONSORSHIP_BUDGET_WRITE_MODES)[number]; + +const requiredText = (flag: string) => + z + .string({ error: `--${flag} is required` }) + .trim() + .min(1, `--${flag} cannot be empty`) + .max(500, `--${flag} must be at most 500 characters`); + +const writeFlagsSchema = z.strictObject({ + environment: z.enum(SDP_ENVIRONMENTS, { error: "--environment must be sandbox or production" }), + mode: z.enum(SPONSORSHIP_BUDGET_WRITE_MODES, { error: "--mode must be preview or apply" }), + operator: requiredText("operator"), + reason: requiredText("reason"), +}); + +const expectedVersionSchema = z + .string() + .regex(CANONICAL_NON_NEGATIVE_INTEGER, "--expected-version must be a non-negative integer") + .transform(Number) + .pipe(z.number().int().max(Number.MAX_SAFE_INTEGER)) + .optional(); + +const policyWriteFlagsSchema = writeFlagsSchema.extend({ + "per-tx-lamports": z.string().optional(), + "hourly-lamports": z.string().optional(), + "daily-lamports": z.string().optional(), + enabled: z.enum(["true", "false"], { error: "--enabled must be true or false" }), + "expected-version": expectedVersionSchema, +}); + +const sponsorshipBudgetCommandSchema = z.discriminatedUnion( + "command", + [ + z.strictObject({ + command: z.literal("status"), + environment: z.enum(SDP_ENVIRONMENTS, { + error: "--environment must be sandbox or production", + }), + }), + policyWriteFlagsSchema.extend({ + command: z.literal("set-project"), + "organization-id": requiredText("organization-id"), + }), + policyWriteFlagsSchema.extend({ command: z.literal("set-project-default") }), + policyWriteFlagsSchema.extend({ command: z.literal("set-global") }), + writeFlagsSchema.extend({ command: z.literal("kill") }), + writeFlagsSchema.extend({ + command: z.literal("resume"), + "expected-version": expectedVersionSchema, + }), + ], + { error: `Usage: sponsorship:budget <${SPONSORSHIP_BUDGET_COMMANDS.join("|")}> [flags]` } +); + +/** + * The policy version a write is bound to. An apply always names the version the + * operator (or the workflow's preview) read; a preview may omit it and reports + * the current version instead. + */ +export type SponsorshipWriteVersion = + | { mode: "preview"; expectedVersion: number | null } + | { mode: "apply"; expectedVersion: number }; + +export interface SponsorshipPolicyWrite { + enabled: boolean; + perTransactionLamports: number; + hourlyLamports: number; + dailyLamports: number; +} + +interface SponsorshipOperatorAttribution { + network: SponsorshipNetwork; + operator: string; + reason: string; +} + +export type SponsorshipBudgetCommand = + | { command: "status"; network: SponsorshipNetwork } + | ({ + command: "set-project"; + environment: (typeof SDP_ENVIRONMENTS)[number]; + organizationId: string; + } & SponsorshipOperatorAttribution & + SponsorshipPolicyWrite & + SponsorshipWriteVersion) + | ({ command: "set-project-default" | "set-global" } & SponsorshipOperatorAttribution & + SponsorshipPolicyWrite & + SponsorshipWriteVersion) + | ({ command: "kill" } & SponsorshipOperatorAttribution & + ({ mode: "preview" } | { mode: "apply" })) + | ({ command: "resume" } & SponsorshipOperatorAttribution & SponsorshipWriteVersion); + +/** + * Bind a write to the version it was computed from. An apply without one is + * refused: it could overwrite a change (a breaker trip in particular) that its + * operator never saw. + * + * @param mode - Whether the write previews or applies. + * @param expectedVersion - The version given with `--expected-version`, if any. + * @returns The mode and version, with the version required for an apply. + */ +function writeVersion( + mode: SponsorshipBudgetWriteMode, + expectedVersion: number | undefined +): SponsorshipWriteVersion { + if (mode === "preview") { + return { mode, expectedVersion: expectedVersion === undefined ? null : expectedVersion }; + } + if (expectedVersion === undefined) { + throw new Error( + "--expected-version is required with --mode apply (0 when the policy does not exist yet)" + ); + } + return { mode, expectedVersion }; +} + +/** + * The budget network an SDP environment's projects draw sponsorship from. + * + * @param environment - The SDP environment the operator named. + * @returns The budget network of that environment's cluster. + */ +export function sponsorshipNetworkForEnvironment( + environment: (typeof SDP_ENVIRONMENTS)[number] +): SponsorshipNetwork { + return sponsorshipNetworkForCluster(CLUSTER_BY_SDP_ENVIRONMENT[environment]); +} + +/** + * Parse the operator CLI's command and flags. Every write names its environment, + * mode, operator and reason; every apply that can enable sponsorship also names the + * policy version its preview read, so it refuses to overwrite a change it did + * not see (a breaker trip in particular). `kill` takes no version: disabling is + * always safe to apply over a concurrent change. + * + * @param input - The positional command and the `--flag value` pairs. + * @param input.command - The positional command, or undefined when none was given. + * @param input.flags - Flag values keyed by flag name without the leading dashes. + * @returns The validated command with its environment resolved to a budget network. + */ +export function parseSponsorshipBudgetCommand(input: { + command: string | undefined; + flags: Record; +}): SponsorshipBudgetCommand { + const parsed = sponsorshipBudgetCommandSchema.safeParse({ + ...input.flags, + command: input.command, + }); + if (!parsed.success) { + throw new Error(parsed.error.issues.map((issue) => issue.message).join("; ")); + } + const args = parsed.data; + const network = sponsorshipNetworkForEnvironment(args.environment); + switch (args.command) { + case "status": + return { command: "status", network }; + case "kill": + return { + command: "kill", + network, + mode: args.mode, + operator: args.operator, + reason: args.reason, + }; + case "resume": + return { + command: "resume", + network, + operator: args.operator, + reason: args.reason, + ...writeVersion(args.mode, args["expected-version"]), + }; + case "set-project": + case "set-project-default": + case "set-global": { + const write = { + network, + operator: args.operator, + reason: args.reason, + enabled: args.enabled === "true", + ...writeVersion(args.mode, args["expected-version"]), + ...toRedisLuaSafePolicyLimits( + parseSponsorshipPolicyLimits({ + perTransactionLamports: args["per-tx-lamports"], + hourlyLamports: args["hourly-lamports"], + dailyLamports: args["daily-lamports"], + }) + ), + }; + return args.command === "set-project" + ? { + ...write, + command: "set-project", + environment: args.environment, + organizationId: args["organization-id"], + } + : { ...write, command: args.command }; + } + default: { + const unreachable: never = args; + throw new Error(`Unknown sponsorship budget command: ${JSON.stringify(unreachable)}`); + } + } +} + +export function isPolicyControlInSync( + policy: { version: number; enabled: boolean }, + control: { version: number; enabled: boolean } | null +): boolean { + return control?.version === policy.version && control.enabled === policy.enabled; +} diff --git a/apps/sdp-api/scripts/sponsorship-budget.ts b/apps/sdp-api/scripts/sponsorship-budget.ts index 82fc90494a..d2e2531a79 100644 --- a/apps/sdp-api/scripts/sponsorship-budget.ts +++ b/apps/sdp-api/scripts/sponsorship-budget.ts @@ -1,129 +1,461 @@ -import { closeDatabasePools, getDb } from "@/db/client"; +import { parseArgs } from "node:util"; +import type { Project } from "@sdp/types/projects"; +import { z } from "zod"; +import { closeDatabasePools, type DatabaseClient, getDb } from "@/db/client"; import { runWithSystemDatabaseIdentity } from "@/db/identity"; import { + type SponsorshipBudgetPolicy, SponsorshipBudgetRepository, + type SponsorshipBudgetScopeType, type SponsorshipNetwork, } from "@/db/repositories/sponsorship-budget.repository"; import { getProcessEnv } from "@/lib/runtime-env"; +import { closeAllRedisClients } from "@/runtime/kv-redis"; import { SponsorshipBudgetRedis } from "@/runtime/sponsorship-budget-redis"; import { isPolicyControlInSync, - parseSponsorshipNetwork, - parseSponsorshipPolicyLimits, - parseSponsorshipScope, - toRedisLuaSafePolicyLimits, -} from "@/services/sponsorship-budget-operator"; - -function readArg(name: string): string | undefined { - const index = process.argv.indexOf(`--${name}`); - if (index === -1) return undefined; - const value = process.argv[index + 1]; - if (value === undefined || value.startsWith("--")) { - throw new Error(`--${name} requires a value`); - } - return value; + parseSponsorshipBudgetCommand, + type SponsorshipBudgetCommand, +} from "@/scripts/lib/sponsorship-budget-operator"; +import { ProjectService } from "@/services/project.service"; + +const FLAG_NAMES = [ + "environment", + "mode", + "organization-id", + "per-tx-lamports", + "hourly-lamports", + "daily-lamports", + "enabled", + "expected-version", + "operator", + "reason", +] as const; + +const organizationRowSchema = z.object({ + id: z.string(), + name: z.string(), + slug: z.string(), + status: z.string(), +}); + +type OrganizationSummary = z.infer; + +interface ProjectSummary { + id: string; + environment: string; + status: string; } -function requireArg(name: string): string { - const value = readArg(name); - if (!value) throw new Error(`--${name} is required`); - return value; +interface PolicyTarget { + network: SponsorshipNetwork; + scopeType: SponsorshipBudgetScopeType; + scopeId: string | null; + organization: OrganizationSummary | null; + project: ProjectSummary | null; } -function parseNetwork(): SponsorshipNetwork { - return parseSponsorshipNetwork(readArg("network")); +interface ProposedPolicy { + enabled: boolean; + perTransactionLamports: number | null; + hourlyLamports: number | null; + dailyLamports: number | null; } -async function persistGlobalEnabled(enabled: boolean) { - const env = getProcessEnv(); - const repository = new SponsorshipBudgetRepository(getDb(env)); - const network = parseNetwork(); - const policy = await repository.setPolicyEnabled({ - network, - scopeType: "global", - scopeId: null, - enabled, - operator: requireArg("operator"), - reason: requireArg("reason"), +interface PolicyChangePlan { + command: Exclude; + mode: "preview" | "apply"; + operator: string; + reason: string; + target: PolicyTarget; + current: SponsorshipBudgetPolicy | null; + inheritedDefault: SponsorshipBudgetPolicy | null; + proposed: ProposedPolicy; + expectedVersion: number | null; + currentVersion: number; + result: SponsorshipBudgetPolicy | null; +} + +interface SponsorshipPolicyStatus extends SponsorshipBudgetPolicy { + organization: OrganizationSummary | null; + project: ProjectSummary | null; + redisControl: { version: number; enabled: boolean } | null; + inSync: boolean; +} + +/** + * Read the positional command and every `--flag value` pair, refusing unknown flags. + * + * @returns The command and the flags that were given. + */ +function readCommandLine(): { command: string | undefined; flags: Record } { + const { positionals, values } = parseArgs({ + args: process.argv.slice(2), + allowPositionals: true, + strict: true, + options: Object.fromEntries(FLAG_NAMES.map((name) => [name, { type: "string" as const }])), }); - if (!policy) { - const current = (await repository.listPolicies(network)).find( - (candidate) => candidate.scopeType === "global" && candidate.scopeId === null + if (positionals.length > 1) { + throw new Error(`Expected one command, got: ${positionals.join(" ")}`); + } + const flags: Record = {}; + for (const [name, value] of Object.entries(values)) { + if (value !== undefined) flags[name] = value; + } + return { command: positionals[0], flags }; +} + +/** + * Read the organization an operator named, so every change prints who it caps. + * + * @param db - The database client. + * @param organizationId - The organization id. + * @returns The organization's id, name, slug and status, or null when no such organization exists. + */ +async function getOrganizationSummary( + db: DatabaseClient, + organizationId: string +): Promise { + const row = await db + .prepare("SELECT id, name, slug, status FROM organizations WHERE id = ?") + .bind(organizationId) + .first(); + return row === null ? null : organizationRowSchema.parse(row); +} + +/** + * Summarize a project for output. + * + * @param project - The project. + * @returns Its id, environment and status. + */ +function projectSummary(project: Project): ProjectSummary { + return { id: project.id, environment: project.environment, status: project.status }; +} + +/** + * Mirror a committed policy into Redis. Admission reads `enabled` from Postgres + * and re-syncs every policy it resolves, so a failed sync here leaves the + * committed change in force; the operator re-runs `status` to confirm. + * + * @param redis - The budget's Redis store. + * @param policy - The policy just committed. + * @returns The committed policy. + */ +async function syncCommitted( + redis: SponsorshipBudgetRedis, + policy: SponsorshipBudgetPolicy +): Promise { + try { + await redis.syncPolicy(policy); + } catch (error) { + throw new Error( + `Policy ${policy.id} is committed at version ${policy.version}, but the Redis sync failed; run status to confirm`, + { cause: error } ); - if (!current) { - throw new Error("No matching policy exists; use `set` with explicit limits first"); + } + return policy; +} + +/** + * Find the policy row a target names, if it exists. + * + * @param policies - The network's policies. + * @param scopeType - The policy level. + * @param scopeId - The project id, or null for the global row or the project default. + * @returns The matching policy, or null. + */ +function findPolicy( + policies: SponsorshipBudgetPolicy[], + scopeType: SponsorshipBudgetScopeType, + scopeId: string | null +): SponsorshipBudgetPolicy | null { + return ( + policies.find((policy) => policy.scopeType === scopeType && policy.scopeId === scopeId) ?? null + ); +} + +/** + * Describe a policy's version for a refusal message. + * + * @param policy - The policy as it stands, or null when it does not exist. + * @returns A one-line description of its version and last change. + */ +function describeVersion(policy: SponsorshipBudgetPolicy | null): string { + return policy === null + ? "no policy (version 0)" + : `version ${policy.version} (enabled=${policy.enabled}, last changed by ${policy.updatedBy}: ${policy.updateReason})`; +} + +/** + * Resolve where a write lands: the organization and its active project in the + * environment for `set-project`, or the network-wide row otherwise. + * + * @param db - The database client. + * @param projects - The project service. + * @param command - The write command. + * @returns The target scope with the organization and project it names. + */ +async function resolveTarget( + db: DatabaseClient, + projects: ProjectService, + command: Exclude +): Promise { + switch (command.command) { + case "set-project": { + const organization = await getOrganizationSummary(db, command.organizationId); + if (!organization) throw new Error(`Organization ${command.organizationId} does not exist`); + const project = (await projects.listProjects(command.organizationId)).find( + (candidate) => candidate.environment === command.environment + ); + if (!project) { + throw new Error( + `Organization ${organization.name} (${organization.id}) has no active ${command.environment} project` + ); + } + return { + network: command.network, + scopeType: "project", + scopeId: project.id, + organization, + project: projectSummary(project), + }; + } + case "set-project-default": + return { + network: command.network, + scopeType: "project", + scopeId: null, + organization: null, + project: null, + }; + case "set-global": + case "kill": + case "resume": + return { + network: command.network, + scopeType: "global", + scopeId: null, + organization: null, + project: null, + }; + default: { + const unreachable: never = command; + throw new Error(`Unknown sponsorship budget command: ${JSON.stringify(unreachable)}`); } - console.log(JSON.stringify(current, null, 2)); - return; } - await new SponsorshipBudgetRedis(env).syncPolicy(policy); - console.log(JSON.stringify(policy, null, 2)); } -async function main() { - const command = process.argv[2]; - const env = getProcessEnv(); - const repository = new SponsorshipBudgetRepository(getDb(env)); - - if (command === "status") { - const network = parseNetwork(); - const policies = await repository.listPolicies(network); - const redis = new SponsorshipBudgetRedis(env); - const status = await Promise.all( - policies.map(async (policy) => { +/** + * The policy a command proposes for its target. + * + * @param command - The write command. + * @param current - The target policy as it stands, or null when it does not exist. + * @returns The enabled flag and limits the command would write. + */ +function proposedPolicy( + command: Exclude, + current: SponsorshipBudgetPolicy | null +): ProposedPolicy { + switch (command.command) { + case "set-project": + case "set-project-default": + case "set-global": + return { + enabled: command.enabled, + perTransactionLamports: command.perTransactionLamports, + hourlyLamports: command.hourlyLamports, + dailyLamports: command.dailyLamports, + }; + case "kill": + case "resume": + return { + enabled: command.command === "resume", + perTransactionLamports: current === null ? null : current.perTransactionLamports, + hourlyLamports: current === null ? null : current.hourlyLamports, + dailyLamports: current === null ? null : current.dailyLamports, + }; + default: { + const unreachable: never = command; + throw new Error(`Unknown sponsorship budget command: ${JSON.stringify(unreachable)}`); + } + } +} + +/** + * Apply a planned change. Every write except `kill` is a compare-and-set on the + * version the operator read; `kill` stamps the operator over any disable so the + * breaker's auto-recovery never undoes it. + * + * @param repository - The budget repository. + * @param redis - The budget's Redis store. + * @param command - The write command. + * @param target - Where the write lands. + * @returns The policy as written, or the unchanged policy when a kill found it already killed. + */ +async function applyChange( + repository: SponsorshipBudgetRepository, + redis: SponsorshipBudgetRedis, + command: Extract, + target: PolicyTarget +): Promise { + switch (command.command) { + case "set-project": + case "set-project-default": + case "set-global": { + const policy = await repository.writePolicyAtVersion({ + ...command, + scopeType: target.scopeType, + scopeId: target.scopeId, + }); + if (!policy) { + const current = findPolicy( + await repository.listPolicies(target.network), + target.scopeType, + target.scopeId + ); + throw new Error( + `Refused: expected version ${command.expectedVersion}, found ${describeVersion(current)}. Run status and retry.` + ); + } + return syncCommitted(redis, policy); + } + case "kill": { + const policy = await repository.setPolicyEnabled({ + network: command.network, + scopeType: "global", + scopeId: null, + enabled: false, + operator: command.operator, + reason: command.reason, + }); + if (policy) return syncCommitted(redis, policy); + const current = await repository.getGlobalPolicy(command.network); + if (!current) throw new Error(`No global ${command.network} policy exists`); + return current; + } + case "resume": { + const policy = await repository.resumeGlobalPolicyAtVersion(command); + if (!policy) { + const current = await repository.getGlobalPolicy(command.network); + throw new Error( + `Refused: resume expected a disabled global policy at version ${command.expectedVersion}, found ${describeVersion(current)}. Run status and retry.` + ); + } + return syncCommitted(redis, policy); + } + default: { + const unreachable: never = command; + throw new Error(`Unknown sponsorship budget command: ${JSON.stringify(unreachable)}`); + } + } +} + +/** + * Plan a write, and apply it when the mode is `apply`. A preview refuses a + * stale expected version too, so an approver is never asked to approve a run + * that would be refused. + * + * @param db - The database client. + * @param repository - The budget repository. + * @param redis - The budget's Redis store. + * @param command - The write command. + * @returns The plan, with the written policy when applied. + */ +async function planAndApply( + db: DatabaseClient, + repository: SponsorshipBudgetRepository, + redis: SponsorshipBudgetRedis, + command: Exclude +): Promise { + const target = await resolveTarget(db, new ProjectService(db), command); + const policies = await repository.listPolicies(target.network); + const current = findPolicy(policies, target.scopeType, target.scopeId); + const inheritedDefault = + current === null && target.scopeType === "project" && target.scopeId !== null + ? findPolicy(policies, "project", null) + : null; + const expectedVersion = command.command === "kill" ? null : command.expectedVersion; + if (expectedVersion !== null && (current === null ? 0 : current.version) !== expectedVersion) { + throw new Error( + `Refused: expected version ${expectedVersion}, found ${describeVersion(current)}. Run status and retry.` + ); + } + const plan: PolicyChangePlan = { + command: command.command, + mode: command.mode, + operator: command.operator, + reason: command.reason, + target, + current, + inheritedDefault, + proposed: proposedPolicy(command, current), + expectedVersion, + currentVersion: current === null ? 0 : current.version, + result: null, + }; + if (command.mode === "preview") return plan; + return { ...plan, result: await applyChange(repository, redis, command, target) }; +} + +/** + * List a network's policies with the organization and project each override + * caps. Each policy is first mirrored into Redis (forward-only by version, as + * admission does), so `inSync` false means Redis holds a newer version than + * Postgres, never merely a row no sponsorship has touched yet. + * + * @param db - The database client. + * @param repository - The budget repository. + * @param redis - The budget's Redis store. + * @param network - The budget network. + * @returns Every policy on the network, global first. + */ +async function status( + db: DatabaseClient, + repository: SponsorshipBudgetRepository, + redis: SponsorshipBudgetRedis, + network: SponsorshipNetwork +): Promise { + const projects = new ProjectService(db); + return Promise.all( + (await repository.listPolicies(network)).map( + async (policy): Promise => { + await redis.syncPolicy(policy); const control = await redis.getPolicyControl(policy); + const project = policy.scopeId === null ? null : await projects.getProject(policy.scopeId); return { ...policy, + organization: + project === null ? null : await getOrganizationSummary(db, project.organizationId), + project: project === null ? null : projectSummary(project), redisControl: control, inSync: isPolicyControlInSync(policy, control), }; - }) - ); - console.log(JSON.stringify(status, null, 2)); - if (status.some((policy) => !policy.inSync)) process.exitCode = 2; - return; - } - if (command === "kill") { - await persistGlobalEnabled(false); - return; - } - if (command === "resume") { - await persistGlobalEnabled(true); - return; - } - if (command !== "set") { - throw new Error("Usage: sponsorship:budget [flags]"); - } - - const network = parseNetwork(); - const { scopeType, scopeId } = parseSponsorshipScope(readArg("scope"), readArg("scope-id")); - const enabledArg = readArg("enabled") ?? "true"; - if (enabledArg !== "true" && enabledArg !== "false") { - throw new Error("--enabled must be true or false"); - } - const limits = toRedisLuaSafePolicyLimits( - parseSponsorshipPolicyLimits({ - perTransactionLamports: readArg("per-tx-lamports"), - hourlyLamports: readArg("hourly-lamports"), - dailyLamports: readArg("daily-lamports"), - }) + } + ) ); - const policy = await repository.upsertPolicy({ - network, - scopeType, - scopeId, - enabled: enabledArg === "true", - ...limits, - operator: requireArg("operator"), - reason: requireArg("reason"), - }); - await new SponsorshipBudgetRedis(env).syncPolicy(policy); - console.log(JSON.stringify(policy, null, 2)); } -runWithSystemDatabaseIdentity("script:sponsorship-budget", main) +/** Output is one JSON line on stdout, so a Cloud Run execution logs it as one entry. */ +runWithSystemDatabaseIdentity("script:sponsorship-budget", async () => { + const command = parseSponsorshipBudgetCommand(readCommandLine()); + const env = getProcessEnv(); + const db = getDb(env); + const repository = new SponsorshipBudgetRepository(db); + const redis = new SponsorshipBudgetRedis(env); + if (command.command === "status") { + const statuses = await status(db, repository, redis, command.network); + console.log(JSON.stringify(statuses)); + if (statuses.some((policy) => !policy.inSync)) process.exitCode = 2; + return; + } + console.log(JSON.stringify(await planAndApply(db, repository, redis, command))); +}) .catch((error) => { console.error(error instanceof Error ? error.message : error); + if (error instanceof Error && error.cause instanceof Error) { + console.error(`Caused by: ${error.cause.message}`); + } process.exitCode = 1; }) - .finally(closeDatabasePools); + .finally(() => Promise.all([closeDatabasePools(), closeAllRedisClients()])); diff --git a/apps/sdp-api/src/db/repositories/sponsorship-budget.repository.test.ts b/apps/sdp-api/src/db/repositories/sponsorship-budget.repository.test.ts index a8fd082501..e51975b798 100644 --- a/apps/sdp-api/src/db/repositories/sponsorship-budget.repository.test.ts +++ b/apps/sdp-api/src/db/repositories/sponsorship-budget.repository.test.ts @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it } from "vitest"; import { getDb } from "@/db/client"; import { env } from "@/test/helpers/env"; import { seedDefaultProjects } from "@/test/helpers/projects"; +import { required } from "@/test/helpers/required"; import { seedTestDatabase } from "@/test/mocks/db"; import { type CreateSponsorshipReservationInput, @@ -65,38 +66,161 @@ describe("SponsorshipBudgetRepository", () => { ids: { sandbox: "project_1", production: "project_1_production" }, }); await insertPolicy({ id: "global", scopeType: "global", scopeId: null }); - await insertPolicy({ id: "org_default", scopeType: "organization", scopeId: null }); await insertPolicy({ id: "project_default", scopeType: "project", scopeId: null }); }); - it("prefers exact tenant policies and falls back independently at each scope", async () => { + it("resolves the exact project policy over its default and ignores organization rows", async () => { + await insertPolicy({ id: "project_exact", scopeType: "project", scopeId: "project_1" }); await insertPolicy({ id: "org_exact", scopeType: "organization", scopeId: "org_1", hourly: 50, }); - const policies = await new SponsorshipBudgetRepository(getDb(env)).resolvePolicies({ + const repository = new SponsorshipBudgetRepository(getDb(env)); + + const policies = await repository.resolvePolicies({ network: "devnet", - organizationId: "org_1", projectId: "project_1", }); - expect(policies.map((policy) => policy.id)).toEqual(["global", "org_exact", "project_default"]); + expect(policies.map((policy) => policy.id)).toEqual(["global", "project_exact"]); + expect((await repository.listPolicies("devnet")).map((policy) => policy.id)).toEqual([ + "global", + "project_default", + "project_exact", + ]); }); - it("toggles enabled without clobbering a concurrently-updated limit", async () => { + it("creates a policy at version 0 exactly once under concurrent writers", async () => { const repository = new SponsorshipBudgetRepository(getDb(env)); - const updated = await repository.upsertPolicy({ - network: "devnet", - scopeType: "global", - scopeId: null, + const create = { + network: "devnet" as const, + scopeType: "project" as const, + scopeId: "project_1", + enabled: true, + perTransactionLamports: 10, + hourlyLamports: 100, + dailyLamports: 1000, + expectedVersion: 0, + operator: "operator:oncall", + reason: "launch limits", + }; + + const results = await Promise.all([ + repository.writePolicyAtVersion(create), + repository.writePolicyAtVersion(create), + ]); + + const created = results.filter((result) => result !== null); + expect(created).toHaveLength(1); + const winner = required(created[0]); + expect(winner).toMatchObject({ scopeId: "project_1", version: 1 }); + expect( + await getDb(env).queryMany<{ version: number }>( + "SELECT version FROM sponsorship_budget_policy_revisions WHERE policy_id = ?", + [winner.id] + ) + ).toEqual([{ version: 1 }]); + }); + + it("refuses a version-0 create over an existing NULL-scope default", async () => { + const repository = new SponsorshipBudgetRepository(getDb(env)); + + await expect( + repository.writePolicyAtVersion({ + network: "devnet", + scopeType: "project", + scopeId: null, + enabled: true, + perTransactionLamports: 10, + hourlyLamports: 500, + dailyLamports: 1000, + expectedVersion: 0, + operator: "operator:oncall", + reason: "raise defaults", + }) + ).resolves.toBeNull(); + + const policies = await repository.listPolicies("devnet"); + expect(policies.find((policy) => policy.id === "project_default")).toMatchObject({ + hourlyLamports: 100, + version: 1, + }); + expect( + await getDb(env).queryOne<{ count: number }>( + "SELECT COUNT(*)::int AS count FROM sponsorship_budget_policy_revisions" + ) + ).toEqual({ count: 0 }); + }); + + it("refuses a write computed before a breaker trip moved the version", async () => { + const repository = new SponsorshipBudgetRepository(getDb(env)); + await repository.tripGlobalBreaker("devnet", "overspend detected"); + + await expect( + repository.writePolicyAtVersion({ + network: "devnet", + scopeType: "global", + scopeId: null, + enabled: true, + perTransactionLamports: 10, + hourlyLamports: 100, + dailyLamports: 1000, + expectedVersion: 1, + operator: "operator:oncall", + reason: "raise limits", + }) + ).resolves.toBeNull(); + + expect(await repository.getGlobalPolicy("devnet")).toMatchObject({ + enabled: false, + version: 2, + updateReason: "overspend detected", + }); + }); + + it("resumes an integrity-tripped global policy only at the version the operator read", async () => { + const repository = new SponsorshipBudgetRepository(getDb(env)); + const tripped = required(await repository.tripGlobalBreaker("devnet", "overspend detected")); + const resume = { + network: "devnet" as const, + operator: "operator:oncall", + reason: "overspend investigated", + }; + + await expect( + repository.resumeGlobalPolicyAtVersion({ ...resume, expectedVersion: tripped.version - 1 }) + ).resolves.toBeNull(); + const resumed = await repository.resumeGlobalPolicyAtVersion({ + ...resume, + expectedVersion: tripped.version, + }); + expect(resumed).toMatchObject({ enabled: true, - perTransactionLamports: 111, - hourlyLamports: 222, - dailyLamports: 333, - operator: "operator", - reason: "raise limits", + version: tripped.version + 1, + updatedBy: "operator:oncall", }); + await expect( + repository.resumeGlobalPolicyAtVersion({ ...resume, expectedVersion: tripped.version + 1 }) + ).resolves.toBeNull(); + }); + + it("toggles enabled without clobbering a concurrently-updated limit", async () => { + const repository = new SponsorshipBudgetRepository(getDb(env)); + const updated = required( + await repository.writePolicyAtVersion({ + network: "devnet", + scopeType: "global", + scopeId: null, + enabled: true, + perTransactionLamports: 111, + hourlyLamports: 222, + dailyLamports: 333, + expectedVersion: 1, + operator: "operator", + reason: "raise limits", + }) + ); const disabled = await repository.setPolicyEnabled({ network: "devnet", @@ -255,17 +379,20 @@ describe("SponsorshipBudgetRepository", () => { it("writes an audited revision for each policy change and prevents mutation", async () => { const repository = new SponsorshipBudgetRepository(getDb(env)); - const changed = await repository.upsertPolicy({ - network: "devnet", - scopeType: "global", - scopeId: null, - enabled: false, - perTransactionLamports: 10, - hourlyLamports: 100, - dailyLamports: 1000, - operator: "operator@example.com", - reason: "incident kill switch", - }); + const changed = required( + await repository.writePolicyAtVersion({ + network: "devnet", + scopeType: "global", + scopeId: null, + enabled: false, + perTransactionLamports: 10, + hourlyLamports: 100, + dailyLamports: 1000, + expectedVersion: 1, + operator: "operator@example.com", + reason: "incident kill switch", + }) + ); expect(changed.version).toBe(2); const revision = await getDb(env).queryOne<{ version: number; @@ -317,17 +444,15 @@ describe("SponsorshipBudgetRepository", () => { const policies = await repository.listPolicies(); expect(policies.map((entry) => entry.id)).toEqual([ "sbp_devnet_global", - "sbp_devnet_org_default", "sbp_devnet_project_default", "sbp_mainnet_global", - "sbp_mainnet_org_default", "sbp_mainnet_project_default", ]); expect( await getDb(env).queryOne<{ count: number }>( "SELECT COUNT(*)::int AS count FROM sponsorship_budget_policy_revisions" ) - ).toEqual({ count: 6 }); + ).toEqual({ count: 4 }); }); it("returns window usage sums as JavaScript numbers, not NUMERIC strings", async () => { @@ -342,8 +467,8 @@ describe("SponsorshipBudgetRepository", () => { dayBucket: input.dayBucket, }); expect(usage).toEqual({ - hour: { global: 5, organization: 5, project: 5 }, - day: { global: 5, organization: 5, project: 5 }, + hour: { global: 5, project: 5 }, + day: { global: 5, project: 5 }, }); }); @@ -364,8 +489,8 @@ describe("SponsorshipBudgetRepository", () => { }); expect(snapshot.usage).toEqual({ - hour: { global: 5, organization: 5, project: 5 }, - day: { global: 5, organization: 5, project: 5 }, + hour: { global: 5, project: 5 }, + day: { global: 5, project: 5 }, }); expect(snapshot.liveReservations.hour.map((reservation) => reservation.id)).toEqual([ "reservation_other", diff --git a/apps/sdp-api/src/db/repositories/sponsorship-budget.repository.ts b/apps/sdp-api/src/db/repositories/sponsorship-budget.repository.ts index be4dc09b0c..ad39c5d4bb 100644 --- a/apps/sdp-api/src/db/repositories/sponsorship-budget.repository.ts +++ b/apps/sdp-api/src/db/repositories/sponsorship-budget.repository.ts @@ -11,7 +11,7 @@ export function sponsorshipNetworkForCluster( ): SponsorshipNetwork { return cluster === "mainnet-beta" ? "mainnet" : "devnet"; } -export type SponsorshipBudgetScopeType = "global" | "organization" | "project"; +export type SponsorshipBudgetScopeType = "global" | "project"; export type SponsorshipReservationStatus = | "reserved" | "signed" @@ -37,7 +37,6 @@ export interface SponsorshipBudgetPolicy { export interface SponsorshipBudgetUsage { global: number; - organization: number; project: number; } @@ -82,13 +81,11 @@ export interface SponsorshipLiveWindowReservation { id: string; attempt: number; reservedLamports: number; - organizationId: string; projectId: string; } export interface SponsorshipReconciliationReservation extends SponsorshipReservation { network: SponsorshipNetwork; - organizationId: string; projectId: string; feePayer: string; providerConfigFingerprint: string; @@ -116,6 +113,11 @@ type PolicyRow = { updated_at: string; }; +/** A new policy row id. */ +function sponsorshipPolicyId(): string { + return `sbp_${randomUUID()}`; +} + function mapPolicy(row: PolicyRow): SponsorshipBudgetPolicy { return { id: row.id, @@ -148,9 +150,8 @@ export class SponsorshipBudgetRepository { async listPolicies(network?: SponsorshipNetwork): Promise { const rows = await this.db.queryMany( `SELECT * FROM sponsorship_budget_policies - WHERE (?::text IS NULL OR network = ?) - ORDER BY network, CASE scope_type WHEN 'global' THEN 0 WHEN 'organization' THEN 1 ELSE 2 END, - scope_id NULLS FIRST`, + WHERE (?::text IS NULL OR network = ?) AND scope_type <> 'organization' + ORDER BY network, CASE scope_type WHEN 'global' THEN 0 ELSE 1 END, scope_id NULLS FIRST`, [network ?? null, network ?? null] ); return rows.map(mapPolicy); @@ -158,7 +159,6 @@ export class SponsorshipBudgetRepository { async resolvePolicies(input: { network: SponsorshipNetwork; - organizationId: string; projectId: string; }): Promise { const rows = await this.db.queryMany( @@ -167,14 +167,13 @@ export class SponsorshipBudgetRepository { WHERE network = ? AND ( (scope_type = 'global' AND scope_id IS NULL) OR - (scope_type = 'organization' AND (scope_id = ? OR scope_id IS NULL)) OR (scope_type = 'project' AND (scope_id = ? OR scope_id IS NULL)) ) ORDER BY scope_type, (scope_id IS NOT NULL) DESC`, - [input.network, input.organizationId, input.projectId] + [input.network, input.projectId] ); const policies = rows.map(mapPolicy); - const requiredScopes: SponsorshipBudgetScopeType[] = ["global", "organization", "project"]; + const requiredScopes: SponsorshipBudgetScopeType[] = ["global", "project"]; if (requiredScopes.some((scopeType) => !policies.some((row) => row.scopeType === scopeType))) { throw new Error("Sponsorship budget policy hierarchy is incomplete"); } @@ -185,7 +184,27 @@ export class SponsorshipBudgetRepository { }); } - async upsertPolicy(input: { + /** + * Write an operator's policy change as a compare-and-set on the version the + * operator read. Version 0 means the policy must not exist yet; a concurrent + * create of the same scope loses on the scope's unique index instead of + * raising. A breaker trip bumps the version, so a write computed before the + * trip refuses rather than silently resuming sponsorship. + * + * @param input - The policy scope, its new limits, and the operator's attribution. + * @param input.network - The budget network the policy governs. + * @param input.scopeType - The policy level. + * @param input.scopeId - The project id, or null for the global row or the project default. + * @param input.enabled - Whether the policy admits sponsorship. + * @param input.perTransactionLamports - The per-transaction lamport ceiling. + * @param input.hourlyLamports - The hourly lamport ceiling. + * @param input.dailyLamports - The daily lamport ceiling. + * @param input.expectedVersion - The version the operator read; 0 when the policy does not exist. + * @param input.operator - Who made the change. + * @param input.reason - Why the change was made. + * @returns The written policy, or null when its version no longer matches. + */ + async writePolicyAtVersion(input: { network: SponsorshipNetwork; scopeType: SponsorshipBudgetScopeType; scopeId: string | null; @@ -193,48 +212,88 @@ export class SponsorshipBudgetRepository { perTransactionLamports: number; hourlyLamports: number; dailyLamports: number; + expectedVersion: number; operator: string; reason: string; - }): Promise { + }): Promise { + return this.db.transaction(async (tx) => { + const row = + input.expectedVersion === 0 + ? await tx.queryOne( + `INSERT INTO sponsorship_budget_policies ( + id, network, scope_type, scope_id, enabled, per_transaction_lamports, + hourly_lamports, daily_lamports, version, updated_by, update_reason + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?) + ON CONFLICT (network, scope_type, (COALESCE(scope_id, ''))) DO NOTHING + RETURNING *`, + [ + sponsorshipPolicyId(), + input.network, + input.scopeType, + input.scopeId, + input.enabled, + input.perTransactionLamports, + input.hourlyLamports, + input.dailyLamports, + input.operator, + input.reason, + ] + ) + : await tx.queryOne( + `UPDATE sponsorship_budget_policies SET + enabled = ?, per_transaction_lamports = ?, hourly_lamports = ?, + daily_lamports = ?, version = version + 1, updated_by = ?, + update_reason = ?, updated_at = sdp_iso_now() + WHERE network = ? AND scope_type = ? AND scope_id IS NOT DISTINCT FROM ? + AND version = ? + RETURNING *`, + [ + input.enabled, + input.perTransactionLamports, + input.hourlyLamports, + input.dailyLamports, + input.operator, + input.reason, + input.network, + input.scopeType, + input.scopeId, + input.expectedVersion, + ] + ); + if (!row) return null; + await this.insertRevision(tx, row, input.operator, input.reason); + return mapPolicy(row); + }); + } + + /** + * Resume a network's global policy as a compare-and-set on the version the + * operator read, whatever disabled it: an operator may resume an integrity + * trip, which the breaker's own recovery (`resumeGlobalBreaker`) refuses. + * + * @param input - The network, the version the operator read, and the attribution. + * @param input.network - The budget network to resume. + * @param input.expectedVersion - The global policy version the operator read. + * @param input.operator - Who resumed sponsorship. + * @param input.reason - Why sponsorship was resumed. + * @returns The resumed policy, or null when it is enabled or its version moved. + */ + async resumeGlobalPolicyAtVersion(input: { + network: SponsorshipNetwork; + expectedVersion: number; + operator: string; + reason: string; + }): Promise { return this.db.transaction(async (tx) => { - const existing = await tx.queryOne( - `SELECT * FROM sponsorship_budget_policies - WHERE network = ? AND scope_type = ? AND scope_id IS NOT DISTINCT FROM ? - FOR UPDATE`, - [input.network, input.scopeType, input.scopeId] - ); - const id = existing?.id ?? `sbp_${randomUUID()}`; - const version = (existing?.version ?? 0) + 1; const row = await tx.queryOne( - `INSERT INTO sponsorship_budget_policies ( - id, network, scope_type, scope_id, enabled, per_transaction_lamports, - hourly_lamports, daily_lamports, version, updated_by, update_reason - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT (id) DO UPDATE SET - enabled = EXCLUDED.enabled, - per_transaction_lamports = EXCLUDED.per_transaction_lamports, - hourly_lamports = EXCLUDED.hourly_lamports, - daily_lamports = EXCLUDED.daily_lamports, - version = EXCLUDED.version, - updated_by = EXCLUDED.updated_by, - update_reason = EXCLUDED.update_reason, - updated_at = sdp_iso_now() + `UPDATE sponsorship_budget_policies + SET enabled = TRUE, version = version + 1, updated_by = ?, update_reason = ?, updated_at = sdp_iso_now() + WHERE network = ? AND scope_type = 'global' AND scope_id IS NULL + AND enabled = FALSE AND version = ? RETURNING *`, - [ - id, - input.network, - input.scopeType, - input.scopeId, - input.enabled, - input.perTransactionLamports, - input.hourlyLamports, - input.dailyLamports, - version, - input.operator, - input.reason, - ] + [input.operator, input.reason, input.network, input.expectedVersion] ); - if (!row) throw new Error("Failed to persist sponsorship budget policy"); + if (!row) return null; await this.insertRevision(tx, row, input.operator, input.reason); return mapPolicy(row); }); @@ -520,14 +579,10 @@ export class SponsorshipBudgetRepository { input.hourBucket, input.hourBucket, input.organizationId, - input.hourBucket, - input.organizationId, input.projectId, input.dayBucket, input.dayBucket, input.organizationId, - input.dayBucket, - input.organizationId, input.projectId, input.network, input.hourBucket, @@ -537,10 +592,8 @@ export class SponsorshipBudgetRepository { const row = await executor.queryOne>( `SELECT COALESCE(SUM(CASE WHEN hour_bucket = ? THEN COALESCE(actual_lamports, reserved_lamports) ELSE 0 END), 0)::bigint AS global_hour, - COALESCE(SUM(CASE WHEN hour_bucket = ? AND organization_id = ? THEN COALESCE(actual_lamports, reserved_lamports) ELSE 0 END), 0)::bigint AS organization_hour, COALESCE(SUM(CASE WHEN hour_bucket = ? AND organization_id = ? AND project_id = ? THEN COALESCE(actual_lamports, reserved_lamports) ELSE 0 END), 0)::bigint AS project_hour, COALESCE(SUM(CASE WHEN day_bucket = ? THEN COALESCE(actual_lamports, reserved_lamports) ELSE 0 END), 0)::bigint AS global_day, - COALESCE(SUM(CASE WHEN day_bucket = ? AND organization_id = ? THEN COALESCE(actual_lamports, reserved_lamports) ELSE 0 END), 0)::bigint AS organization_day, COALESCE(SUM(CASE WHEN day_bucket = ? AND organization_id = ? AND project_id = ? THEN COALESCE(actual_lamports, reserved_lamports) ELSE 0 END), 0)::bigint AS project_day FROM sponsorship_budget_reservations WHERE network = ? AND status <> 'released' AND (hour_bucket = ? OR day_bucket = ?)${ @@ -551,12 +604,10 @@ export class SponsorshipBudgetRepository { return { hour: { global: row?.global_hour ?? 0, - organization: row?.organization_hour ?? 0, project: row?.project_hour ?? 0, }, day: { global: row?.global_day ?? 0, - organization: row?.organization_day ?? 0, project: row?.project_day ?? 0, }, }; @@ -580,12 +631,11 @@ export class SponsorshipBudgetRepository { id: string; attempt: number; reserved_lamports: number; - organization_id: string; project_id: string; hour_bucket: string; day_bucket: string; }>( - `SELECT id, attempt, reserved_lamports, organization_id, project_id, hour_bucket, day_bucket + `SELECT id, attempt, reserved_lamports, project_id, hour_bucket, day_bucket FROM sponsorship_budget_reservations WHERE network = ? AND status IN ('reserved', 'signed', 'submitted') AND (hour_bucket = ? OR day_bucket = ?)${excludeReservationId ? " AND id <> ?" : ""}`, @@ -598,7 +648,6 @@ export class SponsorshipBudgetRepository { id: row.id, attempt: row.attempt, reservedLamports: row.reserved_lamports, - organizationId: row.organization_id, projectId: row.project_id, }; if (row.hour_bucket === input.hourBucket) { @@ -651,7 +700,6 @@ export class SponsorshipBudgetRepository { actual_lamports: number | null; attempt: number; network: SponsorshipNetwork; - organization_id: string; project_id: string; fee_payer: string; provider_config_fingerprint: string; @@ -664,7 +712,7 @@ export class SponsorshipBudgetRepository { }>( `SELECT id, status, signature, signed_transaction, reserved_lamports, actual_lamports, attempt, network, - organization_id, project_id, fee_payer, provider_config_fingerprint, + project_id, fee_payer, provider_config_fingerprint, last_valid_block_height, hour_bucket, day_bucket, miss_count, updated_at, redis_settled_at FROM sponsorship_budget_reservations @@ -685,7 +733,6 @@ export class SponsorshipBudgetRepository { actualLamports: row.actual_lamports, attempt: row.attempt, network: row.network, - organizationId: row.organization_id, projectId: row.project_id, feePayer: row.fee_payer, providerConfigFingerprint: row.provider_config_fingerprint, diff --git a/apps/sdp-api/src/db/tenant-isolation.test.ts b/apps/sdp-api/src/db/tenant-isolation.test.ts index 28d59fadf3..2344eaa243 100644 --- a/apps/sdp-api/src/db/tenant-isolation.test.ts +++ b/apps/sdp-api/src/db/tenant-isolation.test.ts @@ -203,22 +203,17 @@ describe("database-enforced tenant isolation", () => { it("keeps the NULL-scoped sponsorship defaults resolvable under a tenant identity", async () => { // Sponsorship admission runs in the request path and resolves the full - // policy hierarchy — global, organization default (scope_id IS NULL), - // project default — and trips the breaker when any level is missing. The + // policy hierarchy — global and the project default (scope_id IS NULL) — + // and trips the breaker when either level is missing. The // platform defaults carry no tenant data, so the SELECT policy must keep // them visible to every tenant. const policies = await runWithTenantDatabaseIdentity({ organizationId: ORG_A }, () => new SponsorshipBudgetRepository(getDb(env)).resolvePolicies({ network: "devnet", - organizationId: ORG_A, projectId: PROJECT_A, }) ); - expect(policies.map((policy) => policy.scopeType).sort()).toEqual([ - "global", - "organization", - "project", - ]); + expect(policies.map((policy) => policy.scopeType).sort()).toEqual(["global", "project"]); }); it("keeps explicit cross-tenant access for the system identity", async () => { diff --git a/apps/sdp-api/src/runtime/sponsorship-budget-redis.node.test.ts b/apps/sdp-api/src/runtime/sponsorship-budget-redis.node.test.ts index e8cb59ead1..4279cab4c5 100644 --- a/apps/sdp-api/src/runtime/sponsorship-budget-redis.node.test.ts +++ b/apps/sdp-api/src/runtime/sponsorship-budget-redis.node.test.ts @@ -17,8 +17,8 @@ vi.mock("./money-path-events", async (importOriginal) => ({ const PROJECT_ID = "project_1"; const EMPTY_USAGE = { - hour: { global: 0, organization: 0, project: 0 }, - day: { global: 0, organization: 0, project: 0 }, + hour: { global: 0, project: 0 }, + day: { global: 0, project: 0 }, }; function policy( @@ -65,8 +65,7 @@ describe("SponsorshipBudgetRedis", () => { logEvent.mockClear(); const policies = [ policy("global", 1, true, 1000), - { ...policy("organization", 1, true, 1000), hourlyLamports: 5 }, - policy("project", 1, true, 1000), + { ...policy("project", 1, true, 1000), hourlyLamports: 5 }, ]; const window = { network: "devnet" as const, @@ -101,7 +100,7 @@ describe("SponsorshipBudgetRedis", () => { expect.objectContaining({ event: "sdp_api_sponsorship_denied", network: "devnet", - scope_type: "organization", + scope_type: "project", organization_id: "org_1", project_id: PROJECT_ID, requested_lamports: 4, @@ -113,11 +112,7 @@ describe("SponsorshipBudgetRedis", () => { }); it("enforces concurrent limits atomically across all applicable scopes", async () => { - const policies = [ - policy("global", 1, true, 5), - policy("organization", 1, true, 3), - policy("project", 1, true, 5), - ]; + const policies = [policy("global", 1, true, 5), policy("project", 1, true, 3)]; const attempts = await Promise.all( Array.from({ length: 10 }, (_, index) => budget.reserve({ @@ -137,16 +132,11 @@ describe("SponsorshipBudgetRedis", () => { expect(attempts.filter((result) => result === "admitted")).toHaveLength(3); const hour = await raw.hgetall("sdp:sponsorship:{devnet}:hour:2026-08-03T10:00:00.000Z"); expect(hour.global).toBe("3"); - expect(hour["organization:org_1"]).toBe("3"); expect(hour[`project:${PROJECT_ID}`]).toBe("3"); }); - it("restores each newly-seen organization field from durable usage", async () => { - const policies = [ - policy("global", 1, true, 100), - policy("organization", 1, true, 10), - policy("project", 1, true, 100), - ]; + it("restores each newly-seen project field from durable usage", async () => { + const policies = [policy("global", 1, true, 100), policy("project", 1, true, 10)]; await expect( budget.reserve({ network: "devnet", @@ -154,7 +144,7 @@ describe("SponsorshipBudgetRedis", () => { projectId: PROJECT_ID, hourBucket: "2026-08-03T10:00:00.000Z", dayBucket: "2026-08-03T00:00:00.000Z", - reservationId: "reservation_org_1", + reservationId: "reservation_project_1", attempt: 1, amount: 3, policies, @@ -168,18 +158,18 @@ describe("SponsorshipBudgetRedis", () => { projectId: "project_2", hourBucket: "2026-08-03T10:00:00.000Z", dayBucket: "2026-08-03T00:00:00.000Z", - reservationId: "reservation_org_2", + reservationId: "reservation_project_2", attempt: 1, amount: 4, policies, usage: { - hour: { global: 10, organization: 7, project: 7 }, - day: { global: 10, organization: 7, project: 7 }, + hour: { global: 10, project: 7 }, + day: { global: 10, project: 7 }, }, }) ).resolves.toBe("denied"); expect( - await raw.hget("sdp:sponsorship:{devnet}:hour:2026-08-03T10:00:00.000Z", "organization:org_2") + await raw.hget("sdp:sponsorship:{devnet}:hour:2026-08-03T10:00:00.000Z", "project:project_2") ).toBe("7"); }); @@ -194,7 +184,7 @@ describe("SponsorshipBudgetRedis", () => { reservationId: "reservation_stale", attempt: 1, amount: 1, - policies: [policy("global", 1), policy("organization", 1), policy("project", 1)], + policies: [policy("global", 1), policy("project", 1)], usage: EMPTY_USAGE, }); expect(result).toBe("stale_policy"); @@ -213,7 +203,7 @@ describe("SponsorshipBudgetRedis", () => { reservationId: "reservation_orphan", attempt: 1, amount: 1, - policies: [policy("global", 1), policy("organization", 1), policy("project", 1)], + policies: [policy("global", 1), policy("project", 1)], usage: EMPTY_USAGE, }; await expect(budget.reserve(input)).resolves.toBe("admitted"); @@ -222,7 +212,7 @@ describe("SponsorshipBudgetRedis", () => { await expect( budget.reserve({ ...input, - policies: [killed, policy("organization", 1), policy("project", 1)], + policies: [killed, policy("project", 1)], }) ).resolves.toBe("stale_policy"); }); @@ -242,17 +232,12 @@ describe("SponsorshipBudgetRedis", () => { reservationId: `reservation_settle_${actualLamports}`, attempt: 1, amount: 5, - policies: [ - policy("global", 1, true, 20), - policy("organization", 1, true, 20), - policy("project", 1, true, 20), - ], + policies: [policy("global", 1, true, 20), policy("project", 1, true, 20)], usage: EMPTY_USAGE, }; await expect(budget.reserve(input)).resolves.toBe("admitted"); const settlement = { network: input.network, - organizationId: input.organizationId, projectId: input.projectId, hourBucket: input.hourBucket, dayBucket: input.dayBucket, @@ -278,11 +263,7 @@ describe("SponsorshipBudgetRedis", () => { reservationId: "reservation_owned", attempt: 1, amount: 5, - policies: [ - policy("global", 1, true, 20), - policy("organization", 1, true, 20), - policy("project", 1, true, 20), - ], + policies: [policy("global", 1, true, 20), policy("project", 1, true, 20)], usage: EMPTY_USAGE, }; await expect(budget.reserve(input)).resolves.toBe("admitted"); @@ -299,7 +280,6 @@ describe("SponsorshipBudgetRedis", () => { await expect( budget.settle({ network: input.network, - organizationId: input.organizationId, projectId: input.projectId, hourBucket: input.hourBucket, dayBucket: input.dayBucket, @@ -325,11 +305,7 @@ describe("SponsorshipBudgetRedis", () => { reservationId: "reservation_cancel_missing", attempt: 1, amount: 5, - policies: [ - policy("global", 1, true, 20), - policy("organization", 1, true, 20), - policy("project", 1, true, 20), - ], + policies: [policy("global", 1, true, 20), policy("project", 1, true, 20)], usage: EMPTY_USAGE, }; await expect(budget.reserve(input)).resolves.toBe("admitted"); @@ -367,11 +343,7 @@ describe("SponsorshipBudgetRedis", () => { reservationId: "reservation_expired", attempt: 1, amount: 5, - policies: [ - policy("global", 1, true, 20), - policy("organization", 1, true, 20), - policy("project", 1, true, 20), - ], + policies: [policy("global", 1, true, 20), policy("project", 1, true, 20)], usage: EMPTY_USAGE, }; await expect(budget.reserve(input)).resolves.toBe("admitted"); @@ -380,7 +352,6 @@ describe("SponsorshipBudgetRedis", () => { await raw.del(reservationKey); const settlement = { network: input.network, - organizationId: input.organizationId, projectId: input.projectId, hourBucket: input.hourBucket, dayBucket: input.dayBucket, @@ -418,11 +389,7 @@ describe("SponsorshipBudgetRedis", () => { reservationId: "reservation_missing", attempt: 1, amount: 5, - policies: [ - policy("global", 1, true, 20), - policy("organization", 1, true, 20), - policy("project", 1, true, 20), - ], + policies: [policy("global", 1, true, 20), policy("project", 1, true, 20)], usage: EMPTY_USAGE, }; await expect(budget.reserve(input)).resolves.toBe("admitted"); @@ -434,7 +401,6 @@ describe("SponsorshipBudgetRedis", () => { await expect( budget.settle({ network: input.network, - organizationId: input.organizationId, projectId: input.projectId, hourBucket: input.hourBucket, dayBucket: input.dayBucket, @@ -448,7 +414,7 @@ describe("SponsorshipBudgetRedis", () => { const hourKey = "sdp:sponsorship:{devnet}:hour:2026-08-03T10:00:00.000Z"; expect(await raw.hget(hourKey, "global")).toBe("5"); - expect(await raw.hget(hourKey, "organization:org_1")).toBe("5"); + expect(await raw.hget(hourKey, `project:${PROJECT_ID}`)).toBe("5"); expect(await raw.hget(hourKey, "__reservation:reservation_missing:1")).toBeNull(); expect(await raw.hget(hourKey, "__reservation:reservation_concurrent:1")).toBe("3"); expect(await raw.get("sdp:sponsorship:{devnet}:reservation:reservation_concurrent:1")).toBe( @@ -466,11 +432,7 @@ describe("SponsorshipBudgetRedis", () => { reservationId: "reservation_rebuilt", attempt: 1, amount: 5, - policies: [ - policy("global", 1, true, 20), - policy("organization", 1, true, 20), - policy("project", 1, true, 20), - ], + policies: [policy("global", 1, true, 20), policy("project", 1, true, 20)], usage: EMPTY_USAGE, }; await expect(budget.reserve(input)).resolves.toBe("admitted"); @@ -478,8 +440,8 @@ describe("SponsorshipBudgetRedis", () => { const dayKey = "sdp:sponsorship:{devnet}:day:2026-08-03T00:00:00.000Z"; await raw.del("sdp:sponsorship:{devnet}:reservation:reservation_rebuilt:1", hourKey, dayKey); const rebuiltUsage = { - hour: { global: 2, organization: 2, project: 2 }, - day: { global: 2, organization: 2, project: 2 }, + hour: { global: 2, project: 2 }, + day: { global: 2, project: 2 }, }; await expect( budget.reserve({ @@ -493,7 +455,6 @@ describe("SponsorshipBudgetRedis", () => { await expect( budget.settle({ network: input.network, - organizationId: input.organizationId, projectId: input.projectId, hourBucket: input.hourBucket, dayBucket: input.dayBucket, @@ -518,11 +479,7 @@ describe("SponsorshipBudgetRedis", () => { projectId: PROJECT_ID, hourBucket: "2026-08-03T10:00:00.000Z", dayBucket: "2026-08-03T00:00:00.000Z", - policies: [ - policy("global", 1, true, 20), - policy("organization", 1, true, 20), - policy("project", 1, true, 20), - ], + policies: [policy("global", 1, true, 20), policy("project", 1, true, 20)], }; const hourKey = "sdp:sponsorship:{devnet}:hour:2026-08-03T10:00:00.000Z"; const dayKey = "sdp:sponsorship:{devnet}:day:2026-08-03T00:00:00.000Z"; @@ -547,8 +504,8 @@ describe("SponsorshipBudgetRedis", () => { attempt: 1, amount: 3, usage: { - hour: { global: 5, organization: 5, project: 5 }, - day: { global: 5, organization: 5, project: 5 }, + hour: { global: 5, project: 5 }, + day: { global: 5, project: 5 }, }, liveReservations: { hour: [ @@ -556,7 +513,6 @@ describe("SponsorshipBudgetRedis", () => { id: "reservation_partial", attempt: 1, reservedLamports: 5, - organizationId: "org_1", projectId: PROJECT_ID, }, ], @@ -565,7 +521,6 @@ describe("SponsorshipBudgetRedis", () => { id: "reservation_partial", attempt: 1, reservedLamports: 5, - organizationId: "org_1", projectId: PROJECT_ID, }, ], @@ -580,7 +535,6 @@ describe("SponsorshipBudgetRedis", () => { await expect( budget.settle({ network: base.network, - organizationId: base.organizationId, projectId: base.projectId, hourBucket: base.hourBucket, dayBucket: base.dayBucket, @@ -602,11 +556,7 @@ describe("SponsorshipBudgetRedis", () => { const hourBucket = "2026-08-03T10:00:00.000Z"; const dayBucket = "2026-08-03T00:00:00.000Z"; const hourKey = "sdp:sponsorship:{devnet}:hour:2026-08-03T10:00:00.000Z"; - const policies = [ - policy("global", 1, true, 20), - policy("organization", 1, true, 20), - policy("project", 1, true, 20), - ]; + const policies = [policy("global", 1, true, 20), policy("project", 1, true, 20)]; await expect( budget.reserve({ @@ -639,8 +589,8 @@ describe("SponsorshipBudgetRedis", () => { amount: 3, policies, usage: { - hour: { global: 5, organization: 0, project: 0 }, - day: { global: 5, organization: 0, project: 0 }, + hour: { global: 5, project: 0 }, + day: { global: 5, project: 0 }, }, liveReservations: { hour: [ @@ -648,7 +598,6 @@ describe("SponsorshipBudgetRedis", () => { id: "res_b", attempt: 1, reservedLamports: 5, - organizationId: "org_b", projectId: "project_b", }, ], @@ -657,7 +606,6 @@ describe("SponsorshipBudgetRedis", () => { id: "res_b", attempt: 1, reservedLamports: 5, - organizationId: "org_b", projectId: "project_b", }, ], @@ -666,13 +614,12 @@ describe("SponsorshipBudgetRedis", () => { ).resolves.toBe("admitted"); expect(await raw.hget(hourKey, "global")).toBe("8"); - expect(await raw.hget(hourKey, "__initialized:organization:org_b")).toBeNull(); + expect(await raw.hget(hourKey, "__initialized:project:project_b")).toBeNull(); expect(await raw.hget(hourKey, "__reservation:res_b:1")).toBe("5"); await expect( budget.settle({ network: "devnet", - organizationId: "org_b", projectId: "project_b", hourBucket, dayBucket, @@ -698,10 +645,6 @@ describe("SponsorshipBudgetRedis", () => { "10", "__initialized:global", "1", - "organization:org_1", - "10", - "__initialized:organization:org_1", - "1", `project:${PROJECT_ID}`, "10", `__initialized:project:${PROJECT_ID}`, @@ -721,21 +664,17 @@ describe("SponsorshipBudgetRedis", () => { reservationId: "res_legacy_retry", attempt: 1, amount: 10, - policies: [ - policy("global", 1, true, 100), - policy("organization", 1, true, 100), - policy("project", 1, true, 100), - ], + policies: [policy("global", 1, true, 100), policy("project", 1, true, 100)], usage: { - hour: { global: 10, organization: 10, project: 10 }, - day: { global: 10, organization: 10, project: 10 }, + hour: { global: 10, project: 10 }, + day: { global: 10, project: 10 }, }, liveReservations: { hour: [], day: [] }, }) ).resolves.toBe("admitted"); expect(await raw.hget(hourKey, "global")).toBe("10"); - expect(await raw.hget(hourKey, "organization:org_1")).toBe("10"); + expect(await raw.hget(hourKey, `project:${PROJECT_ID}`)).toBe("10"); expect(await raw.hget(dayKey, "global")).toBe("10"); }); @@ -749,10 +688,6 @@ describe("SponsorshipBudgetRedis", () => { "10", "__initialized:global", "1", - "organization:org_1", - "10", - "__initialized:organization:org_1", - "1", `project:${PROJECT_ID}`, "10", `__initialized:project:${PROJECT_ID}`, @@ -765,7 +700,6 @@ describe("SponsorshipBudgetRedis", () => { await expect( budget.settle({ network: "devnet", - organizationId: "org_1", projectId: PROJECT_ID, hourBucket: "2026-08-03T10:00:00.000Z", dayBucket: "2026-08-03T00:00:00.000Z", @@ -778,7 +712,7 @@ describe("SponsorshipBudgetRedis", () => { ).resolves.toBe(-6); expect(await raw.hget(hourKey, "global")).toBe("4"); - expect(await raw.hget(hourKey, "organization:org_1")).toBe("4"); + expect(await raw.hget(hourKey, `project:${PROJECT_ID}`)).toBe("4"); expect(await raw.hget(dayKey, "global")).toBe("4"); expect(await raw.hget(hourKey, "__reservation:res_legacy:1")).toBeNull(); }); @@ -793,10 +727,6 @@ describe("SponsorshipBudgetRedis", () => { "10", "__initialized:global", "1", - "organization:org_1", - "10", - "__initialized:organization:org_1", - "1", `project:${PROJECT_ID}`, "10", `__initialized:project:${PROJECT_ID}`, @@ -817,17 +747,13 @@ describe("SponsorshipBudgetRedis", () => { }); expect(await raw.hget(hourKey, "global")).toBe("0"); - expect(await raw.hget(dayKey, "organization:org_1")).toBe("0"); + expect(await raw.hget(dayKey, `project:${PROJECT_ID}`)).toBe("0"); expect(await raw.hget(hourKey, "__reservation:res_legacy_cancel:1")).toBeNull(); }); it("counts a tenant once when its field is seeded into an already warm window", async () => { const hourKey = "sdp:sponsorship:{devnet}:hour:2026-08-03T10:00:00.000Z"; - const policies = [ - policy("global", 1, true, 1000), - policy("organization", 1, true, 1000), - policy("project", 1, true, 1000), - ]; + const policies = [policy("global", 1, true, 1000), policy("project", 1, true, 1000)]; const window = { network: "devnet" as const, hourBucket: "2026-08-03T10:00:00.000Z", @@ -852,7 +778,6 @@ describe("SponsorshipBudgetRedis", () => { id: "res_b2", attempt: 1, reservedLamports: 10, - organizationId: "org_b", projectId: "project_b", }; await expect( @@ -864,8 +789,8 @@ describe("SponsorshipBudgetRedis", () => { attempt: 1, amount: 10, usage: { - hour: { global: 15, organization: 10, project: 10 }, - day: { global: 15, organization: 10, project: 10 }, + hour: { global: 15, project: 10 }, + day: { global: 15, project: 10 }, }, liveReservations: { hour: [liveB2], day: [liveB2] }, }) @@ -875,7 +800,6 @@ describe("SponsorshipBudgetRedis", () => { id: "res_b1", attempt: 1, reservedLamports: 10, - organizationId: "org_b", projectId: "project_b", }; await expect( @@ -887,14 +811,14 @@ describe("SponsorshipBudgetRedis", () => { attempt: 1, amount: 10, usage: { - hour: { global: 15, organization: 10, project: 10 }, - day: { global: 15, organization: 10, project: 10 }, + hour: { global: 15, project: 10 }, + day: { global: 15, project: 10 }, }, liveReservations: { hour: [liveB1], day: [liveB1] }, }) ).resolves.toBe("admitted"); - expect(await raw.hget(hourKey, "organization:org_b")).toBe("20"); + expect(await raw.hget(hourKey, "project:project_b")).toBe("20"); }); it("denies an adopted reservation that exceeds the per-transaction limit", async () => { @@ -910,14 +834,10 @@ describe("SponsorshipBudgetRedis", () => { reservationId: "res_self", attempt: 1, amount: 11, - policies: [ - policy("global", 1, true, 10), - policy("organization", 1, true, 10), - policy("project", 1, true, 10), - ], + policies: [policy("global", 1, true, 10), policy("project", 1, true, 10)], usage: { - hour: { global: 11, organization: 11, project: 11 }, - day: { global: 11, organization: 11, project: 11 }, + hour: { global: 11, project: 11 }, + day: { global: 11, project: 11 }, }, liveReservations: { hour: [ @@ -925,7 +845,6 @@ describe("SponsorshipBudgetRedis", () => { id: "res_self", attempt: 1, reservedLamports: 11, - organizationId: "org_1", projectId: PROJECT_ID, }, ], @@ -934,7 +853,6 @@ describe("SponsorshipBudgetRedis", () => { id: "res_self", attempt: 1, reservedLamports: 11, - organizationId: "org_1", projectId: PROJECT_ID, }, ], @@ -957,14 +875,10 @@ describe("SponsorshipBudgetRedis", () => { reservationId: "res_over", attempt: 1, amount: 5, - policies: [ - policy("global", 1, true, 20), - policy("organization", 1, true, 20), - policy("project", 1, true, 20), - ], + policies: [policy("global", 1, true, 20), policy("project", 1, true, 20)], usage: { - hour: { global: 25, organization: 25, project: 25 }, - day: { global: 25, organization: 25, project: 25 }, + hour: { global: 25, project: 25 }, + day: { global: 25, project: 25 }, }, liveReservations: { hour: [ @@ -972,7 +886,6 @@ describe("SponsorshipBudgetRedis", () => { id: "res_over", attempt: 1, reservedLamports: 5, - organizationId: "org_1", projectId: PROJECT_ID, }, ], @@ -981,7 +894,6 @@ describe("SponsorshipBudgetRedis", () => { id: "res_over", attempt: 1, reservedLamports: 5, - organizationId: "org_1", projectId: PROJECT_ID, }, ], @@ -997,11 +909,7 @@ describe("SponsorshipBudgetRedis", () => { projectId: PROJECT_ID, hourBucket: "2026-08-03T10:00:00.000Z", dayBucket: "2026-08-03T00:00:00.000Z", - policies: [ - policy("global", 1, true, 100), - policy("organization", 1, true, 100), - policy("project", 1, true, 100), - ], + policies: [policy("global", 1, true, 100), policy("project", 1, true, 100)], }; const hourKey = "sdp:sponsorship:{devnet}:hour:2026-08-03T10:00:00.000Z"; const dayKey = "sdp:sponsorship:{devnet}:day:2026-08-03T00:00:00.000Z"; @@ -1013,8 +921,8 @@ describe("SponsorshipBudgetRedis", () => { attempt: 1, amount: 5, usage: { - hour: { global: 8, organization: 8, project: 8 }, - day: { global: 8, organization: 8, project: 8 }, + hour: { global: 8, project: 8 }, + day: { global: 8, project: 8 }, }, liveReservations: { hour: [ @@ -1022,7 +930,6 @@ describe("SponsorshipBudgetRedis", () => { id: "reservation_b", attempt: 1, reservedLamports: 8, - organizationId: "org_1", projectId: PROJECT_ID, }, ], @@ -1031,7 +938,6 @@ describe("SponsorshipBudgetRedis", () => { id: "reservation_b", attempt: 1, reservedLamports: 8, - organizationId: "org_1", projectId: PROJECT_ID, }, ], @@ -1049,8 +955,8 @@ describe("SponsorshipBudgetRedis", () => { attempt: 1, amount: 8, usage: { - hour: { global: 13, organization: 13, project: 13 }, - day: { global: 13, organization: 13, project: 13 }, + hour: { global: 13, project: 13 }, + day: { global: 13, project: 13 }, }, liveReservations: { hour: [], day: [] }, }) @@ -1070,10 +976,6 @@ describe("SponsorshipBudgetRedis", () => { "0", "__initialized:global", "1", - "organization:org_1", - "0", - "__initialized:organization:org_1", - "1", `project:${PROJECT_ID}`, "0", `__initialized:project:${PROJECT_ID}`, @@ -1090,14 +992,10 @@ describe("SponsorshipBudgetRedis", () => { reservationId: "res_a", attempt: 1, amount: 4, - policies: [ - policy("global", 1, true, 100), - policy("organization", 1, true, 100), - policy("project", 1, true, 100), - ], + policies: [policy("global", 1, true, 100), policy("project", 1, true, 100)], usage: { - hour: { global: 4, organization: 4, project: 4 }, - day: { global: 4, organization: 4, project: 4 }, + hour: { global: 4, project: 4 }, + day: { global: 4, project: 4 }, }, liveReservations: { hour: [ @@ -1105,7 +1003,6 @@ describe("SponsorshipBudgetRedis", () => { id: "res_a", attempt: 1, reservedLamports: 4, - organizationId: "org_1", projectId: PROJECT_ID, }, ], @@ -1114,7 +1011,6 @@ describe("SponsorshipBudgetRedis", () => { id: "res_a", attempt: 1, reservedLamports: 4, - organizationId: "org_1", projectId: PROJECT_ID, }, ], @@ -1136,10 +1032,6 @@ describe("SponsorshipBudgetRedis", () => { "98", "__initialized:global", "1", - "organization:org_1", - "98", - "__initialized:organization:org_1", - "1", `project:${PROJECT_ID}`, "98", `__initialized:project:${PROJECT_ID}`, @@ -1156,14 +1048,10 @@ describe("SponsorshipBudgetRedis", () => { reservationId: "res_a", attempt: 1, amount: 4, - policies: [ - policy("global", 1, true, 100), - policy("organization", 1, true, 100), - policy("project", 1, true, 100), - ], + policies: [policy("global", 1, true, 100), policy("project", 1, true, 100)], usage: { - hour: { global: 4, organization: 4, project: 4 }, - day: { global: 98, organization: 98, project: 98 }, + hour: { global: 4, project: 4 }, + day: { global: 98, project: 98 }, }, liveReservations: { hour: [ @@ -1171,7 +1059,6 @@ describe("SponsorshipBudgetRedis", () => { id: "res_a", attempt: 1, reservedLamports: 4, - organizationId: "org_1", projectId: PROJECT_ID, }, ], @@ -1180,7 +1067,6 @@ describe("SponsorshipBudgetRedis", () => { id: "res_a", attempt: 1, reservedLamports: 4, - organizationId: "org_1", projectId: PROJECT_ID, }, ], @@ -1208,14 +1094,10 @@ describe("SponsorshipBudgetRedis", () => { reservationId: "res_a", attempt: 1, amount: 3, - policies: [ - policy("global", 1, true, 100), - policy("organization", 1, true, 100), - policy("project", 1, true, 100), - ], + policies: [policy("global", 1, true, 100), policy("project", 1, true, 100)], usage: { - hour: { global: 5, organization: 5, project: 5 }, - day: { global: 5, organization: 5, project: 5 }, + hour: { global: 5, project: 5 }, + day: { global: 5, project: 5 }, }, liveReservations: { hour: [ @@ -1223,7 +1105,6 @@ describe("SponsorshipBudgetRedis", () => { id: "res_c", attempt: 1, reservedLamports: 5, - organizationId: "org_1", projectId: PROJECT_ID, }, ], @@ -1232,7 +1113,6 @@ describe("SponsorshipBudgetRedis", () => { id: "res_c", attempt: 1, reservedLamports: 5, - organizationId: "org_1", projectId: PROJECT_ID, }, ], @@ -1241,7 +1121,7 @@ describe("SponsorshipBudgetRedis", () => { ).resolves.toBe("admitted"); expect(await raw.hget(hourKey, "global")).toBe("5"); - expect(await raw.hget(hourKey, "organization:org_1")).toBe("5"); + expect(await raw.hget(hourKey, `project:${PROJECT_ID}`)).toBe("5"); expect(await raw.hget(dayKey, "global")).toBe("5"); expect(await raw.hget(hourKey, "__reservation:res_c:1")).toBeNull(); }); @@ -1260,14 +1140,10 @@ describe("SponsorshipBudgetRedis", () => { reservationId: "res_a", attempt: 1, amount: 3, - policies: [ - policy("global", 1, true, 100), - policy("organization", 1, true, 100), - policy("project", 1, true, 100), - ], + policies: [policy("global", 1, true, 100), policy("project", 1, true, 100)], usage: { - hour: { global: 5, organization: 0, project: 0 }, - day: { global: 5, organization: 0, project: 0 }, + hour: { global: 5, project: 0 }, + day: { global: 5, project: 0 }, }, liveReservations: { hour: [ @@ -1275,7 +1151,6 @@ describe("SponsorshipBudgetRedis", () => { id: "res_b", attempt: 1, reservedLamports: 5, - organizationId: "org_b", projectId: "project_b", }, ], @@ -1284,7 +1159,6 @@ describe("SponsorshipBudgetRedis", () => { id: "res_b", attempt: 1, reservedLamports: 5, - organizationId: "org_b", projectId: "project_b", }, ], @@ -1293,8 +1167,8 @@ describe("SponsorshipBudgetRedis", () => { ).resolves.toBe("admitted"); expect(await raw.hget(hourKey, "global")).toBe("5"); - expect(await raw.hget(hourKey, "organization:org_a")).toBe("3"); - expect(await raw.hget(hourKey, "__initialized:organization:org_b")).toBeNull(); + expect(await raw.hget(hourKey, "project:project_a")).toBe("3"); + expect(await raw.hget(hourKey, "__initialized:project:project_b")).toBeNull(); expect(await raw.hget(hourKey, "__reservation:res_b:1")).toBeNull(); }); @@ -1308,11 +1182,7 @@ describe("SponsorshipBudgetRedis", () => { reservationId: "reservation_mismatch", attempt: 1, amount: 5, - policies: [ - policy("global", 1, true, 20), - policy("organization", 1, true, 20), - policy("project", 1, true, 20), - ], + policies: [policy("global", 1, true, 20), policy("project", 1, true, 20)], usage: EMPTY_USAGE, }; await expect(budget.reserve(input)).resolves.toBe("admitted"); @@ -1321,7 +1191,6 @@ describe("SponsorshipBudgetRedis", () => { await expect( budget.settle({ network: input.network, - organizationId: input.organizationId, projectId: input.projectId, hourBucket: input.hourBucket, dayBucket: input.dayBucket, @@ -1344,17 +1213,12 @@ describe("SponsorshipBudgetRedis", () => { reservationId: "reservation_retry", attempt: 1, amount: 5, - policies: [ - policy("global", 1, true, 20), - policy("organization", 1, true, 20), - policy("project", 1, true, 20), - ], + policies: [policy("global", 1, true, 20), policy("project", 1, true, 20)], usage: EMPTY_USAGE, }; await budget.reserve(input); await budget.settle({ network: input.network, - organizationId: input.organizationId, projectId: input.projectId, hourBucket: input.hourBucket, dayBucket: input.dayBucket, @@ -1376,7 +1240,6 @@ describe("SponsorshipBudgetRedis", () => { }); await budget.settle({ network: input.network, - organizationId: input.organizationId, projectId: input.projectId, hourBucket: input.hourBucket, dayBucket: input.dayBucket, diff --git a/apps/sdp-api/src/runtime/sponsorship-budget-redis.ts b/apps/sdp-api/src/runtime/sponsorship-budget-redis.ts index 55b81afe52..dfb8db8c37 100644 --- a/apps/sdp-api/src/runtime/sponsorship-budget-redis.ts +++ b/apps/sdp-api/src/runtime/sponsorship-budget-redis.ts @@ -280,22 +280,14 @@ export class SponsorshipBudgetRedis { ): Promise<"admitted" | "duplicate" | "denied" | "stale_policy"> { const client = await this.getClient(); const keys = this.keys(input); - const fields = this.fields(input.organizationId, input.projectId); - const hourUsage = [ - input.usage.hour.global, - input.usage.hour.organization, - input.usage.hour.project, - ]; - const dayUsage = [ - input.usage.day.global, - input.usage.day.organization, - input.usage.day.project, - ]; + const fields = this.fields(input.projectId); + const hourUsage = [input.usage.hour.global, input.usage.hour.project]; + const dayUsage = [input.usage.day.global, input.usage.day.project]; const liveReservations = input.liveReservations ?? { hour: [], day: [] }; const prefix = `sdp:sponsorship:{${input.network}}`; const reservationMarkerArgs = (reservations: SponsorshipLiveWindowReservation[]) => reservations.flatMap((reservation) => { - const scopeFields = this.fields(reservation.organizationId, reservation.projectId); + const scopeFields = this.fields(reservation.projectId); return [ this.ownershipField({ reservationId: reservation.id, attempt: reservation.attempt }), reservation.reservedLamports, @@ -326,7 +318,7 @@ export class SponsorshipBudgetRedis { await Promise.all(input.policies.map((policy) => this.syncPolicy(policy))); const args: Array = [input.amount, fields.length]; for (let index = 0; index < fields.length; index += 1) { - const scopeType = index === 0 ? "global" : index === 1 ? "organization" : "project"; + const scopeType = index === 0 ? "global" : "project"; const policy = policyByScope.get(scopeType); if (!policy) throw new Error(`Missing ${scopeType} sponsorship budget policy`); args.push( @@ -377,8 +369,7 @@ export class SponsorshipBudgetRedis { } private scopeTypeAt(index: number): SponsorshipBudgetScopeType { - if (index === 1) return "global"; - return index === 2 ? "organization" : "project"; + return index === 1 ? "global" : "project"; } async cancel(input: Omit): Promise { @@ -389,7 +380,7 @@ export class SponsorshipBudgetRedis { "cancel", CANCEL_LUA, [keys.hour, keys.day, keys.reservation], - [this.ownershipField(input), ...this.fields(input.organizationId, input.projectId)] + [this.ownershipField(input), ...this.fields(input.projectId)] ); if (Number(result) === -1 || Number(result) === -2) { throw new Error("Sponsorship budget counter invariant violated during compensation"); @@ -427,7 +418,6 @@ export class SponsorshipBudgetRedis { async settle(input: { network: SponsorshipNetwork; - organizationId: string; projectId: string; hourBucket: string; dayBucket: string; @@ -448,8 +438,8 @@ export class SponsorshipBudgetRedis { [ input.reservedLamports, input.actualLamports, - this.fields(input.organizationId, input.projectId).length, - ...this.fields(input.organizationId, input.projectId), + this.fields(input.projectId).length, + ...this.fields(input.projectId), this.ttlUntilNextDay(), input.detectMissingReservation ? 1 : 0, this.ownershipField(input), @@ -461,8 +451,8 @@ export class SponsorshipBudgetRedis { return Number(result[1]); } - private fields(organizationId: string, projectId: string): string[] { - return ["global", `organization:${organizationId}`, `project:${projectId}`]; + private fields(projectId: string): string[] { + return ["global", `project:${projectId}`]; } private getClient(): Promise { diff --git a/apps/sdp-api/src/services/jobs/reconcile-sponsorship-budgets.node.test.ts b/apps/sdp-api/src/services/jobs/reconcile-sponsorship-budgets.node.test.ts index 5a22d3c3a5..56116cb8c2 100644 --- a/apps/sdp-api/src/services/jobs/reconcile-sponsorship-budgets.node.test.ts +++ b/apps/sdp-api/src/services/jobs/reconcile-sponsorship-budgets.node.test.ts @@ -95,7 +95,6 @@ function reservation( actualLamports: null, attempt: 1, network: "devnet", - organizationId: "org_1", projectId: "project_1", feePayer: PROVIDER_CONFIGURATION.signerAddress, providerConfigFingerprint: sponsorshipProviderConfigFingerprint(PROVIDER_CONFIGURATION), diff --git a/apps/sdp-api/src/services/jobs/reconcile-sponsorship-budgets.ts b/apps/sdp-api/src/services/jobs/reconcile-sponsorship-budgets.ts index 32e8a48fe7..7b0148fc15 100644 --- a/apps/sdp-api/src/services/jobs/reconcile-sponsorship-budgets.ts +++ b/apps/sdp-api/src/services/jobs/reconcile-sponsorship-budgets.ts @@ -416,7 +416,6 @@ async function syncRedisSettlement( try { await budgetRedis.settle({ network: reservation.network, - organizationId: reservation.organizationId, projectId: reservation.projectId, hourBucket: reservation.hourBucket, dayBucket: reservation.dayBucket, diff --git a/apps/sdp-api/src/services/sponsorship-budget-operator.test.ts b/apps/sdp-api/src/services/sponsorship-budget-operator.test.ts deleted file mode 100644 index c6b9a39f5b..0000000000 --- a/apps/sdp-api/src/services/sponsorship-budget-operator.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - isPolicyControlInSync, - parseLamportAmount, - parseSponsorshipNetwork, - parseSponsorshipPolicyLimits, - parseSponsorshipScope, - toRedisLuaSafePolicyLimits, -} from "./sponsorship-budget-operator"; - -describe("sponsorship budget operator validation", () => { - it.each(["1e6", "1.0", "+1", "-1", "01", " 1", "1 ", "", undefined])( - "rejects non-canonical lamport input %s", - (value) => { - expect(() => parseLamportAmount(value, "hourly-lamports")).toThrow(); - } - ); - - it("parses canonical decimal strings as bigint without rounding", () => { - expect(parseLamportAmount("0", "hourly-lamports")).toBe(0n); - expect(parseLamportAmount("10000000", "hourly-lamports")).toBe(10_000_000n); - expect(parseLamportAmount("9007199254740993", "daily-lamports")).toBe(9_007_199_254_740_993n); - }); - - it("validates limit ordering as bigint before safe-number conversion", () => { - expect(() => - parseSponsorshipPolicyLimits({ - perTransactionLamports: "9007199254740993", - hourlyLamports: "9007199254740992", - dailyLamports: "9007199254740994", - }) - ).toThrow("--hourly-lamports must be at least"); - expect(() => - parseSponsorshipPolicyLimits({ - perTransactionLamports: "1", - hourlyLamports: "9007199254740994", - dailyLamports: "9007199254740993", - }) - ).toThrow("--daily-lamports must be at least"); - }); - - it("converts only safe bigint limits at the Redis-Lua boundary", () => { - expect( - toRedisLuaSafePolicyLimits({ - perTransactionLamports: 10_000_000n, - hourlyLamports: 1_000_000_000n, - dailyLamports: 3_000_000_000n, - }) - ).toEqual({ - perTransactionLamports: 10_000_000, - hourlyLamports: 1_000_000_000, - dailyLamports: 3_000_000_000, - }); - expect(() => - toRedisLuaSafePolicyLimits({ - perTransactionLamports: 1n, - hourlyLamports: 2n, - dailyLamports: 9_007_199_254_740_992n, - }) - ).toThrow("Redis-Lua safe integer range"); - }); - - it("requires an explicit valid network", () => { - expect(parseSponsorshipNetwork("devnet")).toBe("devnet"); - expect(parseSponsorshipNetwork("mainnet")).toBe("mainnet"); - expect(() => parseSponsorshipNetwork(undefined)).toThrow("--network is required"); - expect(() => parseSponsorshipNetwork("mainnet-beta")).toThrow("--network is required"); - }); - - it("rejects global scope IDs and keeps omitted tenant IDs as defaults", () => { - expect(parseSponsorshipScope(undefined, undefined)).toEqual({ - scopeType: "global", - scopeId: null, - }); - expect(() => parseSponsorshipScope("global", "org_1")).toThrow( - "--scope-id is not valid for global" - ); - expect(parseSponsorshipScope("organization", undefined)).toEqual({ - scopeType: "organization", - scopeId: null, - }); - expect(parseSponsorshipScope("project", "project_1")).toEqual({ - scopeType: "project", - scopeId: "project_1", - }); - expect(() => parseSponsorshipScope("project", "")).toThrow("cannot be empty"); - }); - - it("detects missing, stale, and enabled-state Redis drift", () => { - const policy = { version: 3, enabled: true }; - expect(isPolicyControlInSync(policy, null)).toBe(false); - expect(isPolicyControlInSync(policy, { version: 2, enabled: true })).toBe(false); - expect(isPolicyControlInSync(policy, { version: 3, enabled: false })).toBe(false); - expect(isPolicyControlInSync(policy, { version: 3, enabled: true })).toBe(true); - }); -}); diff --git a/apps/sdp-api/src/services/sponsorship-budget-operator.ts b/apps/sdp-api/src/services/sponsorship-budget-operator.ts deleted file mode 100644 index d9d2b2fac2..0000000000 --- a/apps/sdp-api/src/services/sponsorship-budget-operator.ts +++ /dev/null @@ -1,86 +0,0 @@ -import type { - SponsorshipBudgetScopeType, - SponsorshipNetwork, -} from "@/db/repositories/sponsorship-budget.repository"; - -const MAX_REDIS_LUA_SAFE_LAMPORTS = BigInt(Number.MAX_SAFE_INTEGER); - -export interface SponsorshipPolicyLimitsBigInt { - perTransactionLamports: bigint; - hourlyLamports: bigint; - dailyLamports: bigint; -} - -export function parseLamportAmount(value: string | undefined, flagName: string): bigint { - if (!value || !/^(0|[1-9][0-9]*)$/.test(value)) { - throw new Error(`--${flagName} must be a canonical non-negative integer`); - } - return BigInt(value); -} - -export function parseSponsorshipPolicyLimits(input: { - perTransactionLamports: string | undefined; - hourlyLamports: string | undefined; - dailyLamports: string | undefined; -}): SponsorshipPolicyLimitsBigInt { - const limits = { - perTransactionLamports: parseLamportAmount(input.perTransactionLamports, "per-tx-lamports"), - hourlyLamports: parseLamportAmount(input.hourlyLamports, "hourly-lamports"), - dailyLamports: parseLamportAmount(input.dailyLamports, "daily-lamports"), - }; - if (limits.hourlyLamports < limits.perTransactionLamports) { - throw new Error("--hourly-lamports must be at least --per-tx-lamports"); - } - if (limits.dailyLamports < limits.hourlyLamports) { - throw new Error("--daily-lamports must be at least --hourly-lamports"); - } - return limits; -} - -/** Convert only at the DB/Redis-Lua boundary, after all bigint validation. */ -export function toRedisLuaSafePolicyLimits(limits: SponsorshipPolicyLimitsBigInt): { - perTransactionLamports: number; - hourlyLamports: number; - dailyLamports: number; -} { - for (const [name, value] of Object.entries(limits)) { - if (value > MAX_REDIS_LUA_SAFE_LAMPORTS) { - throw new Error(`${name} exceeds the Redis-Lua safe integer range`); - } - } - return { - perTransactionLamports: Number(limits.perTransactionLamports), - hourlyLamports: Number(limits.hourlyLamports), - dailyLamports: Number(limits.dailyLamports), - }; -} - -export function parseSponsorshipNetwork(value: string | undefined): SponsorshipNetwork { - if (value !== "devnet" && value !== "mainnet") { - throw new Error("--network is required and must be devnet or mainnet"); - } - return value; -} - -export function parseSponsorshipScope( - scopeValue: string | undefined, - scopeIdValue: string | undefined -): { scopeType: SponsorshipBudgetScopeType; scopeId: string | null } { - const scopeType = scopeValue ?? "global"; - if (scopeType !== "global" && scopeType !== "organization" && scopeType !== "project") { - throw new Error("--scope must be global, organization, or project"); - } - if (scopeType === "global") { - if (scopeIdValue !== undefined) throw new Error("--scope-id is not valid for global scope"); - return { scopeType, scopeId: null }; - } - if (scopeIdValue === "") throw new Error("--scope-id cannot be empty when provided"); - return { scopeType, scopeId: scopeIdValue ?? null }; -} - -export function isPolicyControlInSync( - policy: { version: number; enabled: boolean }, - control: { version: number; enabled: boolean } | null -): boolean { - return control?.version === policy.version && control.enabled === policy.enabled; -} diff --git a/apps/sdp-api/src/services/sponsorship-budget.service.node.test.ts b/apps/sdp-api/src/services/sponsorship-budget.service.node.test.ts index ea451df2f2..ab57dc6bb2 100644 --- a/apps/sdp-api/src/services/sponsorship-budget.service.node.test.ts +++ b/apps/sdp-api/src/services/sponsorship-budget.service.node.test.ts @@ -94,13 +94,11 @@ function policy( function harnessParts() { const repository = { - resolvePolicies: vi - .fn() - .mockResolvedValue([policy("global"), policy("organization"), policy("project")]), + resolvePolicies: vi.fn().mockResolvedValue([policy("global"), policy("project")]), loadWindowAdmissionSnapshot: vi.fn().mockResolvedValue({ usage: { - hour: { global: 0, organization: 0, project: 0 }, - day: { global: 0, organization: 0, project: 0 }, + hour: { global: 0, project: 0 }, + day: { global: 0, project: 0 }, }, liveReservations: { hour: [], day: [] }, }), @@ -200,7 +198,6 @@ describe("BudgetedFeePayment", () => { readMoneyAdmissionFacts.mockResolvedValue(ACTIVE_ENTITLED_PRODUCTION); repository.resolvePolicies.mockResolvedValue([ { ...policy("global"), network: "mainnet" }, - { ...policy("organization"), network: "mainnet" }, { ...policy("project"), network: "mainnet" }, ]); @@ -226,7 +223,6 @@ describe("BudgetedFeePayment", () => { const { feePayment, provider, repository, budgetRedis } = harness(); repository.resolvePolicies.mockResolvedValueOnce([ { ...policy("global"), perTransactionLamports: 1 }, - { ...policy("organization"), perTransactionLamports: 1 }, { ...policy("project"), perTransactionLamports: 1 }, ]); budgetRedis.reserve.mockResolvedValueOnce("denied"); @@ -256,7 +252,6 @@ describe("BudgetedFeePayment", () => { }); repository.resolvePolicies.mockResolvedValueOnce([ { ...policy("global"), perTransactionLamports: 1_000_000 }, - { ...policy("organization"), perTransactionLamports: 1_000_000 }, { ...policy("project"), perTransactionLamports: 1_000_000 }, ]); budgetRedis.reserve.mockImplementationOnce(async (input) => @@ -315,8 +310,8 @@ describe("BudgetedFeePayment", () => { const { feePayment, provider, repository, budgetRedis } = harness(); budgetRedis.reserve.mockResolvedValueOnce("stale_policy"); repository.resolvePolicies - .mockResolvedValueOnce([policy("global"), policy("organization"), policy("project")]) - .mockResolvedValue([policy("global", false), policy("organization"), policy("project")]); + .mockResolvedValueOnce([policy("global"), policy("project")]) + .mockResolvedValue([policy("global", false), policy("project")]); await expect(feePayment.signAsFeePayer(buildTransaction())).rejects.toMatchObject({ code: "PROVIDER_NOT_AVAILABLE", @@ -467,8 +462,8 @@ describe("BudgetedFeePayment", () => { it("releases an owned submission killed after signing before anything is persisted", async () => { const { feePayment, repository, budgetRedis } = harness(); repository.resolvePolicies - .mockResolvedValueOnce([policy("global"), policy("organization"), policy("project")]) - .mockResolvedValueOnce([policy("global", false), policy("organization"), policy("project")]); + .mockResolvedValueOnce([policy("global"), policy("project")]) + .mockResolvedValueOnce([policy("global", false), policy("project")]); const lifecycle = { persistSigned: vi.fn().mockResolvedValue(undefined), markStarted: vi.fn(), diff --git a/apps/sdp-api/src/services/sponsorship-budget.service.ts b/apps/sdp-api/src/services/sponsorship-budget.service.ts index ff57ee9f5e..62c3aac3d5 100644 --- a/apps/sdp-api/src/services/sponsorship-budget.service.ts +++ b/apps/sdp-api/src/services/sponsorship-budget.service.ts @@ -573,7 +573,6 @@ export class BudgetedFeePayment implements SponsorshipFeePayment { try { policies = await this.repository.resolvePolicies({ network, - organizationId: this.scope.organizationId, projectId: this.scope.projectId, }); } catch (error) { diff --git a/apps/sdp-api/src/sponsorship-budget-operator.test.ts b/apps/sdp-api/src/sponsorship-budget-operator.test.ts new file mode 100644 index 0000000000..4d9a0760be --- /dev/null +++ b/apps/sdp-api/src/sponsorship-budget-operator.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from "vitest"; +import { + isPolicyControlInSync, + parseLamportAmount, + parseSponsorshipBudgetCommand, + parseSponsorshipPolicyLimits, + toRedisLuaSafePolicyLimits, +} from "@/scripts/lib/sponsorship-budget-operator"; + +const SET_PROJECT_FLAGS = { + environment: "production", + mode: "apply", + operator: "oncall@example.test", + reason: "raise launch limits", + "organization-id": "org_operator_1", + "per-tx-lamports": "10000000", + "hourly-lamports": "1000000000", + "daily-lamports": "3000000000", + enabled: "true", + "expected-version": "4", +}; + +describe("sponsorship budget operator validation", () => { + it.each(["1e6", "1.0", "+1", "-1", "01", " 1", "1 ", "", undefined])( + "rejects non-canonical lamport input %s", + (value) => { + expect(() => parseLamportAmount(value, "hourly-lamports")).toThrow(); + } + ); + + it("parses canonical decimal strings as bigint without rounding", () => { + expect(parseLamportAmount("0", "hourly-lamports")).toBe(0n); + expect(parseLamportAmount("10000000", "hourly-lamports")).toBe(10_000_000n); + expect(parseLamportAmount("9007199254740993", "daily-lamports")).toBe(9_007_199_254_740_993n); + }); + + it("validates limit ordering as bigint before safe-number conversion", () => { + expect(() => + parseSponsorshipPolicyLimits({ + perTransactionLamports: "9007199254740993", + hourlyLamports: "9007199254740992", + dailyLamports: "9007199254740994", + }) + ).toThrow("--hourly-lamports must be at least"); + expect(() => + parseSponsorshipPolicyLimits({ + perTransactionLamports: "1", + hourlyLamports: "9007199254740994", + dailyLamports: "9007199254740993", + }) + ).toThrow("--daily-lamports must be at least"); + }); + + it("converts only safe bigint limits at the Redis-Lua boundary", () => { + expect( + toRedisLuaSafePolicyLimits({ + perTransactionLamports: 10_000_000n, + hourlyLamports: 1_000_000_000n, + dailyLamports: 3_000_000_000n, + }) + ).toEqual({ + perTransactionLamports: 10_000_000, + hourlyLamports: 1_000_000_000, + dailyLamports: 3_000_000_000, + }); + expect(() => + toRedisLuaSafePolicyLimits({ + perTransactionLamports: 1n, + hourlyLamports: 2n, + dailyLamports: 9_007_199_254_740_992n, + }) + ).toThrow("Redis-Lua safe integer range"); + }); + + it("detects missing, stale, and enabled-state Redis drift", () => { + const policy = { version: 3, enabled: true }; + expect(isPolicyControlInSync(policy, null)).toBe(false); + expect(isPolicyControlInSync(policy, { version: 2, enabled: true })).toBe(false); + expect(isPolicyControlInSync(policy, { version: 3, enabled: false })).toBe(false); + expect(isPolicyControlInSync(policy, { version: 3, enabled: true })).toBe(true); + }); +}); + +describe("parseSponsorshipBudgetCommand", () => { + it("parses a production set-project apply onto the mainnet budget", () => { + expect( + parseSponsorshipBudgetCommand({ command: "set-project", flags: SET_PROJECT_FLAGS }) + ).toEqual({ + command: "set-project", + environment: "production", + organizationId: "org_operator_1", + network: "mainnet", + mode: "apply", + operator: "oncall@example.test", + reason: "raise launch limits", + enabled: true, + expectedVersion: 4, + perTransactionLamports: 10_000_000, + hourlyLamports: 1_000_000_000, + dailyLamports: 3_000_000_000, + }); + }); + + it("parses a set-project preview that names no version", () => { + const flags = Object.fromEntries( + Object.entries({ ...SET_PROJECT_FLAGS, mode: "preview" }).filter( + ([name]) => name !== "expected-version" + ) + ); + expect(parseSponsorshipBudgetCommand({ command: "set-project", flags })).toEqual({ + command: "set-project", + environment: "production", + organizationId: "org_operator_1", + network: "mainnet", + mode: "preview", + operator: "oncall@example.test", + reason: "raise launch limits", + enabled: true, + expectedVersion: null, + perTransactionLamports: 10_000_000, + hourlyLamports: 1_000_000_000, + dailyLamports: 3_000_000_000, + }); + }); + + it("parses a sandbox kill onto the devnet budget without a version", () => { + expect( + parseSponsorshipBudgetCommand({ + command: "kill", + flags: { + environment: "sandbox", + mode: "preview", + operator: "oncall@example.test", + reason: "incident", + }, + }) + ).toEqual({ + command: "kill", + network: "devnet", + mode: "preview", + operator: "oncall@example.test", + reason: "incident", + }); + }); + + it("refuses a flag the command does not take", () => { + expect(() => + parseSponsorshipBudgetCommand({ + command: "set-project", + flags: { ...SET_PROJECT_FLAGS, network: "mainnet" }, + }) + ).toThrow('Unrecognized key: "network"'); + }); + + it.each([ + { flag: "mode", message: "--mode must be preview or apply" }, + { flag: "expected-version", message: "--expected-version is required" }, + { flag: "enabled", message: "--enabled must be true or false" }, + { flag: "organization-id", message: "--organization-id is required" }, + ])("refuses a set-project without --$flag", ({ flag, message }) => { + const flags = Object.fromEntries( + Object.entries(SET_PROJECT_FLAGS).filter(([name]) => name !== flag) + ); + expect(() => parseSponsorshipBudgetCommand({ command: "set-project", flags })).toThrow(message); + }); + + it("refuses a resume that names no version", () => { + expect(() => + parseSponsorshipBudgetCommand({ + command: "resume", + flags: { + environment: "production", + mode: "apply", + operator: "oncall@example.test", + reason: "incident resolved", + }, + }) + ).toThrow("--expected-version is required"); + }); +}); diff --git a/apps/sdp-api/src/test/mocks/db.ts b/apps/sdp-api/src/test/mocks/db.ts index 87a9df38b2..634f1ed763 100644 --- a/apps/sdp-api/src/test/mocks/db.ts +++ b/apps/sdp-api/src/test/mocks/db.ts @@ -113,10 +113,8 @@ async function seedSponsorshipBudgetPolicies(env: Env): Promise { version, updated_by, update_reason ) VALUES ('sbp_devnet_global', 'devnet', 'global', NULL, TRUE, 10000000, 2000000000, 10000000000, 1, 'test-seed', 'Reset devnet sponsorship controls'), - ('sbp_devnet_org_default', 'devnet', 'organization', NULL, TRUE, 10000000, 1000000000, 5000000000, 1, 'test-seed', 'Reset devnet organization default'), ('sbp_devnet_project_default', 'devnet', 'project', NULL, TRUE, 10000000, 1000000000, 3000000000, 1, 'test-seed', 'Reset devnet project default'), ('sbp_mainnet_global', 'mainnet', 'global', NULL, FALSE, 10000000, 500000000, 1000000000, 1, 'test-seed', 'Reset mainnet sponsorship controls'), - ('sbp_mainnet_org_default', 'mainnet', 'organization', NULL, TRUE, 10000000, 250000000, 500000000, 1, 'test-seed', 'Reset mainnet organization default'), ('sbp_mainnet_project_default', 'mainnet', 'project', NULL, TRUE, 10000000, 100000000, 250000000, 1, 'test-seed', 'Reset mainnet project default')` ); await db.execute( diff --git a/docs/contributing/code-standards.md b/docs/contributing/code-standards.md index d13c92ef13..f5276fdf9c 100644 --- a/docs/contributing/code-standards.md +++ b/docs/contributing/code-standards.md @@ -54,7 +54,7 @@ Kora simulates against its own Solana RPC (`KORA__RPC_URL`); the budget **Sponsorship budget** (`apps/sdp-api/src/services/sponsorship-budget.service.ts`): every sponsored signature is admitted against lamport limits before Kora signs. -- **Policies** exist per network (devnet, mainnet) at three scopes (global, organization, project), each with per-transaction, hourly and daily lamport limits. All three must allow the spend. +- **Policies** exist per network (devnet, mainnet) at two scopes, global and project, each with per-transaction, hourly and daily lamport limits. A project is one organization's one environment, so its row is the per-tenant cap: an exact project row overrides the project default. Both must allow the spend. Operators change them only with `pnpm sponsorship:budget` (`apps/sdp-api/scripts/sponsorship-budget.ts`), which previews before it applies and refuses a write against a version the operator did not read. - **The ceiling** is the message's network fee, priced on Kora's upstream, plus Kora's `max_allowed_lamports`, always: the policy read is not bound to the signature, so the reservation assumes the worst outflow Kora allows and reconciliation settles the actual lamports. - **A reservation** (a Postgres ledger row plus Redis window counters) moves `reserved → signed → submitted → committed`. It is `released` when the send is provably refused or the transaction provably never landed (the `finalized` block height passed the reservation's last valid block height and its signature is absent from history), and `charged_unknown` when the outcome can't be proven: counted, never refunded. - **The reconciler cron** settles actual lamports from the chain through the chain reads. A ledger or Redis failure trips the network's breaker, which refuses sponsorship until it recovers. A reservation pins Kora's signer and policy fingerprint; a mismatch fails closed. diff --git a/docs/decisions/0004-earn-volume-caps.md b/docs/decisions/0004-earn-volume-caps.md index e1e2a6c3e8..b0b86293e6 100644 --- a/docs/decisions/0004-earn-volume-caps.md +++ b/docs/decisions/0004-earn-volume-caps.md @@ -127,7 +127,7 @@ Reuses the policy engine rather than adding a parallel limits system. non-failed operation amounts for a scope (org, wallet, or API key), with `window`, `max` and `assets`. Prior art is the sponsorship budget's per-transaction / hourly / daily limits - (`sponsorship-budget-operator.ts`). Implementation note (PRO-1933): the sum + (`apps/sdp-api/src/db/repositories/sponsorship-budget.repository.ts`). Implementation note (PRO-1933): the sum runs over `wallet_operations`, the generic ledger every policy-gated route writes, not `earn_movements`, which is what makes the rule reusable by payments unchanged. On breach the rule's `action` is the decision (default diff --git a/packages/sdp-api-integration/src/tests/kora-flow.test.ts b/packages/sdp-api-integration/src/tests/kora-flow.test.ts index 4adaa12195..5cd76fc40e 100644 --- a/packages/sdp-api-integration/src/tests/kora-flow.test.ts +++ b/packages/sdp-api-integration/src/tests/kora-flow.test.ts @@ -136,21 +136,17 @@ describe("Kora Fee Payment (Live Smoke)", () => { scopeId: null, hourlyLamports: 2_000_000_000, dailyLamports: 10_000_000_000, - }, - { - scopeType: "organization" as const, - scopeId: TEST_ORG.id, - hourlyLamports: 1_000_000_000, - dailyLamports: 5_000_000_000, + expectedVersion: 1, }, { scopeType: "project" as const, scopeId: TEST_PROJECT.id, hourlyLamports: 1_000_000_000, dailyLamports: 3_000_000_000, + expectedVersion: 0, }, ]) { - await repository.upsertPolicy({ + const written = await repository.writePolicyAtVersion({ network: "devnet", ...policy, enabled: true, @@ -158,10 +154,10 @@ describe("Kora Fee Payment (Live Smoke)", () => { operator: KORA_LIVE_SMOKE_POLICY_OPERATOR, reason: KORA_LIVE_SMOKE_POLICY_REASON, }); + expect(written).not.toBeNull(); } liveSmokePolicies = await repository.resolvePolicies({ network: "devnet", - organizationId: TEST_ORG.id, projectId: TEST_PROJECT.id, }); await createSigningService(env).initializePrivySigning(TEST_ORG.id, TEST_PROJECT.id, { @@ -174,11 +170,7 @@ describe("Kora Fee Payment (Live Smoke)", () => { }); it("uses audited budget overrides for every live-smoke scope", () => { - expect(liveSmokePolicies.map((policy) => policy.scopeType)).toEqual([ - "global", - "organization", - "project", - ]); + expect(liveSmokePolicies.map((policy) => policy.scopeType)).toEqual(["global", "project"]); for (const policy of liveSmokePolicies) { expect(policy.perTransactionLamports).toBe(KORA_LIVE_SMOKE_PER_TRANSACTION_LAMPORTS); expect(policy.updatedBy).toBe(KORA_LIVE_SMOKE_POLICY_OPERATOR);