diff --git a/src/backend/controllers/fs/LegacyFSController.routes.test.ts b/src/backend/controllers/fs/LegacyFSController.routes.test.ts index 7b989ee13..2833d4189 100644 --- a/src/backend/controllers/fs/LegacyFSController.routes.test.ts +++ b/src/backend/controllers/fs/LegacyFSController.routes.test.ts @@ -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( diff --git a/src/backend/controllers/fs/LegacyFSController.ts b/src/backend/controllers/fs/LegacyFSController.ts index b80defedf..fc36ab3ba 100644 --- a/src/backend/controllers/fs/LegacyFSController.ts +++ b/src/backend/controllers/fs/LegacyFSController.ts @@ -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, }, }; diff --git a/src/backend/controllers/peer/PeerController.test.ts b/src/backend/controllers/peer/PeerController.test.ts index 70315f1c6..afa93def0 100644 --- a/src/backend/controllers/peer/PeerController.test.ts +++ b/src/backend/controllers/peer/PeerController.test.ts @@ -458,6 +458,7 @@ describe('PeerController TURN', () => { uuid: created.uuid, id: created.id, username: created.username, + email: created.email, }, effectiveApp: null, }); diff --git a/src/backend/controllers/peer/PeerController.ts b/src/backend/controllers/peer/PeerController.ts index 88263ef25..5683d678f 100644 --- a/src/backend/controllers/peer/PeerController.ts +++ b/src/backend/controllers/peer/PeerController.ts @@ -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( diff --git a/src/backend/core/http/middleware/puterSite.test.ts b/src/backend/core/http/middleware/puterSite.test.ts index 1065a0a8b..e617b744a 100644 --- a/src/backend/core/http/middleware/puterSite.test.ts +++ b/src/backend/core/http/middleware/puterSite.test.ts @@ -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}`; diff --git a/src/backend/core/http/middleware/puterSite.ts b/src/backend/core/http/middleware/puterSite.ts index a2b472119..9da8e22e1 100644 --- a/src/backend/core/http/middleware/puterSite.ts +++ b/src/backend/core/http/middleware/puterSite.ts @@ -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, }, }; diff --git a/src/backend/services/metering/MeteringService.test.ts b/src/backend/services/metering/MeteringService.test.ts index 6450fdbdf..86a073df8 100644 --- a/src/backend/services/metering/MeteringService.test.ts +++ b/src/backend/services/metering/MeteringService.test.ts @@ -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>() + .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), diff --git a/src/backend/services/metering/MeteringService.ts b/src/backend/services/metering/MeteringService.ts index 9a95e1e1c..1dc5c8c37 100644 --- a/src/backend/services/metering/MeteringService.ts +++ b/src/backend/services/metering/MeteringService.ts @@ -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 { + /** `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 { + ): 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 { 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 { + ): 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 ----------------------------------- diff --git a/src/backend/services/profile/ProfileService.test.ts b/src/backend/services/profile/ProfileService.test.ts index 17b950241..d652a0e31 100644 --- a/src/backend/services/profile/ProfileService.test.ts +++ b/src/backend/services/profile/ProfileService.test.ts @@ -53,6 +53,8 @@ describe('ProfileService', () => { * out of the next one's. */ const plans = new Map(); + /** Plans granted by address rather than bought, keyed by email. */ + const plansByEmail = new Map(); const makeUser = async (): Promise => { 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 .profile file for a free owner on site.access.check', async () => { const user = await makeUser(); await service.updateProfile(user, { picture: PICTURE }); diff --git a/src/backend/services/profile/ProfileService.ts b/src/backend/services/profile/ProfileService.ts index e0e018005..a6123cdcf 100644 --- a/src/backend/services/profile/ProfileService.ts +++ b/src/backend/services/profile/ProfileService.ts @@ -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; +type ProfileOwner = Pick< + UserRow, + 'id' | 'uuid' | 'username' | 'email' | 'suspended' +>; const isPlainObject = (value: unknown): value is Record => 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,