diff --git a/extensions/metering.test.ts b/extensions/metering.test.ts index 75c36b2a7..dfa8a1562 100644 --- a/extensions/metering.test.ts +++ b/extensions/metering.test.ts @@ -8,6 +8,7 @@ import { it, vi, } from 'vitest'; +import { makeActor, type Actor } from '../src/backend/core/actor.ts'; import { runWithContext } from '../src/backend/core/context.ts'; import { PuterServer } from '../src/backend/server.ts'; import { setupTestServer } from '../src/backend/testUtil.ts'; @@ -88,6 +89,80 @@ describe('metering extension — handleMeteringUsage', () => { (captured.body as Record).allowanceInfo, ).toBeDefined(); }); + + describe('usage split across apps', () => { + const seedUsage = async () => { + const user = await seedUser(); + const owner = { uuid: user.uuid, id: user.id as number }; + const metering = server.services.metering; + await metering.incrementUsage( + makeActor({ user: owner, app: { uid: 'app-mine' } }), + 'kv:read', + 1, + 10, + ); + await metering.incrementUsage( + makeActor({ user: owner, app: { uid: 'app-other' } }), + 'kv:write', + 1, + 20, + ); + await metering.incrementUsage( + makeActor({ user: owner }), + 'kv:list', + 1, + 40, + ); + await server.stores.meteringBuffer.flushCycle(); + return owner; + }; + + const usageAs = async (actor: Actor) => { + const { res, captured } = makeRes(); + await runWithContext({ actor }, () => + handleMeteringUsage(makeReq(), res), + ); + return captured.body as { + usage: Record & { total: number }; + appTotals: Record; + allowanceInfo: Record; + }; + }; + + it('reports only the calling app’s own usage to an app actor', async () => { + const owner = await seedUsage(); + const body = await usageAs( + makeActor({ user: owner, app: { uid: 'app-mine' } }), + ); + + expect(body.usage.total).toBe(10); + expect(Object.keys(body.usage).sort()).toEqual([ + 'kv:read', + 'total', + ]); + expect(body.appTotals).toEqual({ + 'app-mine': { total: 10, count: 1 }, + }); + expect(body.allowanceInfo).toMatchObject({ + remaining: expect.any(Number), + monthUsageAllowance: expect.any(Number), + }); + }); + + it('still reports the whole account to the user', async () => { + const owner = await seedUsage(); + const body = await usageAs(makeActor({ user: owner })); + + expect(body.usage.total).toBe(70); + expect(body.usage).toHaveProperty('kv:read'); + expect(body.usage).toHaveProperty('kv:write'); + expect(body.usage).toHaveProperty('kv:list'); + expect(body.appTotals).toMatchObject({ + 'app-mine': { total: 10, count: 1 }, + 'app-other': { total: 20, count: 1 }, + }); + }); + }); }); describe('metering extension — handleMeteringUsageForApp', () => { @@ -132,6 +207,83 @@ describe('metering extension — handleMeteringUsageForApp', () => { ), ).rejects.toMatchObject({ statusCode: 404 }); }); + + describe('scoped to the calling app', () => { + const appUsageAs = async (actor: Actor, appIdOrName: string) => { + const { res, captured } = makeRes(); + await runWithContext({ actor }, () => + handleMeteringUsageForApp( + makeReq({ params: { appIdOrName } }), + res, + ), + ); + return captured.body as { total: number }; + }; + + const seedApps = async () => { + const user = await seedUser(); + const owner = { uuid: user.uuid, id: user.id as number }; + const mine = `app-${uuidv4()}`; + const other = `app-${uuidv4()}`; + await server.services.metering.incrementUsage( + makeActor({ user: owner, app: { uid: mine } }), + 'kv:read', + 1, + 10, + ); + await server.services.metering.incrementUsage( + makeActor({ user: owner, app: { uid: other } }), + 'kv:write', + 1, + 20, + ); + await server.stores.meteringBuffer.flushCycle(); + return { + owner, + mine, + other, + asMine: makeActor({ user: owner, app: { uid: mine } }), + }; + }; + + it('lets an app read its own usage', async () => { + const { mine, asMine } = await seedApps(); + expect((await appUsageAs(asMine, mine)).total).toBe(10); + }); + + it('refuses an app another app’s usage', async () => { + const { other, asMine } = await seedApps(); + await expect(appUsageAs(asMine, other)).rejects.toMatchObject({ + statusCode: 403, + }); + }); + + it('refuses an app another app’s usage by name', async () => { + const { owner, other, asMine } = await seedApps(); + const name = `other-${uuidv4()}`; + await server.clients.db.write( + 'INSERT INTO `apps` (`uid`, `name`, `title`, `index_url`, `owner_user_id`) VALUES (?, ?, ?, ?, ?)', + [other, name, name, `https://${name}.example/`, owner.id], + ); + await expect(appUsageAs(asMine, name)).rejects.toMatchObject({ + statusCode: 403, + }); + }); + + it('refuses an app the usage outside any app', async () => { + const { asMine } = await seedApps(); + await expect(appUsageAs(asMine, 'os-global')).rejects.toMatchObject( + { statusCode: 403 }, + ); + }); + + it('still lets the user read any of their apps', async () => { + const { owner, mine, other } = await seedApps(); + const asUser = makeActor({ user: owner }); + expect((await appUsageAs(asUser, mine)).total).toBe(10); + expect((await appUsageAs(asUser, other)).total).toBe(20); + }); + }); }); describe('metering extension — handleMeteringGlobalUsage', () => { diff --git a/extensions/metering.ts b/extensions/metering.ts index 21ceba905..7d4cd64dd 100644 --- a/extensions/metering.ts +++ b/extensions/metering.ts @@ -6,6 +6,7 @@ import { servicesContainers, } from '@heyputer/backend/src/exports'; import { extension } from '@heyputer/backend/src/extensions'; +import { GLOBAL_APP_KEY } from '@heyputer/backend/src/services/metering/consts'; import { creditMultiplierFrom, toCredits, @@ -151,6 +152,21 @@ export const handleMeteringUsageForApp = async ( let appId = String(req.params.appIdOrName ?? ''); if (!appId) throw new HttpError(400, 'appId parameter is required'); + // An app may read its own usage only; refuse a uid before any lookup. + const ownAppId = actor.effectiveApp?.uid; + const refuseOtherApp = (uid: string) => { + if (ownAppId && uid !== ownAppId) { + throw new HttpError( + 403, + 'An app can only get usage details for itself', + { legacyCode: 'forbidden' }, + ); + } + }; + if (appId.startsWith('app-') || appId === GLOBAL_APP_KEY) { + refuseOtherApp(appId); + } + // If not a UUID-shaped app UID, look up by name if (!appId.startsWith('app-')) { const appRows = (await clients.db.read( @@ -162,6 +178,7 @@ export const handleMeteringUsageForApp = async ( } else { throw new HttpError(404, 'App not found'); } + refuseOtherApp(appId); } const appUsage = diff --git a/extensions/whoami.test.ts b/extensions/whoami.test.ts index 00332a0f9..4b7d58b73 100644 --- a/extensions/whoami.test.ts +++ b/extensions/whoami.test.ts @@ -8,7 +8,7 @@ import { it, vi, } from 'vitest'; -import { makeActor } from '../src/backend/core/actor.ts'; +import { makeActor, type Actor } from '../src/backend/core/actor.ts'; import { runWithContext } from '../src/backend/core/context.ts'; import { configContainer } from '../src/backend/exports.ts'; import { PuterServer } from '../src/backend/server.ts'; @@ -285,6 +285,86 @@ describe('whoami extension — handleWhoami', () => { expect(body.directories).toBeUndefined(); }); + describe('fields added by whoami.details listeners', () => { + const addAccountDetails = ( + _key: unknown, + event: { details: Record }, + ) => { + Object.assign(event.details, { + subscribed: true, + paid_storage: 1024, + subscription: { tier: 'pro', status: 'trialing' }, + accountEligible: true, + }); + }; + + const whoamiWithListener = async (actor: Actor) => { + const { res, captured } = makeRes(); + server.clients.event.on('whoami.details', addAccountDetails); + try { + await runWithContext({ actor }, () => + handleWhoami(makeReq(), res), + ); + } finally { + server.clients.event.off('whoami.details', addAccountDetails); + } + return captured.body as Record; + }; + + it('reach an app actor only as the documented subscribed / paid_storage', async () => { + const user = await seedUser(); + const body = await whoamiWithListener( + makeActor({ + user: { uuid: user.uuid, id: user.id as number }, + app: { uid: 'app-test-actor' }, + }), + ); + + expect(body.subscribed).toBe(true); + expect(body.paid_storage).toBe(1024); + expect(body).not.toHaveProperty('subscription'); + expect(body).not.toHaveProperty('accountEligible'); + // Fields the handler built itself still ship. + expect(body.username).toBe(user.username); + expect(body.app_name).toBe('app-test-actor'); + }); + + it('are withheld from an app reached through an access token it issued', async () => { + const user = await seedUser(); + const owner = { uuid: user.uuid, id: user.id as number }; + const body = await whoamiWithListener( + makeActor({ + user: owner, + accessToken: { + uid: 'tok-1', + issuer: makeActor({ + user: owner, + app: { uid: 'app-issuer' }, + }), + }, + }), + ); + + expect(body.subscribed).toBe(true); + expect(body).not.toHaveProperty('subscription'); + expect(body).not.toHaveProperty('accountEligible'); + }); + + it('all reach a user actor', async () => { + const user = await seedUser(); + const body = await whoamiWithListener( + makeActor({ user: { uuid: user.uuid, id: user.id as number } }), + ); + + expect(body).toMatchObject({ + subscribed: true, + paid_storage: 1024, + subscription: { tier: 'pro', status: 'trialing' }, + accountEligible: true, + }); + }); + }); + it('redacts tmp_password from metadata for user actors', async () => { const user = await seedUser(); await server.stores.user.updateMetadata(user.id as number, { diff --git a/extensions/whoami.ts b/extensions/whoami.ts index d171b321a..9fd7bdd26 100644 --- a/extensions/whoami.ts +++ b/extensions/whoami.ts @@ -71,6 +71,13 @@ const SENSITIVE_KEYS: ReadonlySet = new Set([ 'audit_metadata', ]); +// What an app actor may receive from `whoami.details` listeners. Anything +// else a listener adds describes the account, and only ships to user actors. +const APP_VISIBLE_LISTENER_KEYS: ReadonlySet = new Set([ + 'subscribed', + 'paid_storage', +]); + // Depth-limited, cycle-safe walk deleting every SENSITIVE_KEYS entry it finds // at any level (`metadata` and `taskbar_items` are both nested structures). const scrubSensitive = ( @@ -292,6 +299,7 @@ export const handleWhoami = async ( details.app_name = app.uid; } + const builtKeys = isUser ? null : new Set(Object.keys(details)); try { await clients.event.emitAndWait( 'whoami.details', @@ -301,6 +309,13 @@ export const handleWhoami = async ( } catch { /* best-effort */ } + if (builtKeys) { + for (const key of Object.keys(details)) { + if (!builtKeys.has(key) && !APP_VISIBLE_LISTENER_KEYS.has(key)) { + delete details[key]; + } + } + } const subscription = details.subscription as { offering?: Record } | undefined; diff --git a/src/backend/clients/event/types.ts b/src/backend/clients/event/types.ts index 0bdca67e9..0a15934d7 100644 --- a/src/backend/clients/event/types.ts +++ b/src/backend/clients/event/types.ts @@ -728,6 +728,7 @@ export type EventMap = { email?: string; app_name?: string; }; + /** False for an app actor: add only `subscribed` / `paid_storage` then. */ isUser: boolean; }; 'wisp.get-policy': { diff --git a/src/backend/services/metering/MeteringService.test.ts b/src/backend/services/metering/MeteringService.test.ts index fd0ad59fa..6450fdbdf 100644 --- a/src/backend/services/metering/MeteringService.test.ts +++ b/src/backend/services/metering/MeteringService.test.ts @@ -1192,8 +1192,8 @@ describe('MeteringService', () => { expect(result.appTotals.B?.total).toBe(50); }); - it('filters appTotals by actor.app.uid and rolls others into "others"', async () => { - const userId = actor.user.uuid; + it('scopes usage and appTotals to the calling app', async () => { + const userId = makeUser().uuid; const appA: Actor = resolveActor({ user: { uuid: userId }, app: { uid: 'A', id: 1 }, @@ -1203,13 +1203,59 @@ describe('MeteringService', () => { app: { uid: 'B', id: 2 }, }); await target.incrementUsage(appA, 'kv:read', 1, 100); - await target.incrementUsage(appB, 'kv:read', 1, 50); + await target.incrementUsage(appB, 'kv:write', 1, 50); + await target.incrementUsage( + { user: { uuid: userId } }, + 'kv:list', + 1, + 25, + ); await server.stores.meteringBuffer.flushCycle(); const r = await target.getActorCurrentMonthUsageDetails(appA); - expect(r.appTotals.A?.total).toBe(100); - expect(r.appTotals.others?.total).toBe(50); - expect(r.appTotals).not.toHaveProperty('B'); + expect(r.usage.total).toBe(100); + expect(r.usage['kv:read']).toMatchObject({ cost: 100 }); + expect(r.usage).not.toHaveProperty('kv:write'); + expect(r.usage).not.toHaveProperty('kv:list'); + expect(r.appTotals).toEqual({ A: { total: 100, count: 1 } }); + + // The account's own view is unchanged. + const own = await target.getActorCurrentMonthUsageDetails({ + user: { uuid: userId }, + }); + expect(own.usage.total).toBe(175); + expect(own.usage).toHaveProperty('kv:write'); + expect(own.appTotals.A?.total).toBe(100); + expect(own.appTotals.B?.total).toBe(50); + }); + + it('scopes an app reached through an access token it issued', async () => { + const user = makeUser(); + const app = resolveActor({ user, app: { uid: 'issuer-app' } }); + await target.incrementUsage(app, 'kv:read', 1, 30); + await target.incrementUsage({ user }, 'kv:write', 1, 70); + await server.stores.meteringBuffer.flushCycle(); + + const token = resolveActor({ + user, + accessToken: { uid: 'tok', issuer: app }, + }); + const r = await target.getActorCurrentMonthUsageDetails(token); + expect(r.usage.total).toBe(30); + expect(r.appTotals).toEqual({ + 'issuer-app': { total: 30, count: 1 }, + }); + }); + + it('gives an app with no usage an empty view', async () => { + const user = makeUser(); + await target.incrementUsage({ user }, 'kv:write', 1, 70); + await server.stores.meteringBuffer.flushCycle(); + + const r = await target.getActorCurrentMonthUsageDetails( + resolveActor({ user, app: { uid: 'idle-app' } }), + ); + expect(r).toEqual({ usage: { total: 0 }, appTotals: {} }); }); it('rejects an actor with no user uuid', async () => { @@ -3486,7 +3532,7 @@ describe('MeteringService', () => { expect(appTotals['app-new']).toEqual({ total: 10, count: 1 }); }); - it('an app actor sees only its own total, with everyone else folded into others', async () => { + it('an app actor sees only its own total', async () => { const bufActor: Actor = { user: makeUser() }; await target.incrementUsage( resolveActor({ ...bufActor, app: { uid: 'app-mine' } }), @@ -3509,9 +3555,7 @@ describe('MeteringService', () => { const { appTotals } = await target.getActorCurrentMonthUsageDetails(appActor); - expect(appTotals['app-mine']).toEqual({ total: 10, count: 1 }); - expect(appTotals['app-theirs']).toBeUndefined(); - expect(appTotals.others).toEqual({ total: 20, count: 1 }); + expect(appTotals).toEqual({ 'app-mine': { total: 10, count: 1 } }); }); it('caches the app-totals listing and invalidates it on a correction', async () => { diff --git a/src/backend/services/metering/MeteringService.ts b/src/backend/services/metering/MeteringService.ts index 33ea5b86a..9a95e1e1c 100644 --- a/src/backend/services/metering/MeteringService.ts +++ b/src/backend/services/metering/MeteringService.ts @@ -1216,6 +1216,22 @@ export class MeteringService extends PuterService { ); const currentMonth = this.monthYearString(); + + // An app sees its own usage only, never the rest of the account's. + const appId = actor.effectiveApp?.uid; + if (appId) { + const [appUsage, appTotals] = await Promise.all([ + this.#readActorAppUsage(actor.user.uuid, appId, currentMonth), + this.#actorAppTotals(actor.user.uuid, currentMonth), + ]); + return { + usage: appUsage, + appTotals: appTotals[appId] + ? { [appId]: appTotals[appId] } + : {}, + }; + } + const monthKey = `${METRICS_V2_PREFIX}:actor:${actor.user.uuid}:${currentMonth}`; const { res } = await this.stores.meteringBuffer.get({ @@ -1248,25 +1264,6 @@ export class MeteringService extends PuterService { currentMonth, ); - const appId = actor.effectiveApp?.uid; - if (appId && Object.keys(appTotals).length > 0) { - const filtered: Record = {}; - const others: AppTotals = {} as AppTotals; - Object.entries(appTotals).forEach(([appKey, appUsage]) => { - if (appKey === appId) { - filtered[appKey] = appUsage; - } else { - Object.entries(appUsage).forEach(([usageKind, amount]) => { - const key = usageKind as keyof AppTotals; - if (!others[key]) others[key] = 0; - others[key] += amount; - }); - } - }); - if (others) filtered['others'] = others; - return { usage: resolvedUsage, appTotals: filtered }; - } - return { usage: resolvedUsage, appTotals }; } diff --git a/src/docs/src/Auth/getMonthlyUsage.md b/src/docs/src/Auth/getMonthlyUsage.md index 18174e00e..0d0e59865 100644 --- a/src/docs/src/Auth/getMonthlyUsage.md +++ b/src/docs/src/Auth/getMonthlyUsage.md @@ -8,7 +8,7 @@ Get the user's current monthly resource usage in the Puter ecosystem.
-Usage data is scoped to the calling app only. +When called from an app, `usage` and `appTotals` cover only that app's own usage. `allowanceInfo` is the user's allowance for the whole account.