mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-13 07:10:05 +00:00
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:
1 parent
3fa23bb809
commit
2cfc1461e4
8 files changed
+337
-31
No files matched your search
@@ -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', () => {
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in new issue
Block a user