fix: scope whoami billing details and monthly usage to the calling app (PUT-2015) (#4050)

* fix: scope whoami billing details and monthly usage to the calling app (PUT-2015)

* fix(metering): refuse an app another app's detailed usage (PUT-2015)
This commit is contained in:
Daniel Salazar authored and GitHub committed 2026-10-03 16:38:31 -07:00
1 parent 3fa23bb809
commit 2cfc1461e4
8 files changed
+337 -31

No files matched your search

+152
View File
@@ -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<string, unknown>).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<string, unknown> & { total: number };
appTotals: Record<string, unknown>;
allowanceInfo: Record<string, unknown>;
};
};
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', () => {
+17
View File
@@ -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 =
+81 -1
View File
@@ -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<string, unknown> },
) => {
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<string, unknown>;
};
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, {
+15
View File
@@ -71,6 +71,13 @@ const SENSITIVE_KEYS: ReadonlySet<string> = 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<string> = 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<string, unknown> } | undefined;
+1
View File
@@ -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': {
@@ -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 () => {
@@ -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<string, AppTotals> = {};
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 };
}
+1 -1
View File
@@ -8,7 +8,7 @@ Get the user's current monthly resource usage in the Puter ecosystem.
<div class="info">
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.
</div>