mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-25 08:09:15 +00:00
fix(metering): stop caching subscription answers from email-less actors and failed lookups (PUT-2002) (#4052)
This commit is contained in:
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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user