fix(metering): stop caching subscription answers from email-less actors and failed lookups (PUT-2002) (#4052)

This commit is contained in:
Daniel Salazar authored and GitHub committed 2026-10-03 16:39:08 -07:00
1 parent 2cfc1461e4
commit 205f19a167
10 files changed
+253 -30

No files matched your search

@@ -735,6 +735,26 @@ describe('LegacyFSController.file (signed streaming)', () => {
);
});
// Metering resolves the billed account's plan from this actor, and some
// plans are keyed on the account's email.
it('bills an anonymous read to the owner, email included', async () => {
const { actor, userId, username } = await makeUser();
const { query } = await signedQuery(
actor,
`/${username}/Documents/signed-billed.txt`,
'billed',
);
const req = makeReq({ query });
const { res, finished } = makeStreamRes();
await controller.file(req, res);
await finished;
expect(req.egressActor?.user).toMatchObject({
id: userId,
uuid: actor.user.uuid,
email: `${username}@test.local`,
});
});
it('rejects a signature minted before the owner was suspended', async () => {
const { actor, userId, username } = await makeUser();
const { query } = await signedQuery(
@@ -1770,6 +1770,7 @@ export class LegacyFSController extends PuterController {
uuid: owner.uuid,
id: owner.id,
username: owner.username,
email: owner.email ?? null,
suspended: !!(owner as { suspended?: unknown }).suspended,
},
};
@@ -458,6 +458,7 @@ describe('PeerController TURN', () => {
uuid: created.uuid,
id: created.id,
username: created.username,
email: created.email,
},
effectiveApp: null,
});
@@ -456,6 +456,7 @@ export class PeerController extends PuterController {
uuid: user.uuid,
id: user.id,
username: user.username,
email: user.email ?? null,
},
});
await this.services.metering.incrementUsage(
@@ -542,6 +542,34 @@ describe('createPuterSiteMiddleware — file serving', () => {
expect(piped!.equals(body)).toBe(true);
});
// Metering resolves the billed account's plan from this actor, and some
// plans are keyed on the account's email.
it('bills an anonymous visit to the owner, email included', async () => {
const owner = await makeUserWithHome();
const homePath = `/${owner.username}`;
const homeEntry = await server.stores.fsEntry.getEntryByPath(homePath);
const sub = `bill-${Math.random().toString(36).slice(2, 8)}`;
await server.stores.subdomain.create({
userId: owner.id,
subdomain: sub,
rootDirId: homeEntry!.id,
});
await writeFile(owner.id, `${homePath}/index.html`, Buffer.from('hi'));
const req = makeReq({
hostname: `${sub}.site.puter.localhost`,
path: '/index.html',
});
const { res } = makeRes();
await buildMiddleware()(req, res, vi.fn());
expect(req.egressActor?.user).toMatchObject({
uuid: owner.uuid,
id: owner.id,
email: owner.email,
});
});
it('emits site.htmlServed with the original URL target including query string', async () => {
const owner = await makeUserWithHome();
const homePath = `/${owner.username}`;
@@ -148,6 +148,7 @@ interface UserRow {
id: number;
uuid: string;
username: string;
email?: string | null;
suspended?: number | null;
}
@@ -763,6 +764,7 @@ export const createPuterSiteMiddleware = (
uuid: owner.uuid,
id: owner.id,
username: owner.username,
email: owner.email ?? null,
suspended: !!owner.suspended,
},
};
@@ -334,6 +334,72 @@ describe('MeteringService', () => {
expect(stub).toHaveBeenCalledTimes(2);
});
// An actor built from a few row fields can't be resolved by email, so
// its answer must not stand in for the account's plan.
it('never caches an answer resolved without the email, but serves one from the cache', async () => {
target.registerPolicy({
id: 'by-email',
monthUsageAllowance: toMicroCents(5),
monthlyStorageAllowance: 1024 * 1024 * 1024,
});
const stub = vi.fn(async (a: Actor) =>
a.user.email ? 'by-email' : null,
);
target.registerDefaultSubscriptionResolver(stub);
const { email: _email, ...partialUser } = actor.user;
const partial: Actor = { user: partialUser };
expect((await target.getActorSubscription(partial)).id).toBe(
DEFAULT_TEMP_SUBSCRIPTION,
);
expect((await target.getActorSubscription(actor)).id).toBe(
'by-email',
);
expect((await target.getActorSubscription(partial)).id).toBe(
'by-email',
);
expect(stub).toHaveBeenCalledTimes(2);
});
it('caches the fallback from a failed resolver only briefly', async () => {
target.registerPolicy({
id: 'flaky-paid',
monthUsageAllowance: toMicroCents(10),
monthlyStorageAllowance: 1024 * 1024 * 1024,
});
const stub = vi
.fn<() => Promise<string | null>>()
.mockRejectedValueOnce(new Error('store unavailable'))
.mockResolvedValue('flaky-paid');
target.registerSubscriptionResolver(stub);
expect((await target.getActorSubscription(actor)).id).toBe(
DEFAULT_FREE_SUBSCRIPTION,
);
// Reused for the short window rather than re-asked per request.
expect((await target.getActorSubscription(actor)).id).toBe(
DEFAULT_FREE_SUBSCRIPTION,
);
expect(stub).toHaveBeenCalledTimes(1);
const { SUBSCRIPTION_FALLBACK_CACHE_MS: fallbackMs } =
target.constructor as typeof MeteringService;
expect(fallbackMs).toBeLessThan(
(target.constructor as typeof MeteringService)
.SUBSCRIPTION_CACHE_MS,
);
const now = Date.now();
vi.spyOn(Date, 'now').mockReturnValue(now + fallbackMs + 1);
try {
expect((await target.getActorSubscription(actor)).id).toBe(
'flaky-paid',
);
} finally {
vi.mocked(Date.now).mockRestore();
}
expect(stub).toHaveBeenCalledTimes(2);
});
it('rejects an actor with no user uuid', async () => {
await expect(
target.getActorSubscription({
@@ -1907,6 +1973,38 @@ describe('MeteringService', () => {
getSpy.mockRestore();
});
it('caches the allowance from a failed override read only briefly', async () => {
paidPolicy('month-paid', 10);
target.registerSubscriptionResolver(async () => 'month-paid');
await target.setMonthAllowance(
actor.user.uuid!,
'month-paid',
toMicroCents(4),
);
const getSpy = vi
.spyOn(server.stores.kv, 'get')
.mockRejectedValueOnce(new Error('kv down'));
await target.getActorSubscription(actor);
getSpy.mockRestore();
const { SUBSCRIPTION_FALLBACK_CACHE_MS: fallbackMs } =
target.constructor as typeof MeteringService;
expect(fallbackMs).toBeLessThan(
(target.constructor as typeof MeteringService)
.SUBSCRIPTION_CACHE_MS,
);
const now = Date.now();
vi.spyOn(Date, 'now').mockReturnValue(now + fallbackMs + 1);
try {
expect(
(await target.getActorSubscription(actor))
.monthUsageAllowance,
).toBe(toMicroCents(4));
} finally {
vi.mocked(Date.now).mockRestore();
}
});
it('refuses an allowance that would read as unmetered', async () => {
await expect(
target.setMonthAllowance(actor.user.uuid!, 'month-paid', 0),
@@ -180,6 +180,13 @@ export class MeteringService extends PuterService {
static SUBSCRIPTION_CACHE_MS = 60_000;
static SUBSCRIPTION_CACHE_LIMIT = 50_000;
/**
* How long an answer is reused when a lookup behind it failed and it fell
* back. Short so the real plan returns soon after the store recovers, but
* long enough that an outage doesn't send every gated request back to it.
*/
static SUBSCRIPTION_FALLBACK_CACHE_MS = 5_000;
/**
* How long "does this actor have budget left" is reused before being
* recomputed, and how many actors are remembered at once.
@@ -449,7 +456,9 @@ export class MeteringService extends PuterService {
/**
* Register a resolver that maps an actor to a subscription id. The first
* resolver that returns a non-empty id wins; later resolvers are skipped.
* resolver that returns a non-empty id wins; later resolvers are skipped. A
* resolver whose lookup fails should throw rather than return null, so the
* fallback it causes isn't cached as the actor's plan.
*/
registerSubscriptionResolver(fn: SubscriptionResolver): void {
this.subscriptionResolvers.push(fn);
@@ -2004,7 +2013,13 @@ export class MeteringService extends PuterService {
const cached = this.subscriptionCache.get(uuid);
if (cached && cached.expiresAt > now) return cached.policy;
const policy = await this.#resolveActorSubscription(actor);
const { policy, lookupFailed } =
await this.#resolveActorSubscription(actor);
// Without the email (an actor built from a few row fields), the
// email-keyed default and fallback can't be resolved, so the answer
// isn't this account's plan and must not be cached under it.
if (actor.user.email === undefined) return policy;
// Map preserves insertion order; FIFO-evict so a flood of one-shot
// actors can't grow this without bound.
@@ -2017,26 +2032,36 @@ export class MeteringService extends PuterService {
}
this.subscriptionCache.set(uuid, {
policy,
expiresAt: now + MeteringService.SUBSCRIPTION_CACHE_MS,
expiresAt:
now +
(lookupFailed
? MeteringService.SUBSCRIPTION_FALLBACK_CACHE_MS
: MeteringService.SUBSCRIPTION_CACHE_MS),
});
return policy;
}
async #resolveActorSubscription(actor: Actor): Promise<SubscriptionPolicy> {
/** `lookupFailed`: a resolver or the allowance read threw, so it fell back. */
async #resolveActorSubscription(
actor: Actor,
): Promise<{ policy: SubscriptionPolicy; lookupFailed: boolean }> {
const fallbackDefault = this.config.unlimitedMetering
? UNLIMITED_SUBSCRIPTION
: actor.user?.email
? DEFAULT_FREE_SUBSCRIPTION
: DEFAULT_TEMP_SUBSCRIPTION;
const resolvedDefault =
(await this.firstResolver(
this.defaultSubscriptionResolvers,
actor,
)) || fallbackDefault;
const resolvedUser =
(await this.firstResolver(this.subscriptionResolvers, actor)) ||
resolvedDefault;
const defaults = await this.firstResolver(
this.defaultSubscriptionResolvers,
actor,
);
const resolvedDefault = defaults.id || fallbackDefault;
const user = await this.firstResolver(
this.subscriptionResolvers,
actor,
);
const resolvedUser = user.id || resolvedDefault;
const resolverFailed = defaults.failed || user.failed;
const availablePolicies: SubscriptionPolicy[] = [
...this.extraPolicies,
@@ -2055,31 +2080,43 @@ export class MeteringService extends PuterService {
availablePolicies.find((p) => p.id === resolvedUser) ??
availablePolicies.find((p) => p.id === resolvedDefault) ??
availablePolicies.find((p) => p.id === fallbackDefault);
if (policy) return this.#withMonthAllowance(actor.user.uuid!, policy);
if (policy) {
const withAllowance = await this.#withMonthAllowance(
actor.user.uuid!,
policy,
);
return {
policy: withAllowance.policy,
lookupFailed: resolverFailed || withAllowance.failed,
};
}
console.warn(
`[metering] no registered policy for '${resolvedUser}' or` +
` '${resolvedDefault}' — falling back to` +
` '${REGISTERED_USER_FREE.id}'`,
);
return REGISTERED_USER_FREE as SubscriptionPolicy;
return {
policy: REGISTERED_USER_FREE as SubscriptionPolicy,
lookupFailed: resolverFailed,
};
}
/**
* `policy` with the month's allowance override applied, if one is set for
* it (see `setMonthAllowance`). Free and unmetered policies never carry
* one, so they skip the read.
* one, so they skip the read. `failed` when the read threw.
*/
async #withMonthAllowance(
userUuid: string,
policy: SubscriptionPolicy,
month: string = this.monthYearString(),
): Promise<SubscriptionPolicy> {
): Promise<{ policy: SubscriptionPolicy; failed: boolean }> {
if (
isFreeSubscription(policy.id) ||
policy.id === UNLIMITED_SUBSCRIPTION ||
!(policy.monthUsageAllowance > 0)
) {
return policy;
return { policy, failed: false };
}
try {
const allowance = await this.#readMonthAllowance(
@@ -2087,18 +2124,22 @@ export class MeteringService extends PuterService {
policy.id,
month,
);
return allowance === null
? policy
: ({
...policy,
monthUsageAllowance: allowance,
} as SubscriptionPolicy);
return {
policy:
allowance === null
? policy
: ({
...policy,
monthUsageAllowance: allowance,
} as SubscriptionPolicy),
failed: false,
};
} catch (e) {
// The policy's own allowance is what applied before any override.
console.warn(
`[metering] month allowance read failed for ${userUuid}: ${(e as Error).message}`,
);
return policy;
return { policy, failed: true };
}
}
@@ -2264,7 +2305,7 @@ export class MeteringService extends PuterService {
): Promise<number | null> {
const policy = this.getRegisteredPolicy(policyId);
if (!policy) return null;
return (await this.#withMonthAllowance(userUuid, policy, month))
return (await this.#withMonthAllowance(userUuid, policy, month)).policy
.monthUsageAllowance;
}
@@ -2363,19 +2404,22 @@ export class MeteringService extends PuterService {
tracked.finally(() => this.pendingAuxPromises.delete(tracked));
}
/** The first non-empty answer, and whether any resolver threw on the way. */
private async firstResolver(
resolvers: SubscriptionResolver[],
actor: Actor,
): Promise<string | null> {
): Promise<{ id: string | null; failed: boolean }> {
let failed = false;
for (const resolver of resolvers) {
try {
const result = await resolver(actor);
if (result) return result;
if (result) return { id: result, failed };
} catch (e) {
failed = true;
console.warn('[metering] subscription resolver failed', e);
}
}
return null;
return { id: null, failed };
}
// -- Internals: monthly charges -----------------------------------
@@ -53,6 +53,8 @@ describe('ProfileService', () => {
* out of the next one's.
*/
const plans = new Map<string, string>();
/** Plans granted by address rather than bought, keyed by email. */
const plansByEmail = new Map<string, string>();
const makeUser = async (): Promise<UserRow> => {
const username = `pr-${Math.random().toString(36).slice(2, 10)}`;
@@ -106,6 +108,9 @@ describe('ProfileService', () => {
server.services.metering.registerSubscriptionResolver(
(actor) => plans.get(actor.user?.uuid ?? '') ?? null,
);
server.services.metering.registerDefaultSubscriptionResolver(
(actor) => plansByEmail.get(actor.user?.email ?? '') ?? null,
);
}, 120_000);
afterAll(async () => {
@@ -252,6 +257,21 @@ describe('ProfileService', () => {
expect(await service.isPubliclyVisible(user)).toBe(false);
});
it('resolves the owner plan the way the owner would, email-keyed plans included', async () => {
const user = await makeUser();
plansByEmail.set(user.email!, 'business');
try {
expect(await service.isPubliclyVisible(user)).toBe(true);
const own = await server.services.metering.getActorSubscription({
user,
});
expect(own.id).toBe('business');
} finally {
plansByEmail.delete(user.email!);
server.services.metering.invalidateActorSubscription(user.uuid);
}
});
it('withholds the hosted <uuid>.profile file for a free owner on site.access.check', async () => {
const user = await makeUser();
await service.updateProfile(user, { picture: PICTURE });
+10 -2
View File
@@ -46,7 +46,10 @@ const PICTURE_DATA_URL = /^data:image\/[a-z0-9.+-]+;base64,[a-z0-9+/]+={0,2}$/i;
const UUID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
type ProfileOwner = Pick<UserRow, 'id' | 'uuid' | 'username' | 'suspended'>;
type ProfileOwner = Pick<
UserRow,
'id' | 'uuid' | 'username' | 'email' | 'suspended'
>;
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -236,7 +239,12 @@ export class ProfileService extends PuterService {
return actorHasSubscription(
this.services.metering,
makeActor({
user: { id: user.id, uuid: user.uuid, username: user.username },
user: {
id: user.id,
uuid: user.uuid,
username: user.username,
email: user.email ?? null,
},
}),
true,
this.config,