[codex] separate billing configuration (#479)

* feat(admin): separate billing configuration

Add dedicated storage egress and downloader credit billing contracts, usecases, RPC wrappers, drawers, generated client updates, and coverage.

Agent-Profile: https://agent-kanban.dev/agents/2673e70e0085f4e0

* fix(billing): preserve not found ordering

Check storage and downloader existence before quota_store gating in dedicated billing usecases, and cover enabled missing-resource requests at usecase and route levels.

Agent-Profile: https://agent-kanban.dev/agents/2673e70e0085f4e0

---------

Co-authored-by: Jordan Park <jordan-park@mails.agent-kanban.dev>
This commit is contained in:
agent-kanban-local[bot]
2026-06-24 05:57:10 -04:00
committed by GitHub
co-authored by Jordan Park
parent 82c5452782
commit f41ed27bba
26 changed files with 1478 additions and 198 deletions
@@ -1875,4 +1875,68 @@ describe('Downloaders — free plan limit', () => {
expect((await postDownloader(app, admin, 'first')).status).toBe(201)
expect((await postDownloader(app, admin, 'second')).status).toBe(201)
})
it('updates downloader credit billing through the dedicated route [spec: downloaders/credit-billing]', async () => {
const { app, db } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' })
await seedBusinessLicense(db)
const admin = await adminHeaders(app)
const createRes = await postDownloader(app, admin, 'billable')
const created = (await createRes.json()) as { downloader: { id: string } }
const res = await app.request(`/api/downloads/downloaders/${created.downloader.id}/credit-billing`, {
method: 'PUT',
headers: { ...admin, 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: true, unitBytes: 2048, creditsPerUnit: 3 }),
})
expect(res.status).toBe(200)
const body = (await res.json()) as Downloader
expect(body.remoteDownloadCreditBillingEnabled).toBe(true)
expect(body.remoteDownloadCreditUnitBytes).toBe(2048)
expect(body.remoteDownloadCreditPerUnit).toBe(3)
})
it('returns 402 when enabling downloader credit billing without quota_store', async () => {
const { app } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' })
const admin = await adminHeaders(app)
const createRes = await postDownloader(app, admin, 'blocked-billing')
const created = (await createRes.json()) as { downloader: { id: string } }
const res = await app.request(`/api/downloads/downloaders/${created.downloader.id}/credit-billing`, {
method: 'PUT',
headers: { ...admin, 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: true, unitBytes: 2048, creditsPerUnit: 3 }),
})
expect(res.status).toBe(402)
const body = (await res.json()) as {
error: { message: string; details: { reason: string; metadata: Record<string, string> }[] }
}
expect(body.error.message).toBe('Feature not available')
expect(body.error.details[0].reason).toBe('FEATURE_NOT_AVAILABLE')
expect(body.error.details[0].metadata.feature).toBe('quota_store')
})
it('returns 404 from downloader credit billing when disabled for a missing downloader', async () => {
const { app } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' })
const admin = await adminHeaders(app)
const res = await app.request('/api/downloads/downloaders/missing/credit-billing', {
method: 'PUT',
headers: { ...admin, 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: false, unitBytes: 2048, creditsPerUnit: 3 }),
})
expect(res.status).toBe(404)
})
it('returns 404 from downloader credit billing when enabled for a missing downloader without quota_store', async () => {
const { app, db } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' })
await seedProLicense(db)
const admin = await adminHeaders(app)
const res = await app.request('/api/downloads/downloaders/missing/credit-billing', {
method: 'PUT',
headers: { ...admin, 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: true, unitBytes: 2048, creditsPerUnit: 3 }),
})
expect(res.status).toBe(404)
})
})
+21
View File
@@ -5,6 +5,7 @@ import {
downloaderHeartbeatSchema,
downloaderSchema,
pageSchema,
updateDownloaderCreditBillingSchema,
updateDownloaderSchema,
} from '@shared/schemas'
import { FREE_DOWNLOADER_LIMIT } from '../../../shared/constants'
@@ -17,6 +18,7 @@ import {
listDownloaders,
recordDownloaderHeartbeat,
updateDownloader,
updateDownloaderCreditBilling,
} from '../../usecases/downloads/downloads'
import { featureBlocked, unauthorized } from '../../usecases/ports'
import { loadBindingState } from '../../usecases/site/licensing'
@@ -67,6 +69,21 @@ const updateRoute = createRoute({
},
})
const updateCreditBillingRoute = createRoute({
operationId: 'updateDownloaderCreditBilling',
summary: 'Update downloader credit billing',
tags: ['Downloaders'],
method: 'put',
path: '/{id}/credit-billing',
middleware: [requireAdmin] as const,
request: { params: z.object({ id: z.string() }), ...jsonBody(updateDownloaderCreditBillingSchema) },
responses: {
200: jsonContent(downloaderSchema, 'Updated downloader'),
402: errorResponse('Feature not available'),
404: errorResponse('Not found'),
},
})
const deleteRoute = createRoute({
operationId: 'deleteDownloader',
summary: 'Delete downloader',
@@ -134,6 +151,10 @@ const downloadersRoute = new OpenAPIHono<Env>()
}
return c.json(await updateDownloader(c.get('deps'), id, input), 200)
})
.openapi(updateCreditBillingRoute, async (c) => {
const { id } = c.req.valid('param')
return c.json(await updateDownloaderCreditBilling(c.get('deps'), id, c.req.valid('json')), 200)
})
.openapi(deleteRoute, async (c) => {
const { id } = c.req.valid('param')
await deleteDownloader(c.get('deps'), id)
+23
View File
@@ -152,6 +152,29 @@ describe('[CF] Admin Storages API', () => {
expect(body.title).toBe('Updated CF S3')
})
it('PUT /api/site/storages/:id/egress-billing enforces quota_store for enabling', async () => {
const app = await buildApp()
const headers = await adminHeaders(app)
const platform = createCloudflarePlatform(env)
const created = await createStorageRepo(platform.db).create({
...validStorage,
title: `CF Egress Billing ${Date.now()}`,
bucket: `cf-egress-billing-${Date.now()}`,
})
const res = await app.request(`/api/site/storages/${created.id}/egress-billing`, {
method: 'PUT',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: true, unitBytes: 1024, creditsPerUnit: 2 }),
})
expect(res.status).toBe(402)
const body = (await res.json()) as {
error: { details: Array<{ reason: string; metadata: Record<string, string> }> }
}
expect(body.error.details[0].reason).toBe('FEATURE_NOT_AVAILABLE')
expect(body.error.details[0].metadata.feature).toBe('quota_store')
})
it('DELETE /api/site/storages/:id deletes a storage', async () => {
const app = await buildApp()
const headers = await adminHeaders(app)
+73 -1
View File
@@ -2,7 +2,7 @@ import { FREE_STORAGE_LIMIT } from '@shared/constants'
import { sql } from 'drizzle-orm'
import { describe, expect, it } from 'vitest'
import { createStorageRepo } from '../../adapters/repos/storage.js'
import { adminHeaders, authedHeaders, createTestApp } from '../../test/setup.js'
import { adminHeaders, authedHeaders, createTestApp, seedBusinessLicense, seedProLicense } from '../../test/setup.js'
const validStorage = {
title: 'Test S3',
@@ -189,6 +189,78 @@ describe('Admin Storages API', () => {
expect(res.status).toBe(404)
})
it('PUT /:id/egress-billing updates storage credits billing [spec: storages/egress-billing]', async () => {
const { app, db } = await createTestApp()
await seedBusinessLicense(db)
const headers = await adminHeaders(app)
const createRes = await app.request('/api/site/storages', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify(validStorage),
})
const created = (await createRes.json()) as { id: string }
const res = await app.request(`/api/site/storages/${created.id}/egress-billing`, {
method: 'PUT',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: true, unitBytes: 1024, creditsPerUnit: 2 }),
})
expect(res.status).toBe(200)
const body = (await res.json()) as Record<string, unknown>
expect(body.egressCreditBillingEnabled).toBe(true)
expect(body.egressCreditUnitBytes).toBe(1024)
expect(body.egressCreditPerUnit).toBe(2)
})
it('PUT /:id/egress-billing returns 402 when quota_store is unavailable', async () => {
const { app } = await createTestApp()
const headers = await adminHeaders(app)
const createRes = await app.request('/api/site/storages', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify(validStorage),
})
const created = (await createRes.json()) as { id: string }
const res = await app.request(`/api/site/storages/${created.id}/egress-billing`, {
method: 'PUT',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: true, unitBytes: 1024, creditsPerUnit: 2 }),
})
expect(res.status).toBe(402)
const body = (await res.json()) as {
error: { message: string; details: Array<{ reason: string; metadata: Record<string, string> }> }
}
expect(body.error.message).toBe('Feature not available')
expect(body.error.details[0].reason).toBe('FEATURE_NOT_AVAILABLE')
expect(body.error.details[0].metadata.feature).toBe('quota_store')
})
it('PUT /:id/egress-billing returns 404 for missing storage when disabled', async () => {
const { app } = await createTestApp()
const headers = await adminHeaders(app)
const res = await app.request('/api/site/storages/nonexistent/egress-billing', {
method: 'PUT',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: false, unitBytes: 1024, creditsPerUnit: 2 }),
})
expect(res.status).toBe(404)
})
it('PUT /:id/egress-billing returns 404 for missing storage when enabled without quota_store', async () => {
const { app, db } = await createTestApp()
await seedProLicense(db)
const headers = await adminHeaders(app)
const res = await app.request('/api/site/storages/nonexistent/egress-billing', {
method: 'PUT',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: true, unitBytes: 1024, creditsPerUnit: 2 }),
})
expect(res.status).toBe(404)
})
it('DELETE /:id deletes a storage [spec: storages/delete]', async () => {
const { app } = await createTestApp()
const headers = await adminHeaders(app)
+34 -2
View File
@@ -1,9 +1,16 @@
import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi'
import { createStorageSchema, pageSchema, updateStorageSchema } from '@shared/schemas'
import { createStorageSchema, pageSchema, updateStorageEgressBillingSchema, updateStorageSchema } from '@shared/schemas'
import { requireAdmin } from '../../middleware/auth'
import type { Env } from '../../middleware/platform'
import { type StorageRecord, storageNotFound } from '../../usecases/ports'
import { createStorage, deleteStorage, getStorage, listStorages, updateStorage } from '../../usecases/site/storage'
import {
createStorage,
deleteStorage,
getStorage,
listStorages,
updateStorage,
updateStorageEgressBilling,
} from '../../usecases/site/storage'
import { errorResponse, jsonBody, jsonContent } from '../openapi'
// Admin storage config. The response intentionally includes the S3 credentials
@@ -93,6 +100,21 @@ const updateStorageRoute = createRoute({
},
})
const updateStorageEgressBillingRoute = createRoute({
operationId: 'updateStorageEgressBilling',
summary: 'Update storage egress billing',
tags: ['Storages'],
method: 'put',
path: '/{id}/egress-billing',
middleware: [requireAdmin] as const,
request: { params: z.object({ id: z.string() }), ...jsonBody(updateStorageEgressBillingSchema) },
responses: {
200: jsonContent(storageSchema, 'Updated storage'),
402: errorResponse('Feature not available'),
404: errorResponse('Storage not found'),
},
})
const deleteStorageRoute = createRoute({
operationId: 'deleteStorage',
summary: 'Delete storage',
@@ -138,6 +160,16 @@ const storages = new OpenAPIHono<Env>()
if (!result.ok) throw result.error
return c.json(toStorageDTO(result.storage), 200)
})
.openapi(updateStorageEgressBillingRoute, async (c) => {
const result = await updateStorageEgressBilling(c.get('deps'), {
userId: c.get('userId')!,
orgId: c.get('orgId')!,
id: c.req.valid('param').id,
input: c.req.valid('json'),
})
if (!result.ok) throw result.error
return c.json(toStorageDTO(result.storage), 200)
})
.openapi(deleteStorageRoute, async (c) => {
const id = c.req.valid('param').id
const result = await deleteStorage(c.get('deps'), { userId: c.get('userId')!, orgId: c.get('orgId')!, id })
+155
View File
@@ -0,0 +1,155 @@
import type { BindingState, Downloader } from '@shared/types'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { DownloaderRecord, DownloaderRepo } from '../ports'
import { type AppError, DownloadError } from '../ports'
import { loadBindingState } from '../site/licensing'
import { type DownloadsDeps, updateDownloaderCreditBilling } from './downloads'
vi.mock('../site/licensing', () => ({ loadBindingState: vi.fn() }))
const PRO: BindingState = { bound: true, active: true, edition: 'pro' }
const BUSINESS: BindingState = { bound: true, active: true, edition: 'business' }
const downloader: Downloader = {
id: 'downloader-1',
name: 'Edge worker',
status: 'offline',
enabled: true,
version: '1.0.0',
hostname: 'edge-1',
platform: 'linux',
arch: 'amd64',
engine: 'aria2',
capabilities: ['http'],
maxConcurrentTasks: 2,
currentTasks: 0,
downloadBps: 0,
uploadBps: 0,
freeDiskBytes: 1024,
remoteDownloadCreditBillingEnabled: true,
remoteDownloadCreditUnitBytes: 1024,
remoteDownloadCreditPerUnit: 2,
lastHeartbeatAt: null,
createdBy: 'user-1',
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
}
const downloaderRecord = {
...downloader,
tokenHash: 'hash',
tokenJti: 'jti',
lastHeartbeatAt: null,
createdAt: new Date(downloader.createdAt),
updatedAt: new Date(downloader.updatedAt),
} satisfies DownloaderRecord
function makeDeps(downloaders: Partial<DownloaderRepo> = {}) {
const update = vi.fn(async () => {})
const repo: DownloaderRepo = {
insert: async () => {},
list: async () => [],
get: async () => downloader,
getRecord: async () => downloaderRecord,
findRecord: async () => downloaderRecord,
update,
recordHeartbeat: async () => {},
delete: async () => {},
listAssignmentCandidates: async () => [],
listStaleIds: async () => [],
listUnreachableIds: async () => [],
markStaleOffline: async () => {},
...downloaders,
}
return {
deps: {
downloaders: repo,
downloadTasks: {},
downloadTokens: {},
licenseBinding: {},
licensingCloud: {},
remoteDownloadUsage: {},
} as DownloadsDeps,
update,
}
}
beforeEach(() => vi.clearAllMocks())
describe('updateDownloaderCreditBilling', () => {
it('updates credit billing fields through the downloader repo', async () => {
vi.mocked(loadBindingState).mockResolvedValue(BUSINESS)
const { deps, update } = makeDeps()
const out = await updateDownloaderCreditBilling(deps, 'downloader-1', {
enabled: true,
unitBytes: 2048,
creditsPerUnit: 3,
})
expect(out).toBe(downloader)
expect(update).toHaveBeenCalledWith(
'downloader-1',
{
remoteDownloadCreditBillingEnabled: true,
remoteDownloadCreditUnitBytes: 2048,
remoteDownloadCreditPerUnit: 3,
},
expect.any(Date),
)
})
it('blocks enabling credit billing when quota_store is unavailable', async () => {
vi.mocked(loadBindingState).mockResolvedValue(PRO)
const { deps, update } = makeDeps()
await expect(
updateDownloaderCreditBilling(deps, 'downloader-1', {
enabled: true,
unitBytes: 2048,
creditsPerUnit: 3,
}),
).rejects.toMatchObject({
name: 'AppError',
httpStatus: 402,
meta: { reason: 'FEATURE_NOT_AVAILABLE', metadata: { feature: 'quota_store' } },
} satisfies Partial<AppError>)
expect(update).not.toHaveBeenCalled()
})
it('preserves not_found when credit billing is disabled for a missing downloader', async () => {
vi.mocked(loadBindingState).mockResolvedValue(PRO)
const { deps, update } = makeDeps({
getRecord: async () => {
throw new DownloadError('not_found')
},
})
await expect(
updateDownloaderCreditBilling(deps, 'missing', {
enabled: false,
unitBytes: 2048,
creditsPerUnit: 3,
}),
).rejects.toMatchObject({ name: 'DownloadError', code: 'not_found' })
expect(update).not.toHaveBeenCalled()
})
it('preserves not_found before quota_store gating for a missing downloader when credit billing is enabled', async () => {
vi.mocked(loadBindingState).mockResolvedValue(PRO)
const { deps, update } = makeDeps({
getRecord: async () => {
throw new DownloadError('not_found')
},
})
await expect(
updateDownloaderCreditBilling(deps, 'missing', {
enabled: true,
unitBytes: 2048,
creditsPerUnit: 3,
}),
).rejects.toMatchObject({ name: 'DownloadError', code: 'not_found' })
expect(update).not.toHaveBeenCalled()
})
})
+27 -1
View File
@@ -3,6 +3,7 @@ import type {
CreateDownloadTaskInput,
DownloaderHeartbeatInput,
DownloadTaskActionInput,
UpdateDownloaderCreditBillingInput,
UpdateDownloaderInput,
UpdateDownloadTaskInput,
} from '@shared/schemas'
@@ -10,6 +11,7 @@ import { downloadTaskRuntimeSchema } from '@shared/schemas'
import type { Downloader, DownloadTask, DownloadTaskRuntime } from '@shared/types'
import { nanoid } from 'nanoid'
import { ZPAN_CLOUD_URL_DEFAULT } from '../../../shared/constants'
import { hasFeature } from '../../domain/licensing'
import type { Platform } from '../../platform/interface'
import type {
DownloaderRecord,
@@ -22,7 +24,8 @@ import type {
ListDownloadTasksFilters,
RemoteDownloadUsageRepo,
} from '../ports'
import { DownloadError } from '../ports'
import { DownloadError, featureBlocked } from '../ports'
import { loadBindingState } from '../site/licensing'
import { RemoteDownloadBillingBlockedError, reportRemoteDownloadUnit } from './remote-download-usage'
// Pure orchestration over the downloader / download-task repos: registration,
@@ -142,6 +145,29 @@ export async function updateDownloader(
return deps.downloaders.get(id)
}
export async function updateDownloaderCreditBilling(
deps: DownloadsDeps,
id: string,
input: UpdateDownloaderCreditBillingInput,
): Promise<Downloader> {
await deps.downloaders.getRecord(id) // throws not_found
if (input.enabled && !hasFeature('quota_store', await loadBindingState(deps))) {
throw featureBlocked('Feature not available', {
metadata: { feature: 'quota_store' },
})
}
await deps.downloaders.update(
id,
{
remoteDownloadCreditBillingEnabled: input.enabled,
remoteDownloadCreditUnitBytes: input.unitBytes,
remoteDownloadCreditPerUnit: input.creditsPerUnit,
},
new Date(),
)
return deps.downloaders.get(id)
}
export async function deleteDownloader(deps: DownloadsDeps, id: string): Promise<{ id: string; deleted: true }> {
await deps.downloaders.getRecord(id) // throws not_found
const now = new Date()
+87 -1
View File
@@ -5,7 +5,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ActivityRepo, LicenseBindingRepo, StorageRecord, StorageRepo } from '../ports'
import { AppError } from '../ports'
import { loadBindingState } from './licensing'
import { createStorage, deleteStorage, getStorage, listStorages, type StorageDeps, updateStorage } from './storage'
import {
createStorage,
deleteStorage,
getStorage,
listStorages,
type StorageDeps,
updateStorage,
updateStorageEgressBilling,
} from './storage'
// loadBindingState derives features from a signed certificate — out of scope for
// a usecase unit test. Mock it so each case feeds a chosen edition; the real
@@ -184,6 +192,84 @@ describe('storage usecase', () => {
})
})
describe('updateStorageEgressBilling', () => {
it('updates egress billing fields and records activity', async () => {
edition(BUSINESS)
const update = vi.fn(async () => sampleStorage)
const { deps, record } = makeDeps({ get: async () => sampleStorage, update })
const out = await updateStorageEgressBilling(deps, {
userId: 'u1',
orgId: 'o1',
id: 'st-1',
input: { enabled: true, unitBytes: 1024, creditsPerUnit: 2 },
})
expect(out).toEqual({ ok: true, storage: sampleStorage })
expect(update).toHaveBeenCalledWith('st-1', {
egressCreditBillingEnabled: true,
egressCreditUnitBytes: 1024,
egressCreditPerUnit: 2,
})
expect(record).toHaveBeenCalledWith(expect.objectContaining({ action: 'storage_update', targetId: 'st-1' }))
})
it('blocks enabling egress billing without quota_store', async () => {
edition(PRO)
const update = vi.fn(async () => sampleStorage)
const { deps, record } = makeDeps({ get: async () => sampleStorage, update })
const out = await updateStorageEgressBilling(deps, {
userId: 'u1',
orgId: 'o1',
id: 'st-1',
input: { enabled: true, unitBytes: 1024, creditsPerUnit: 2 },
})
expect(out.ok).toBe(false)
if (!out.ok) {
expect(out.error).toBeInstanceOf(AppError)
expect(out.error.httpStatus).toBe(402)
expect(out.error.meta.reason).toBe('FEATURE_NOT_AVAILABLE')
expect(out.error.meta.metadata).toEqual({ feature: 'quota_store' })
}
expect(update).not.toHaveBeenCalled()
expect(record).not.toHaveBeenCalled()
})
it('returns not_found for a missing storage when billing is disabled', async () => {
edition(PRO)
const { deps, record } = makeDeps({ update: async () => null })
const out = await updateStorageEgressBilling(deps, {
userId: 'u1',
orgId: 'o1',
id: 'missing',
input: { enabled: false, unitBytes: 1024, creditsPerUnit: 2 },
})
expect(out.ok).toBe(false)
if (!out.ok) {
expect(out.error.httpStatus).toBe(404)
expect(out.error.message).toBe('Storage not found')
}
expect(record).not.toHaveBeenCalled()
})
it('returns not_found before quota_store gating for a missing storage when billing is enabled', async () => {
edition(PRO)
const update = vi.fn(async () => null)
const { deps, record } = makeDeps({ get: async () => null, update })
const out = await updateStorageEgressBilling(deps, {
userId: 'u1',
orgId: 'o1',
id: 'missing',
input: { enabled: true, unitBytes: 1024, creditsPerUnit: 2 },
})
expect(out.ok).toBe(false)
if (!out.ok) {
expect(out.error.httpStatus).toBe(404)
expect(out.error.message).toBe('Storage not found')
}
expect(update).not.toHaveBeenCalled()
expect(record).not.toHaveBeenCalled()
})
})
describe('deleteStorage', () => {
it('deletes and records activity with the storage name', async () => {
const del = vi.fn(async () => 'ok' as const)
+28 -1
View File
@@ -9,7 +9,7 @@
// the CRUD resource; that one is a cross-resource operation.
import { FREE_STORAGE_LIMIT } from '@shared/constants'
import type { CreateStorageInput, UpdateStorageInput } from '@shared/schemas'
import type { CreateStorageInput, UpdateStorageEgressBillingInput, UpdateStorageInput } from '@shared/schemas'
import { hasFeature } from '../../domain/licensing'
import {
type ActivityRepo,
@@ -121,6 +121,33 @@ export async function updateStorage(
return { ok: true, storage }
}
export async function updateStorageEgressBilling(
deps: StorageDeps,
params: { userId: string; orgId: string; id: string; input: UpdateStorageEgressBillingInput },
): Promise<UpdateStorageOutcome> {
const { userId, orgId, id, input } = params
const existing = await deps.storages.get(id)
if (!existing) return { ok: false, error: storageNotFound() }
if (input.enabled && !hasFeature('quota_store', await loadBindingState({ licenseBinding: deps.licenseBinding }))) {
return { ok: false, error: featureBlockError({ feature: 'quota_store' }) }
}
const storage = await deps.storages.update(id, {
egressCreditBillingEnabled: input.enabled,
egressCreditUnitBytes: input.unitBytes,
egressCreditPerUnit: input.creditsPerUnit,
})
if (!storage) return { ok: false, error: storageNotFound() }
await deps.activity.record({
orgId,
userId,
action: 'storage_update',
targetType: 'storage',
targetId: storage.id,
targetName: storage.title,
})
return { ok: true, storage }
}
export async function deleteStorage(
deps: StorageDeps,
params: { userId: string; orgId: string; id: string },