Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
2b88944
fix(v2-api): close two secret disclosures and align docs with signatures
waleedlatif1 Aug 11, 2026
32fca05
refactor(v2-api)!: flatten the single-resource response envelope
waleedlatif1 Aug 11, 2026
a2abd09
fix(v2-api): close a third secret disclosure and make concealment coh…
waleedlatif1 Aug 11, 2026
f633e79
docs(v2-api): correct eleven false or misleading spec claims
waleedlatif1 Aug 11, 2026
5d6c47d
test(v2-api): align upload concealment test with cross-tenant-only se…
waleedlatif1 Aug 11, 2026
6abc1b4
fix(v2-api): accept the redacting log status and envelope the knowled…
waleedlatif1 Aug 11, 2026
94fa43b
fix(uploads): restore archive extraction folder parity
waleedlatif1 Aug 11, 2026
0077285
chore(files): tidy archive extraction cleanup
waleedlatif1 Aug 11, 2026
427b260
fix(uploads): roll back folders archive extraction created
waleedlatif1 Aug 11, 2026
9ef805a
fix(billing): withhold the payer credit pool from v2 status readers
waleedlatif1 Aug 11, 2026
aa8f09a
chore(api): remove the unused public API route builder and dead endpo…
waleedlatif1 Aug 11, 2026
8ff822e
fix(billing): deny the payer pool to actor-less workspace API keys
waleedlatif1 Aug 11, 2026
46f9c2f
fix(folders): bound the workflow folderId-branch path index reads
waleedlatif1 Aug 11, 2026
79a3486
chore(billing): tidy payer-pool concealment cleanup
waleedlatif1 Aug 11, 2026
23b7792
fix(api): reject an undecodable offset cursor on v2 table rows
waleedlatif1 Aug 11, 2026
515096b
fix(api): restore v1 table error-response parity and stop internal me…
waleedlatif1 Aug 11, 2026
209e2c8
fix(skills): only reject a built-in name collision on an actual rename
waleedlatif1 Aug 11, 2026
5519d45
chore(tables): tidy v1 error projection cleanup
waleedlatif1 Aug 11, 2026
c8007ce
chore(skills): tidy collision guard cleanup
waleedlatif1 Aug 11, 2026
7af315f
Merge pull request #6565 from simstudioai/fix/archive-extraction-fold…
waleedlatif1 Aug 11, 2026
1667d3c
Merge pull request #6567 from simstudioai/fix/v2-billing-status-authz
waleedlatif1 Aug 11, 2026
3cab8ef
Merge pull request #6568 from simstudioai/chore/v2-dead-code-and-bounds
waleedlatif1 Aug 11, 2026
262ce32
Merge pull request #6569 from simstudioai/fix/v1-response-parity
waleedlatif1 Aug 11, 2026
fe7fd80
Merge pull request #6570 from simstudioai/fix/skills-builtin-name-col…
waleedlatif1 Aug 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 50 additions & 36 deletions apps/docs/openapi-v2-billing.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
"get": {
"operationId": "getBillingStatus",
"summary": "Get Billing Status",
"description": "Return the current plan, billing standing, credit allowance, and storage quota. Billing history lives at `GET /api/v2/billing/logs`. Without a Stripe subscription — notably on the free plan — there is no real billing period: `period` is the open interval 1970-01-01 to 9999-12-31 and `credits.used` is lifetime consumption, not consumption since a period start.",
"description": "Return the current plan, billing standing, credit allowance, and storage quota. `credits` and `storage` report the payer's pooled allowances and are null unless the caller can manage that payer's billing; they are always null for a workspace API key. Billing history lives at `GET /api/v2/billing/logs`. Without a Stripe subscription — notably on the free plan — there is no real billing period: `period` is the open interval 1970-01-01 to 9999-12-31 and `credits.used` is lifetime consumption, not consumption since a period start.",
"tags": ["Billing"],
"parameters": [
{
Expand Down Expand Up @@ -543,47 +543,61 @@
"description": "Current billing standing."
},
"credits": {
"type": "object",
"properties": {
"used": {
"type": "number",
"description": "Credits consumed so far. The counter is reset by Stripe invoice webhooks, so on a paid plan it covers the current billing period; on the free plan nothing resets it and the value is lifetime consumption."
},
"limit": {
"type": "number",
"description": "Credit allowance for the reporting window — per billing period on a paid plan, lifetime on the free plan."
"anyOf": [
{
"type": "object",
"properties": {
"used": {
"type": "number",
"description": "Credits consumed so far. The counter is reset by Stripe invoice webhooks, so on a paid plan it covers the current billing period; on the free plan nothing resets it and the value is lifetime consumption."
},
"limit": {
"type": "number",
"description": "Credit allowance for the reporting window — per billing period on a paid plan, lifetime on the free plan."
},
"remaining": {
"type": "number",
"description": "Allowance minus consumption, over the same window."
}
},
"required": ["used", "limit", "remaining"],
"additionalProperties": false
},
"remaining": {
"type": "number",
"description": "Allowance minus consumption, over the same window."
{
"type": "null"
}
},
"required": ["used", "limit", "remaining"],
"additionalProperties": false,
"description": "Credit usage and allowance. Periodic on a paid plan; lifetime on the free plan, where the counter never resets."
],
"description": "The payer's credit usage and allowance — periodic on a paid plan, lifetime on the free plan, where the counter never resets. Null when the caller cannot manage that payer's billing. Always null for a workspace API key."
},
"storage": {
"type": "object",
"properties": {
"usedBytes": {
"type": "number",
"minimum": 0,
"description": "Storage currently consumed, in bytes."
},
"limitBytes": {
"type": "number",
"minimum": 0,
"description": "Storage quota, in bytes."
"anyOf": [
{
"type": "object",
"properties": {
"usedBytes": {
"type": "number",
"minimum": 0,
"description": "Storage currently consumed, in bytes."
},
"limitBytes": {
"type": "number",
"minimum": 0,
"description": "Storage quota, in bytes."
},
"percentUsed": {
"type": "number",
"minimum": 0,
"description": "Percentage of the storage quota consumed."
}
},
"required": ["usedBytes", "limitBytes", "percentUsed"],
"additionalProperties": false
},
"percentUsed": {
"type": "number",
"minimum": 0,
"description": "Percentage of the storage quota consumed."
{
"type": "null"
}
},
"required": ["usedBytes", "limitBytes", "percentUsed"],
"additionalProperties": false,
"description": "Current storage consumption and quota."
],
"description": "The payer's storage consumption and quota, or null when the caller cannot manage that payer's billing. Always null for a workspace API key."
}
},
"required": ["workspaceId", "period", "plan", "status", "credits", "storage"],
Expand Down
11 changes: 11 additions & 0 deletions apps/sim/app/api/v2/billing/status/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,17 @@ describe('GET /api/v2/billing/status', () => {
expect(response.headers.get('x-ratelimit-limit')).toBe('100')
})

it('serializes a withheld payer pool as null without failing response validation', async () => {
mocks.execute.mockResolvedValueOnce({ ...result, credits: null, storage: null })

const response = await GET(
new NextRequest('http://localhost:3000/api/v2/billing/status?workspaceId=workspace-1')
)

expect(response.status).toBe(200)
expect(await response.json()).toEqual({ data: { ...result, credits: null, storage: null } })
})

it('projects typed workspace-policy errors', async () => {
mocks.execute.mockRejectedValueOnce(
new OrchestrationError('forbidden', 'API key is not authorized for this workspace')
Expand Down
17 changes: 15 additions & 2 deletions apps/sim/lib/api/contracts/v2/billing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,15 @@ export const v2BillingStatusQuerySchema = z.object({
/**
* Current billing standing, credit allowance, and storage quota. Ledger rows
* and source analytics deliberately live outside this status resource.
*
* `credits` and `storage` report the resolved payer's pooled allowances, which
* are shared across every workspace that payer funds. They are populated only
* for a caller who may manage that payer's billing: the billed account holder,
* or an admin of the hosting organization. Billing authority is a property of
* a person, so an actor-less workspace API key never qualifies. Every other
* caller reads both as `null` while still seeing the plan, period, and
* standing that the workspace already surfaces to them — enough to monitor for
* `limit_exceeded` and `billing_blocked`.
*/
export const v2BillingStatusDataSchema = z
.object({
Expand Down Expand Up @@ -78,16 +87,20 @@ export const v2BillingStatusDataSchema = z
),
remaining: z.number().describe('Allowance minus consumption, over the same window.'),
})
.nullable()
.describe(
'Credit usage and allowance. Periodic on a paid plan; lifetime on the free plan, where the counter never resets.'
"The payer's credit usage and allowance — periodic on a paid plan, lifetime on the free plan, where the counter never resets. Null when the caller cannot manage that payer's billing. Always null for a workspace API key."
),
storage: z
.object({
usedBytes: z.number().nonnegative().describe('Storage currently consumed, in bytes.'),
limitBytes: z.number().nonnegative().describe('Storage quota, in bytes.'),
percentUsed: z.number().nonnegative().describe('Percentage of the storage quota consumed.'),
})
.describe('Current storage consumption and quota.'),
.nullable()
.describe(
"The payer's storage consumption and quota, or null when the caller cannot manage that payer's billing. Always null for a workspace API key."
),
})
.meta({
id: 'V2BillingStatus',
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/lib/api/contracts/v2/openapi/billing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ const routes = [
operationId: 'getBillingStatus',
summary: 'Get Billing Status',
description:
'Return the current plan, billing standing, credit allowance, and storage quota. Billing history lives at `GET /api/v2/billing/logs`. Without a Stripe subscription — notably on the free plan — there is no real billing period: `period` is the open interval 1970-01-01 to 9999-12-31 and `credits.used` is lifetime consumption, not consumption since a period start.',
"Return the current plan, billing standing, credit allowance, and storage quota. `credits` and `storage` report the payer's pooled allowances and are null unless the caller can manage that payer's billing; they are always null for a workspace API key. Billing history lives at `GET /api/v2/billing/logs`. Without a Stripe subscription — notably on the free plan — there is no real billing period: `period` is the open interval 1970-01-01 to 9999-12-31 and `credits.used` is lifetime consumption, not consumption since a period start.",
errors: [...WORKSPACE_ERRORS, 'NotFound'],
success: { description: 'The current billing and storage status.' },
}),
Expand Down
126 changes: 121 additions & 5 deletions apps/sim/lib/billing/application/billing-use-cases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ const mocks = vi.hoisted(() => ({
getUsageLogs: vi.fn(),
getWorkspaceUsageLogs: vi.fn(),
recordAudit: vi.fn(),
canUserManageWorkspaceBilling: vi.fn(),
}))

vi.mock('@/lib/billing/core/workspace-billing-authority', () => ({
canUserManageWorkspaceBilling: mocks.canUserManageWorkspaceBilling,
}))

vi.mock('@/lib/workspaces/application/workspace-context', () => ({
Expand Down Expand Up @@ -94,6 +99,7 @@ describe('billing application use cases', () => {
vi.clearAllMocks()
mocks.loadWorkspace.mockResolvedValue(workspaceContext)
mocks.resolvePermission.mockResolvedValue('read')
mocks.canUserManageWorkspaceBilling.mockResolvedValue(false)
mocks.checkUsageStatus.mockResolvedValue({ currentUsage: 1, limit: 10, isExceeded: false })
mocks.checkAttributedBlocks.mockResolvedValue({ blocked: false })
mocks.toUsageLimitSubscription.mockReturnValue(null)
Expand Down Expand Up @@ -161,16 +167,126 @@ describe('billing application use cases', () => {
})

expect(result.workspaceId).toBe('workspace-1')
expect(result).toMatchObject({ plan: 'free', status: 'active' })
expect(mocks.resolveSystemAttribution).toHaveBeenCalledWith('workspace-1')
expect(mocks.resolvePermission).not.toHaveBeenCalled()
expect(mocks.resolveAttribution).not.toHaveBeenCalled()
expect(mocks.recordAudit).not.toHaveBeenCalled()
})

/**
* A workspace API key is actor-less, and any workspace `admin` may mint one,
* so granting it the pool would launder the exact role the projection
* excludes — across the whole organization on an organization-hosted
* workspace.
*/
it('withholds the payer pool from an actor-less workspace key', async () => {
const result = await getBillingStatus.execute({
principal: workspacePrincipal,
input: {},
})

expect(result.credits).toBeNull()
expect(result.storage).toBeNull()
expect(mocks.canUserManageWorkspaceBilling).not.toHaveBeenCalled()
})

it('never reads the payer storage pool it may not disclose', async () => {
await getBillingStatus.execute({ principal: workspacePrincipal, input: {} })
await getBillingStatus.execute({
principal: personalPrincipal,
input: { workspaceId: 'workspace-1' },
})

expect(mocks.resolveStorageContext).not.toHaveBeenCalled()
expect(mocks.getStorageUsageForContext).not.toHaveBeenCalled()
})

it('still reports a workspace key an exceeded pooled limit it cannot read', async () => {
mocks.checkAttributedBlocks.mockResolvedValue({ blocked: true })

const result = await getBillingStatus.execute({
principal: workspacePrincipal,
input: {},
})

expect(result.status).toBe('billing_blocked')
expect(result.credits).toBeNull()
})

it('withholds the payer pool from a workspace member who cannot manage billing', async () => {
mocks.resolvePermission.mockResolvedValue('read')
mocks.canUserManageWorkspaceBilling.mockResolvedValue(false)

const result = await getBillingStatus.execute({
principal: personalPrincipal,
input: { workspaceId: 'workspace-1' },
})

expect(result.credits).toBeNull()
expect(result.storage).toBeNull()
expect(result).toMatchObject({ workspaceId: 'workspace-1', plan: 'free', status: 'active' })
expect(mocks.canUserManageWorkspaceBilling).toHaveBeenCalledWith(workspaceContext, 'user-1')
})

it('withholds the payer pool from a workspace admin who cannot manage billing', async () => {
mocks.resolvePermission.mockResolvedValue('admin')
mocks.canUserManageWorkspaceBilling.mockResolvedValue(false)

const result = await getBillingStatus.execute({
principal: personalPrincipal,
input: { workspaceId: 'workspace-1' },
})

expect(result.credits).toBeNull()
expect(result.storage).toBeNull()
})

it('still reports an exceeded payer limit without disclosing the pool', async () => {
mocks.canUserManageWorkspaceBilling.mockResolvedValue(false)
mocks.checkUsageStatus.mockResolvedValue({ currentUsage: 40, limit: 10, isExceeded: true })

const result = await getBillingStatus.execute({
principal: personalPrincipal,
input: { workspaceId: 'workspace-1' },
})

expect(result.status).toBe('limit_exceeded')
expect(result.credits).toBeNull()
})

it('projects the payer pool to a member who can manage billing', async () => {
mocks.canUserManageWorkspaceBilling.mockResolvedValue(true)

const result = await getBillingStatus.execute({
principal: personalPrincipal,
input: { workspaceId: 'workspace-1' },
})

expect(result.credits).toEqual({ used: 200, limit: 2_000, remaining: 1_800 })
expect(result.storage).toEqual({
usedBytes: 5_242_880,
limitBytes: 1_073_741_824,
percentUsed: 0.48828125,
})
expect(mocks.resolveSystemAttribution).toHaveBeenCalledWith('workspace-1')
expect(mocks.resolveStorageContext).toHaveBeenCalledWith('workspace-1')
expect(mocks.resolvePermission).not.toHaveBeenCalled()
expect(mocks.resolveAttribution).not.toHaveBeenCalled()
expect(mocks.recordAudit).not.toHaveBeenCalled()
})

it('always reports the account-scoped pool the caller owns', async () => {
mocks.canUserManageWorkspaceBilling.mockResolvedValue(false)
mocks.getSubscription.mockResolvedValue({ plan: 'pro' })
mocks.deriveBillingContext.mockReturnValue({
billingEntity: { type: 'user', id: 'user-1' },
billingPeriod: {
start: new Date('2026-01-01T00:00:00Z'),
end: new Date('2026-02-01T00:00:00Z'),
},
})
mocks.checkBillingBlocked.mockResolvedValue({ blocked: false })

const result = await getBillingStatus.execute({ principal: personalPrincipal, input: {} })

expect(result.credits).toEqual({ used: 200, limit: 2_000, remaining: 1_800 })
expect(result.storage).not.toBeNull()
})

it('uses the personal principal as account authority', async () => {
Expand Down
Loading
Loading