diff --git a/doc/contributing-apis.md b/doc/contributing-apis.md index 0c4e40b1d..e414a75c8 100644 --- a/doc/contributing-apis.md +++ b/doc/contributing-apis.md @@ -56,7 +56,7 @@ Both are supported ways to define an API. **Prefer a controller when you need fi - **Driver methods** get their policies from the `@Driver` options: per-method `rateLimit` (limit/window/backend), `concurrent` in-flight caps (optionally `bySubscription`), `requireSubscription`, `requireReputation`, and `noUserSession`. The `/drivers/call` surface enforces them. - **A surface only paying accounts should reach** declares `requireSubscription` — the route option on a controller endpoint, the per-method `@Driver({ requireSubscription })` block on a driver (`/drivers/call` is one shared route, so a driver's requirement can't live in route options). `true` accepts any plan that isn't free, so a plan registered by an extension counts without core naming it; an array of policy ids (`['business', 'pro']`) accepts only those; `false` is "no requirement", the same as leaving it out. An empty array is a boot error rather than a silent no-op — it reads as subscribers-only while admitting everyone. Off unless asked for, and answered from the metering service's cached per-actor subscription, so it costs a map lookup. Distinct from `requireCredits`: this asks which plan the account is on, not whether it has budget left. Deployments with no paid plans (self-hosted installs) turn the whole thing off with `meteringEnforcement.subscriptions: false`. - **A surface only a trusted-enough account should reach** declares `requireReputation` — the route option on a controller endpoint, the per-method `@Driver({ requireReputation })` block on a driver. The value names a tier; what that tier takes is `reputationGate.tiers` in config, so the score a surface is worth is a deployment call and can be retuned without editing the surface. A tier the running config doesn't define is inert and everyone passes — an install that doesn't score its accounts must not start turning traffic away on a score it never computed — and `reputationGate.enabled: false` stops every declared gate at once. Opt-in, implies `requireAuth`, and denies with a bare 403 `reputation_required` that names neither the score nor the tier. Nothing in the tree declares one yet: the mechanism ships ahead of the enrollment. -- **An account must have verified a specific factor** — `requireVerified` (email, and only under `strict_email_verification_required`), `requirePhoneVerified`, `requireCardVerified`. These are opt-in and require the factor to have actually been verified, unlike the default-on gate that turns away accounts still carrying a pending verification the abuse harness asked for. `requireAnyVerified: ['phone', 'card']` is the OR of the last two: any listed factor verified at any point passes; otherwise only the factors the deployment can verify are asked for (an SMS provider configured, a card gate an extension reports on), and with none verifiable the gate is inert rather than locking the route on a self-hosted install. It denies with the first verifiable factor's code plus `factors`, the verifiable ones in the route's order, so a client can lead with one flow and offer the other. `verifiedFactorGate.enabled: false` in config stops every declared gate at once, for an SMS-provider outage. +- **An account must have verified a specific factor** — `requireVerified` (email, and only under `strict_email_verification_required`), `requirePhoneVerified`, `requireCardVerified`. These are opt-in and require the factor to have actually been verified, unlike the default-on gate that turns away accounts still carrying a pending verification the abuse harness asked for. `requireAnyVerified: ['phone', 'card']` is the OR of the last two: any listed factor verified at any point passes, as does a paid plan where `card` is listed (a paying account has a card on file already); otherwise only the factors the deployment can verify are asked for (an SMS provider configured, a card gate an extension reports on), and with none verifiable the gate is inert rather than locking the route on a self-hosted install. It denies with the first verifiable factor's code plus `factors`, the verifiable ones in the route's order, so a client can lead with one flow and offer the other. `verifiedFactorGate.enabled: false` in config stops every declared gate at once, for an SMS-provider outage. - **An endpoint that spends metered resources** on the caller's behalf — moving file content, making object-store requests, anything else the account is billed for — also declares `requireCredits: true`, which turns an account with nothing left of its budget away with a 402 before the handler runs. Endpoints that only describe or delete things deliberately don't: an account that has run out still has to be able to see what it has, clear it, and reach its billing pages. Drivers have no route options to declare this on, so they call `assertActorHasCredits` themselves ([src/backend/services/metering/enforcement.ts](../src/backend/services/metering/enforcement.ts)) — see `KVStoreDriver`, which does it once for every method. ### 3. puter.js diff --git a/src/backend/controllers/peer/costs.ts b/src/backend/controllers/peer/costs.ts index 3b34a5b58..8a0dc563b 100644 --- a/src/backend/controllers/peer/costs.ts +++ b/src/backend/controllers/peer/costs.ts @@ -17,7 +17,9 @@ * along with this program. If not, see . */ +import { toMicroCents } from '../../services/metering/utils'; + // Microcents per byte of TURN egress ($0.05/GB). export const PEER_COSTS = { - 'turn:egress-bytes': 0.005, + 'turn:egress-bytes': toMicroCents(0.1 / 1000 ** 3), } as const; diff --git a/src/backend/controllers/share/ShareController.verification.http.test.ts b/src/backend/controllers/share/ShareController.verification.http.test.ts index b51578127..a57c0ea84 100644 --- a/src/backend/controllers/share/ShareController.verification.http.test.ts +++ b/src/backend/controllers/share/ShareController.verification.http.test.ts @@ -17,7 +17,7 @@ * along with this program. If not, see . */ -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { createTestUser, setupPuterTestEnv, @@ -112,6 +112,27 @@ describe('share verification gate over HTTP', () => { expect(await res.json()).toMatchObject({ status: 'success' }); }); + it('lets a paying owner share without either factor', async () => { + const owner = await makeUser(); + const row = await env.server.stores.user.getByUsername(owner.username); + const metering = env.server.services.metering; + const real = metering.getActorSubscription.bind(metering); + const spy = vi + .spyOn(metering, 'getActorSubscription') + .mockImplementation(async (actor) => + actor.user?.uuid === row!.uuid + ? ({ id: 'business' } as never) + : real(actor), + ); + try { + const file = await makeFile(owner); + expect((await share(owner, file.uid)).status).toBe(200); + } finally { + spy.mockRestore(); + metering.invalidateActorSubscription(row!.uuid); + } + }); + it('accepts a verified card once an extension reports the card gate on', async () => { const cardGateOn = ( _key: string, diff --git a/src/backend/core/http/middleware/gates.test.ts b/src/backend/core/http/middleware/gates.test.ts index e41ca17c0..bf31d60ba 100644 --- a/src/backend/core/http/middleware/gates.test.ts +++ b/src/backend/core/http/middleware/gates.test.ts @@ -19,10 +19,7 @@ import type { Request, Response } from 'express'; import { beforeEach, describe, expect, it } from 'vitest'; -import { - resetCardVerificationStatusCache, - type CardFallbackDeps, -} from '../../../util/cardFallback'; +import { resetCardVerificationStatusCache } from '../../../util/cardFallback'; import { makeActor, type Actor } from '../../actor'; import { HttpError, isHttpError } from '../HttpError'; import { @@ -32,6 +29,7 @@ import { assertNotUserSession, noUserSessionGate, requireAnyVerifiedGate, + type AnyVerifiedDeps, requireAuthGate, requireCardVerifiedGate, requirePhoneVerifiedGate, @@ -93,6 +91,23 @@ const runGate = ( return captured; }; +/** `runGate` for a gate that answers asynchronously. */ +const runGateAsync = ( + gate: ( + req: Request, + res: Response, + next: (arg?: unknown) => void, + ) => unknown, + req: Partial, +): Promise => { + if (req.actor) req = { ...req, actor: reviveActor(req.actor) }; + return new Promise((resolve) => + gate(req as Request, {} as Response, (arg?: unknown) => + resolve(arg as NextArg), + ), + ); +}; + const expectHttpError = (got: NextArg, status: number, legacyCode?: string) => { expect(isHttpError(got)).toBe(true); const err = got as HttpError; @@ -952,22 +967,31 @@ describe('requirePhoneVerifiedGate', () => { }); describe('requireCardVerifiedGate', () => { - it('passes a user with a verified card on file', () => { - const got = runGate(requireCardVerifiedGate(), { + const plan = (paid: boolean) => ({ hasPaidPlan: async () => paid }); + + it('passes a user with a verified card on file', async () => { + const got = await runGateAsync(requireCardVerifiedGate(plan(false)), { actor: { user: { uuid: 'u-1', card_fingerprint: 'fp_1' } }, }); expect(got).toBeUndefined(); }); - it('rejects a user who never verified a card', () => { - const got = runGate(requireCardVerifiedGate(), { + it('passes a paying account as card-verified', async () => { + const got = await runGateAsync(requireCardVerifiedGate(plan(true)), { + actor: { user: { uuid: 'u-1' } }, + }); + expect(got).toBeUndefined(); + }); + + it('rejects a user who never verified a card', async () => { + const got = await runGateAsync(requireCardVerifiedGate(plan(false)), { actor: { user: { uuid: 'u-1' } }, }); expectHttpError(got, 403, 'card_verification_required'); }); - it('rejects a user still carrying the card gate', () => { - const got = runGate(requireCardVerifiedGate(), { + it('rejects a user still carrying the card gate', async () => { + const got = await runGateAsync(requireCardVerifiedGate(plan(false)), { actor: { user: { uuid: 'u-1', @@ -986,27 +1010,20 @@ describe('requireAnyVerifiedGate', () => { // The card probe is memoized module-wide; every case starts cold. beforeEach(() => resetCardVerificationStatusCache()); - const deps = (sms: boolean, card: boolean | null): CardFallbackDeps => ({ + const deps = ( + sms: boolean, + card: boolean | null, + paid = false, + ): AnyVerifiedDeps => ({ smsConfigured: () => sms, probeCardVerification: async () => card, + hasPaidPlan: async () => paid, }); - const runAsync = ( - gate: ReturnType, - req: Partial, - ): Promise => { - if (req.actor) req = { ...req, actor: reviveActor(req.actor) }; - return new Promise((resolve) => - gate(req as Request, {} as Response, (arg?: unknown) => - resolve(arg as NextArg), - ), - ); - }; - const both = ['phone', 'card'] as const; it('is inert where neither factor can be verified', async () => { - const got = await runAsync( + const got = await runGateAsync( requireAnyVerifiedGate(both, deps(false, null)), { actor: { user: { uuid: 'u-1' } } }, ); @@ -1014,7 +1031,7 @@ describe('requireAnyVerifiedGate', () => { }); it('passes a verified number whatever the deployment can do today', async () => { - const got = await runAsync( + const got = await runGateAsync( requireAnyVerifiedGate(both, deps(false, null)), { actor: { user: { uuid: 'u-1', phone: '+15550000000' } } }, ); @@ -1022,15 +1039,50 @@ describe('requireAnyVerifiedGate', () => { }); it('passes a verified card', async () => { - const got = await runAsync( + const got = await runGateAsync( requireAnyVerifiedGate(both, deps(true, true)), { actor: { user: { uuid: 'u-1', card_fingerprint: 'fp_1' } } }, ); expect(got).toBeUndefined(); }); + it('takes a paid plan as the card, verified by other means', async () => { + const got = await runGateAsync( + requireAnyVerifiedGate(both, deps(true, true, true)), + { actor: { user: { uuid: 'u-1' } } }, + ); + expect(got).toBeUndefined(); + }); + + it('does not let a paid plan stand in for a phone', async () => { + const got = await runGateAsync( + requireAnyVerifiedGate(['phone'], deps(true, true, true)), + { actor: { user: { uuid: 'u-1' } } }, + ); + expectHttpError(got, 403, 'phone_verification_required'); + }); + + it('asks about the plan only once the row itself has not answered', async () => { + let asked = 0; + const counting: AnyVerifiedDeps = { + ...deps(true, true), + hasPaidPlan: async () => { + asked++; + return true; + }, + }; + await runGateAsync(requireAnyVerifiedGate(both, counting), { + actor: { user: { uuid: 'u-1', phone: '+15550000000' } }, + }); + expect(asked).toBe(0); + await runGateAsync(requireAnyVerifiedGate(both, counting), { + actor: { user: { uuid: 'u-1' } }, + }); + expect(asked).toBe(1); + }); + it('leads with the phone flow and names both factors when both are verifiable', async () => { - const got = await runAsync( + const got = await runGateAsync( requireAnyVerifiedGate(both, deps(true, true)), { actor: { user: { uuid: 'u-1' } } }, ); @@ -1041,7 +1093,7 @@ describe('requireAnyVerifiedGate', () => { }); it('asks only for the factors the deployment can verify', async () => { - const got = await runAsync( + const got = await runGateAsync( requireAnyVerifiedGate(both, deps(false, true)), { actor: { user: { uuid: 'u-1' } } }, ); @@ -1050,7 +1102,7 @@ describe('requireAnyVerifiedGate', () => { }); it('takes the lead factor from the route order', async () => { - const got = await runAsync( + const got = await runGateAsync( requireAnyVerifiedGate(['card', 'phone'], deps(true, true)), { actor: { user: { uuid: 'u-1' } } }, ); @@ -1061,7 +1113,7 @@ describe('requireAnyVerifiedGate', () => { }); it('does not count a factor still mid-verification', async () => { - const got = await runAsync( + const got = await runGateAsync( requireAnyVerifiedGate(both, deps(true, null)), { actor: { @@ -1078,7 +1130,7 @@ describe('requireAnyVerifiedGate', () => { }); it('rejects when there is no actor at all', async () => { - const got = await runAsync( + const got = await runGateAsync( requireAnyVerifiedGate(both, deps(true, null)), {}, ); diff --git a/src/backend/core/http/middleware/gates.ts b/src/backend/core/http/middleware/gates.ts index 3901b0d62..b697fcaf6 100644 --- a/src/backend/core/http/middleware/gates.ts +++ b/src/backend/core/http/middleware/gates.ts @@ -454,35 +454,62 @@ export const requirePhoneVerifiedGate = (): RequestHandler => { }; /** - * Reject unless the user has a card verified on file. 403 - * `card_verification_required`, matching the pending-verification gate so the - * client shows the card flow it already has. + * What the card-aware gates need beyond the user's own row: whether each factor + * can be verified here (the fallback deps), and whether the account's plan + * already vouches for it. */ -export const assertCardVerified = (user: AccountGateUser | undefined): void => { - if (hasVerifiedCard(user)) return; - throw new HttpError(403, CARD_REQUIRED_MESSAGE, { - legacyCode: 'card_verification_required', - }); -}; +export interface AnyVerifiedDeps extends CardFallbackDeps { + /** + * Whether the actor is on a paid plan. A paying account has a card on file + * with the billing provider, so it is treated as card-verified and never + * asked to verify one again. + */ + hasPaidPlan: (actor: Actor) => Promise; +} -/** Route-option form of {@link assertCardVerified} (`requireCardVerified`). */ -export const requireCardVerifiedGate = (): RequestHandler => { +/** + * The card factor by either proof: a card checked on the row, or a paid plan. + * The row answers first, so the plan is only looked up when it has to be. + */ +const hasCardEvidence = async ( + actor: Actor | undefined, + deps: Pick, +): Promise => + hasVerifiedCard(actor?.user) || + (actor !== undefined && (await deps.hasPaidPlan(actor))); + +/** + * Reject unless the user is card-verified — by a card on file, or by a paid + * plan. 403 `card_verification_required`, matching the pending-verification + * gate so the client shows the card flow it already has. Route-option form: + * `requireCardVerified`. + */ +export const requireCardVerifiedGate = ( + deps: Pick, +): RequestHandler => { return (req, _res, next) => { - try { - assertCardVerified(req.actor?.user); - } catch (err) { - next(err); - return; - } - next(); + hasCardEvidence(req.actor, deps).then((verified) => { + if (verified) { + next(); + return; + } + next( + new HttpError(403, CARD_REQUIRED_MESSAGE, { + legacyCode: 'card_verification_required', + }), + ); + }, next); }; }; const isFactorVerified = ( factor: VerificationFactor, - user: AccountGateUser | undefined, -): boolean => - factor === 'phone' ? hasVerifiedPhone(user) : hasVerifiedCard(user); + actor: Actor | undefined, + deps: Pick, +): Promise => + factor === 'phone' + ? Promise.resolve(hasVerifiedPhone(actor?.user)) + : hasCardEvidence(actor, deps); /** * Whether this deployment can put a user through the factor's flow at all: SMS @@ -500,7 +527,8 @@ const isFactorVerifiable = async ( /** * Reject unless the user has verified at least one of `factors` — the phone and * card gates above joined by OR. A factor verified at any point counts, - * whatever the deployment can do today. Failing that, only the factors the + * whatever the deployment can do today; for `card` a paid plan counts too, as + * it does in `requireCardVerified`. Failing that, only the factors the * deployment can currently verify are asked for; with none of them verifiable * the gate is inert, so a self-hosted install without SMS or a card gate never * has its route locked. @@ -510,11 +538,14 @@ const isFactorVerifiable = async ( * offer the rest as alternatives. */ export const assertAnyVerified = async ( - user: AccountGateUser | undefined, + actor: Actor | undefined, factors: readonly VerificationFactor[], - deps: CardFallbackDeps, + deps: AnyVerifiedDeps, ): Promise => { - if (factors.some((factor) => isFactorVerified(factor, user))) return; + // In the route's order, so a verified phone never costs the plan lookup. + for (const factor of factors) { + if (await isFactorVerified(factor, actor, deps)) return; + } const verifiable: VerificationFactor[] = []; for (const factor of factors) { if (verifiable.includes(factor)) continue; @@ -538,10 +569,10 @@ export const assertAnyVerified = async ( /** Route-option form of {@link assertAnyVerified} (`requireAnyVerified`). */ export const requireAnyVerifiedGate = ( factors: readonly VerificationFactor[], - deps: CardFallbackDeps, + deps: AnyVerifiedDeps, ): RequestHandler => { return (req, _res, next) => { - assertAnyVerified(req.actor?.user, factors, deps).then( + assertAnyVerified(req.actor, factors, deps).then( () => next(), (err) => next(err), ); diff --git a/src/backend/core/http/types.ts b/src/backend/core/http/types.ts index bb2e9d13f..282edece8 100644 --- a/src/backend/core/http/types.ts +++ b/src/backend/core/http/types.ts @@ -248,10 +248,11 @@ export interface RouteOptions { * Reject unless the user has verified at least one of the named factors — * `requirePhoneVerified` / `requireCardVerified` joined by OR, for a * surface where either proof will do. A factor verified at any point - * counts. Otherwise only the factors this deployment can verify are asked - * for (an SMS provider configured; a card gate an extension reports on), - * and with none of them verifiable the gate is inert rather than locking - * the route on a self-hosted install. + * counts, and so does a paid plan where `card` is listed — a paying account + * has a card on file already. Otherwise only the factors this deployment + * can verify are asked for (an SMS provider configured; a card gate an + * extension reports on), and with none of them verifiable the gate is inert + * rather than locking the route on a self-hosted install. * * 403 with the first verifiable factor's code * (`phone_verification_required` / `card_verification_required`) plus diff --git a/src/backend/drivers/kv/costs.ts b/src/backend/drivers/kv/costs.ts index fe31a2fce..fb469f631 100644 --- a/src/backend/drivers/kv/costs.ts +++ b/src/backend/drivers/kv/costs.ts @@ -27,10 +27,10 @@ export const KV_CACHED_READ_RATE_SHARE = 0.1; // Microcents per underlying DynamoDB capacity unit, as reported by // SystemKVStore.KVUsage. Cost is `KV_COSTS[op] * usage.`. export const KV_COSTS = { - 'kv:read': 17, - 'kv:write': 90, + 'kv:read': 50, + 'kv:write': 250, // 10% of `kv:read` — kept as a literal so the reported rate is exactly this // and not a float artifact of the multiplication. The unit count is the one // the equivalent uncached read consumed. - 'kv:read:cached': 1.7, + 'kv:read:cached': 5, } as const; diff --git a/src/backend/server.ts b/src/backend/server.ts index 4a95c0e03..3e49000b3 100644 --- a/src/backend/server.ts +++ b/src/backend/server.ts @@ -35,6 +35,7 @@ import { createAuthProbe } from './core/http/middleware/authProbe'; import { createRequestContextMiddleware } from './core/http/middleware/requestContext'; import { createFingerprintMiddleware } from './core/http/middleware/fingerprint'; import { createErrorHandler } from './core/http/middleware/errorHandler'; +import type { Actor } from './core/actor'; import { isHttpError } from './core/http/HttpError'; import { adminOnlyGate, @@ -55,7 +56,10 @@ import { requireCreditsGate } from './core/http/middleware/credits'; import { requireReputationGate } from './core/http/middleware/reputation'; import { requireSubscriptionGate } from './core/http/middleware/subscription'; import { validateReputationRequirement } from './core/reputation'; -import { validateSubscriptionRequirement } from './services/metering/enforcement'; +import { + actorOnPaidPlan, + validateSubscriptionRequirement, +} from './services/metering/enforcement'; import { createStepUpGate } from './core/http/middleware/stepUpSession'; import { createNotFoundHandler } from './core/http/middleware/notFoundHandler'; import { cardFallbackDepsFrom } from './util/cardFallback'; @@ -1086,8 +1090,12 @@ export class PuterServer { if (opts.requirePhoneVerified) { mwChain.push(requirePhoneVerifiedGate()); } + // A paying account has a card on file already, so both card-aware + // gates take a paid plan as the card factor. + const hasPaidPlan = (actor: Actor) => + actorOnPaidPlan(this.services.metering, actor); if (opts.requireCardVerified) { - mwChain.push(requireCardVerifiedGate()); + mwChain.push(requireCardVerifiedGate({ hasPaidPlan })); } if (opts.requireAnyVerified) { // Same stance as the subscription requirement: an empty list reads @@ -1099,10 +1107,10 @@ export class PuterServer { } if (this.#config.verifiedFactorGate?.enabled !== false) { mwChain.push( - requireAnyVerifiedGate( - opts.requireAnyVerified, - cardFallbackDepsFrom(this.clients), - ), + requireAnyVerifiedGate(opts.requireAnyVerified, { + ...cardFallbackDepsFrom(this.clients), + hasPaidPlan, + }), ); } } diff --git a/src/backend/services/acl/ACLService.ts b/src/backend/services/acl/ACLService.ts index 1a553632f..9a1096c88 100644 --- a/src/backend/services/acl/ACLService.ts +++ b/src/backend/services/acl/ACLService.ts @@ -25,10 +25,7 @@ import { isSystemActor, makeActor } from '../../core/actor'; import { PermissionUtil } from '../permission/permissionUtil'; import { MANAGE_PERM_PREFIX } from '../permission/consts'; import { HttpError } from '../../core/http/HttpError.js'; -import { - subscriptionEnforcementEnabled, - subscriptionSatisfies, -} from '../metering/enforcement'; +import { actorHasSubscription } from '../metering/enforcement'; // -- Types ------------------------------------------------------------ @@ -274,16 +271,14 @@ export class ACLService extends PuterService { * map lookup once warm. */ async #planCoversLinkSharing(ownerUserId: number): Promise { - const metering = this.services.metering; - if (!metering || !subscriptionEnforcementEnabled(this.config)) { - return true; - } const owner = await this.stores.user.getById(ownerUserId); - if (!owner?.uuid) return false; - const subscription = await metering.getActorSubscription( + if (!owner) return false; + return actorHasSubscription( + this.services.metering, makeActor({ user: owner }), + true, + this.config, ); - return subscriptionSatisfies(subscription.id, true); } /** diff --git a/src/backend/services/fs/FSService.test.ts b/src/backend/services/fs/FSService.test.ts index c5b03af17..bd14c2210 100644 --- a/src/backend/services/fs/FSService.test.ts +++ b/src/backend/services/fs/FSService.test.ts @@ -1272,6 +1272,25 @@ describe('FSService signed (direct-to-S3) writes', () => { await fs.abortUrlWrite(user.userId, response.sessionId); }); + it('keeps a zero-byte write single even when multipart is requested', async () => { + // Multipart on a zero declared size is how a caller reaches for a part + // URL carrying no size limit, against a quota check that saw nothing. + const response = await fs.startUrlWrite(user.userId, { + fileMetadata: { + path: `${user.home}/Documents/empty.bin`, + size: 0, + contentType: 'application/octet-stream', + }, + uploadMode: 'multipart', + }); + + expect(response.uploadMode).toBe('single'); + expect(response.multipartUploadId).toBeFalsy(); + expect(response.multipartPartUrls).toBeFalsy(); + + await fs.abortUrlWrite(user.userId, response.sessionId); + }); + it('aborts the multipart upload when the pending row cannot be written', async () => { const abort = vi.spyOn(server.stores.s3Object, 'abortMutipartUpload'); const createPendingEntry = vi diff --git a/src/backend/services/fs/FSService.ts b/src/backend/services/fs/FSService.ts index 219ef3a7d..fbe5bb8a8 100644 --- a/src/backend/services/fs/FSService.ts +++ b/src/backend/services/fs/FSService.ts @@ -769,6 +769,11 @@ export class FSService extends PuterService { ): UploadMode { const maxSingleUploadSize = this.stores.s3Object.getMaxSingleUploadSize(); + // An empty object has no part to carry it, so a multipart request + // here can only be an attempt at a part URL bound to no bytes. + if (size <= 0) { + return 'single'; + } if (requestUploadMode === 'multipart') { return 'multipart'; } diff --git a/src/backend/services/metering/costs.ts b/src/backend/services/metering/costs.ts index d3b008b6f..395b5773f 100644 --- a/src/backend/services/metering/costs.ts +++ b/src/backend/services/metering/costs.ts @@ -28,7 +28,7 @@ const BYTES_PER_GIB = 1024 * 1024 * 1024; * by the same door and cost the same per byte (~$0.12/GiB). */ export const EGRESS_COSTS = { - 'egress:bytes': toMicroCents(0.12 / BYTES_PER_GIB), + 'egress:bytes': toMicroCents(0.2 / BYTES_PER_GIB), } as const; /** @@ -37,8 +37,8 @@ export const EGRESS_COSTS = { * than one large one — which is what these price in. Removals are free. */ export const STORAGE_OP_COSTS = { - 'storage:write:ops': toMicroCents(0.005 / 1000), - 'storage:read:ops': toMicroCents(0.0004 / 1000), + 'storage:write:ops': toMicroCents(0.01 / 1000), + 'storage:read:ops': toMicroCents(0.001 / 1000), 'storage:delete:ops': 0, } as const; diff --git a/src/backend/services/metering/enforcement.test.ts b/src/backend/services/metering/enforcement.test.ts index 1fbd01b4e..9ef904725 100644 --- a/src/backend/services/metering/enforcement.test.ts +++ b/src/backend/services/metering/enforcement.test.ts @@ -22,6 +22,8 @@ import { SYSTEM_ACTOR, type Actor } from '../../core/actor.js'; import { HttpError } from '../../core/http/HttpError.js'; import type { IConfig } from '../../types'; import { + actorHasSubscription, + actorOnPaidPlan, assertActorHasCredits, assertActorHasSubscription, creditEnforcementExempt, @@ -154,6 +156,87 @@ const onPlan = (id: string) => ({ getActorSubscription: vi.fn().mockResolvedValue({ id }), }); +describe('actorHasSubscription', () => { + const metering = (id: string) => ({ + getActorSubscription: vi.fn().mockResolvedValue({ id }), + }); + const user: Actor = { user: { uuid: 'u-1' } } as Actor; + const config = {} as IConfig; + + it('answers the plan question as a yes or no', async () => { + expect( + await actorHasSubscription( + metering('business'), + user, + true, + config, + ), + ).toBe(true); + expect( + await actorHasSubscription( + metering('user_free'), + user, + true, + config, + ), + ).toBe(false); + }); + + it('is a yes wherever the assertion would not ask', async () => { + expect( + await actorHasSubscription( + metering('user_free'), + user, + false, + config, + ), + ).toBe(true); + expect(await actorHasSubscription(undefined, user, true, config)).toBe( + true, + ); + expect( + await actorHasSubscription(metering('user_free'), user, true, { + meteringEnforcement: { subscriptions: false }, + } as IConfig), + ).toBe(true); + expect( + await actorHasSubscription( + metering('user_free'), + SYSTEM_ACTOR, + true, + config, + ), + ).toBe(true); + }); + + it('is a no for an actor with no account behind it', async () => { + const asked = metering('business'); + expect(await actorHasSubscription(asked, undefined, true, config)).toBe( + false, + ); + expect(asked.getActorSubscription).not.toHaveBeenCalled(); + }); +}); + +describe('actorOnPaidPlan', () => { + const metering = (id: string) => ({ + getActorSubscription: vi.fn().mockResolvedValue({ id }), + }); + const user: Actor = { user: { uuid: 'u-1' } } as Actor; + + it('reads the plan as a fact, whatever the enforcement switches say', async () => { + expect(await actorOnPaidPlan(metering('business'), user)).toBe(true); + expect(await actorOnPaidPlan(metering('user_free'), user)).toBe(false); + }); + + it('is a no with nothing to ask, or nobody to ask about', async () => { + expect(await actorOnPaidPlan(undefined, user)).toBe(false); + const asked = metering('business'); + expect(await actorOnPaidPlan(asked, undefined)).toBe(false); + expect(asked.getActorSubscription).not.toHaveBeenCalled(); + }); +}); + describe('subscriptionSatisfies', () => { it('counts anything that is not a free policy as a subscription', () => { expect(subscriptionSatisfies('user_free', true)).toBe(false); diff --git a/src/backend/services/metering/enforcement.ts b/src/backend/services/metering/enforcement.ts index 79a34b789..ad8c8b595 100644 --- a/src/backend/services/metering/enforcement.ts +++ b/src/backend/services/metering/enforcement.ts @@ -168,6 +168,59 @@ export const subscriptionEnforcementEnabled = ( enforcementEnabled(config) && config.meteringEnforcement?.subscriptions !== false; +/** + * The cases in which no plan is asked about at all: nothing was required, there + * is no metering to ask, plan gates are switched off, or the caller is the + * system. Shared by the check and the assertion so the two cannot drift. + */ +const subscriptionCheckWaived = ( + metering: SubscriptionMetering | undefined, + actor: Actor | undefined, + requirement: SubscriptionRequirement, + config: EnforcementConfig, +): boolean => + requirement === false || + (Array.isArray(requirement) && requirement.length === 0) || + !metering || + !subscriptionEnforcementEnabled(config) || + Boolean(actor && isSystemActor(actor)); + +/** + * Whether an actor's plan covers a plan-gated surface right now. The same + * question {@link assertActorHasSubscription} asks of a caller, as a yes or no — + * for an account that is not the caller (an owner whose link share is being + * read or listed), where there is no request to refuse. + */ +export const actorHasSubscription = async ( + metering: SubscriptionMetering | undefined, + actor: Actor | undefined, + requirement: SubscriptionRequirement, + config: EnforcementConfig, +): Promise => { + if (subscriptionCheckWaived(metering, actor, requirement, config)) { + return true; + } + if (!actor?.user?.uuid) return false; + const subscription = await metering!.getActorSubscription(actor); + return subscriptionSatisfies(subscription.id, requirement); +}; + +/** + * Whether the actor is on a paid plan — the fact, not the gate. Unlike + * {@link actorHasSubscription} this ignores the enforcement switches: it is for + * a caller reading the plan as evidence (a paying account has a card on file + * with the billing provider) rather than as an entitlement to enforce. False + * with no metering to ask, or no account behind the actor. + */ +export const actorOnPaidPlan = async ( + metering: SubscriptionMetering | undefined, + actor: Actor | undefined, +): Promise => { + if (!metering || !actor?.user?.uuid) return false; + const subscription = await metering.getActorSubscription(actor); + return subscriptionSatisfies(subscription.id, true); +}; + /** * Reject a caller whose plan doesn't cover the surface they're calling. * @@ -185,11 +238,7 @@ export const assertActorHasSubscription = async ( requirement: SubscriptionRequirement, config: EnforcementConfig, ): Promise => { - if (requirement === false) return; - if (Array.isArray(requirement) && requirement.length === 0) return; - if (!metering) return; - if (!subscriptionEnforcementEnabled(config)) return; - if (actor && isSystemActor(actor)) return; + if (subscriptionCheckWaived(metering, actor, requirement, config)) return; if (!actor?.user?.uuid) { throw new HttpError(403, 'A subscription is required for this action', { diff --git a/src/backend/services/share/ShareService.test.ts b/src/backend/services/share/ShareService.test.ts index adf1147b2..37e35102f 100644 --- a/src/backend/services/share/ShareService.test.ts +++ b/src/backend/services/share/ShareService.test.ts @@ -679,12 +679,36 @@ describe('ShareService', () => { owner.user.uuid, ); expect(await canRead(stranger.actor, file.path)).toBe(false); + // A link nobody can use is not shown as a share anywhere: the + // item's own listing, the owner's outbound one, or the badge. + expect( + await server.services.share.listSharesOf(owner.actor, { + uid: file.uuid, + }), + ).toEqual([]); + expect( + (await server.services.share.listSharedByMe(owner.actor)).items, + ).toEqual([]); + expect( + await server.services.share.shareFlags(owner.actor, [file]), + ).toEqual(new Map([[file.uuid, false]])); paid.add(owner.user.uuid); server.services.metering.invalidateActorSubscription( owner.user.uuid, ); expect(await canRead(stranger.actor, file.path)).toBe(true); + // The row was never dropped, so the plan brings it back as it was. + expect( + await server.services.share.listSharesOf(owner.actor, { + uid: file.uuid, + }), + ).toEqual([ + expect.objectContaining({ anyone: true, mode: 'read' }), + ]); + expect( + await server.services.share.shareFlags(owner.actor, [file]), + ).toEqual(new Map([[file.uuid, true]])); }); it('hands out access, never authority', async () => { diff --git a/src/backend/services/share/ShareService.ts b/src/backend/services/share/ShareService.ts index 123852cd5..b4d1eb2d7 100644 --- a/src/backend/services/share/ShareService.ts +++ b/src/backend/services/share/ShareService.ts @@ -19,7 +19,7 @@ import { contentType as contentTypeFromMime } from 'mime-types'; import { posix as pathPosix } from 'node:path'; -import { userRelatedActor, type Actor } from '../../core/actor'; +import { makeActor, userRelatedActor, type Actor } from '../../core/actor'; import { HttpError, isHttpError } from '../../core/http/HttpError.js'; import { runWithConcurrencyLimitSettled } from '../../util/concurrency.js'; import { isUniqueViolation } from '../../util/dbError.js'; @@ -38,7 +38,10 @@ import { maskEntryPath, resolveSharePath, } from '../fs/sharePathMask'; -import { assertActorHasSubscription } from '../metering/enforcement.js'; +import { + actorHasSubscription, + assertActorHasSubscription, +} from '../metering/enforcement.js'; import { MANAGE_PERM_PREFIX } from '../permission/consts'; import { PermissionUtil } from '../permission/permissionUtil.js'; import { PuterService } from '../types'; @@ -1741,6 +1744,12 @@ export class ShareService extends PuterService { this.#liveGroupGrants(rows, nodeById), ]); + // Link rows are the caller's own (owner-only by construction), so one + // answer covers them all; only asked when there is one to show. + const linkCovered = rows.some((row) => row.anyone) + ? await this.#linkSharingCovered(actor) + : true; + const items: ResolvedShare[] = []; for (const row of rows) { const entry = entries.get(Number(row.fsentry_id)); @@ -1754,8 +1763,10 @@ export class ShareService extends PuterService { if (pending && !pendingAllowed.has(row.uid)) continue; // Its liveness is the grant, not a holder's reach. if (anyone) { - // Owner-only by construction; a node that changed hands had - // its rows dropped, so this only catches a row that slipped. + // Silent while the plan is lapsed, so not listed either. + if (!linkCovered) continue; + // A node that changed hands had its rows dropped, so this only + // catches a row that slipped. if (entry.userId !== Number(row.issuer_user_id)) continue; } else if (row.holder_group_id) { if (!liveGroupGrants.has(row.uid)) continue; @@ -2093,12 +2104,18 @@ export class ShareService extends PuterService { ), ) ).flat(); - // And so can anyone with the link to a folder above it. - const anyoneRows: OutboundShareRow[] = + // And so can anyone with the link to a folder above it — while the + // owner's plan covers it. A link the ACL turns away is not a share the + // owner should see listed as one; it is silent, and stays silent until + // the plan is back. + let anyoneRows: OutboundShareRow[] = await this.stores.share.listAnyoneOnFsentries([ entry.id, ...viaById.keys(), ]); + if (anyoneRows.length > 0 && !(await this.#linkSharingCovered(entry))) { + anyoneRows = []; + } const userIds = [ ...[...rows, ...inherited.map((i) => i.row)].flatMap( (row: { issuer_user_id: number; holder_user_id: number }) => [ @@ -2219,8 +2236,10 @@ export class ShareService extends PuterService { ); if (own.length === 0) return new Map(); + // A link share nobody can use must not badge the item as shared. const sharedIds = await this.stores.share.getSharedFsentryIds( own.map((entry) => entry.id), + { includeAnyone: await this.#linkSharingCovered(actor) }, ); return new Map( own.map((entry) => [entry.uuid, sharedIds.has(entry.id)]), @@ -2600,6 +2619,28 @@ export class ShareService extends PuterService { } } + /** + * Whether link shares on this owner's items work right now — the ACL's + * question, asked here so no listing shows a link the ACL would turn away. + * Takes the owner's actor, or the entry to look the owner up from. + */ + async #linkSharingCovered(owner: Actor | FSEntry): Promise { + let actor: Actor | undefined; + if ('user' in owner) { + actor = owner; + } else { + const row = await this.stores.user.getById(owner.userId); + if (!row) return false; + actor = makeActor({ user: row }); + } + return actorHasSubscription( + this.services.metering, + actor, + true, + this.config, + ); + } + /** Link sharing is the owner's call, on and off alike. */ #assertOwnsLinkShare(entry: FSEntry, userId: number): void { if (entry.userId === userId) return; diff --git a/src/backend/stores/fs/S3ObjectStore.test.ts b/src/backend/stores/fs/S3ObjectStore.test.ts index 3843e2c02..af92c1cae 100644 --- a/src/backend/stores/fs/S3ObjectStore.test.ts +++ b/src/backend/stores/fs/S3ObjectStore.test.ts @@ -336,6 +336,48 @@ describe('S3ObjectStore object round trips', () => { ); }); + it('binds a part URL to zero bytes when the declared total size is zero', async () => { + const key = uuidv4(); + const opened = await store().createSignedUploadUrl( + { + bucket, + objectKey: key, + size: store().getMultipartPartSize() * 2, + contentType: 'application/octet-stream', + uploadMode: 'multipart', + expiresInSeconds: 900, + }, + region, + ); + + // A zero declared size leaves no bytes for the part, which has to bind + // as zero rather than fall through to an unbound URL. + const partUrls = await store().createSignedMultipartPartUrls( + { + bucket, + objectKey: key, + multipartUploadId: opened.multipartUploadId as string, + partNumbers: [1], + expiresInSeconds: 900, + declaredTotalSize: 0, + multipartPartSize: store().getMultipartPartSize(), + }, + region, + ); + + const signedHeaders = new URL( + partUrls[0]?.url as string, + ).searchParams.get('X-Amz-SignedHeaders'); + expect(signedHeaders).toContain('content-length'); + + await store().abortMutipartUpload( + opened.multipartUploadId as string, + region, + bucket, + key, + ); + }); + it('leaves part URLs unbound when the declared sizes are not supplied', async () => { const key = uuidv4(); const opened = await store().createSignedUploadUrl( diff --git a/src/backend/stores/fs/S3ObjectStore.ts b/src/backend/stores/fs/S3ObjectStore.ts index b66faeb6a..acf00d37f 100644 --- a/src/backend/stores/fs/S3ObjectStore.ts +++ b/src/backend/stores/fs/S3ObjectStore.ts @@ -302,7 +302,10 @@ export class S3ObjectStore extends PuterStore { } const offset = (partNumber - 1) * multipartPartSize; const remaining = declaredTotalSize - offset; - if (remaining <= 0) return undefined; + // Zero, not `undefined`: a declared size of 0 leaves nothing for + // the part to carry, and an omitted binding is an unbounded URL + // rather than a small one. + if (remaining <= 0) return 0; return Math.min(multipartPartSize, remaining); }; diff --git a/src/backend/stores/share/ShareStore.js b/src/backend/stores/share/ShareStore.js index de26d18e2..a2f83c059 100644 --- a/src/backend/stores/share/ShareStore.js +++ b/src/backend/stores/share/ShareStore.js @@ -371,12 +371,15 @@ export class ShareStore extends PuterStore { } /** - * Which of `fsentryIds` carry a share, pending invites included. + * Which of `fsentryIds` carry a share, pending invites included. Link + * shares count unless `includeAnyone` is false — what the caller passes + * while the owner's plan has them switched off. * * @param {number[]} fsentryIds + * @param {{ includeAnyone?: boolean }} [opts] * @returns {Promise>} */ - async getSharedFsentryIds(fsentryIds) { + async getSharedFsentryIds(fsentryIds, { includeAnyone = true } = {}) { const ids = [ ...new Set( fsentryIds.map(Number).filter((id) => Number.isFinite(id)), @@ -388,7 +391,8 @@ export class ShareStore extends PuterStore { const placeholders = chunk.map(() => '?').join(', '); const rows = await this.clients.db.read( 'SELECT DISTINCT `fsentry_id` FROM `share` ' + - `WHERE \`fsentry_id\` IN (${placeholders})`, + `WHERE \`fsentry_id\` IN (${placeholders})` + + (includeAnyone ? '' : ' AND `anyone` IS NULL'), chunk, ); for (const row of rows) shared.add(Number(row.fsentry_id)); diff --git a/src/docs/src/FS/getShares.md b/src/docs/src/FS/getShares.md index c849929e2..cc65f4554 100644 --- a/src/docs/src/FS/getShares.md +++ b/src/docs/src/FS/getShares.md @@ -44,7 +44,7 @@ A `Promise` that resolves to an array of share objects, each with `uid`, `mode`, The list includes shares granted by **anyone** holding `manage` on the item, not only your own. That is how an owner sees what someone they trusted has re-shared. -If the item is open to **anyone with the link** (see [`share()`](/FS/share/)), that share is listed too, with `anyone: true` and a `null` `holder` — inherited from a folder above when the folder is what was opened. +If the item is open to **anyone with the link** (see [`share()`](/FS/share/)), that share is listed too, with `anyone: true` and a `null` `holder` — inherited from a folder above when the folder is what was opened. It is left out while the owner's plan does not cover link sharing, because nobody can use it then. It also includes **invitations** — shares aimed at an email address with no confirmed account yet. Those carry `pending: true`, a `null` `holder`, and the address in `recipientEmail`. They grant nothing until the recipient confirms that address, and [`unshare()`](/FS/unshare/) cancels one before it is claimed. diff --git a/src/docs/src/FS/share.md b/src/docs/src/FS/share.md index e7665154d..03d57088a 100644 --- a/src/docs/src/FS/share.md +++ b/src/docs/src/FS/share.md @@ -84,7 +84,7 @@ If some recipients succeed and others fail, the promise resolves with the ones t A rejection carries `{ message, code }`. Because each recipient/item pair succeeds or fails on its own, these are the codes of the *pairs* that failed — you only see one as a rejection when every pair failed. -One refusal applies to the whole call instead: handing out access requires a verified phone number or a verified card on the account, on deployments that can verify either. The rejection is `phone_verification_required` (or `card_verification_required` where only a card can be verified) and carries `factors`, the verifications the deployment accepts, in the order to offer them. Inside the Puter desktop the user is walked through it and the call is retried on its own. Withdrawing and listing shares never ask for this. +One refusal applies to the whole call instead: handing out access requires a verified phone number or a verified card on the account, on deployments that can verify either. An account on a paid plan is never asked — its card is already on file. The rejection is `phone_verification_required` (or `card_verification_required` where only a card can be verified) and carries `factors`, the verifications the deployment accepts, in the order to offer them. Inside the Puter desktop the user is walked through it and the call is retried on its own. Withdrawing and listing shares never ask for this. | `code` | Meaning | | --- | --- | @@ -117,7 +117,7 @@ await puter.fs.unshare('report.txt', { anyone: true }); Three things set it apart from sharing with a person: - **It is the owner's call.** Someone holding `manage` on the item can share it with people, but not open it to everyone; they get `forbidden`. -- **It is a paid-plan feature.** A free account is refused with `subscription_required`. The plan is checked again every time the link is used, so while the owner has no plan the link is silent — nobody has to find it and take it back — and it works again once they do. +- **It is a paid-plan feature.** A free account is refused with `subscription_required`. The plan is checked again every time the link is used, so while the owner has no plan the link is silent — nobody has to find it and take it back — and it is not listed as a share by [`getShares()`](/FS/getShares/) or [`listSharedByMe()`](/FS/listSharedByMe/) either, since nobody can use it. The share itself is kept: once the owner is on a plan again the link works, and is listed, exactly as it was. - **Nobody is told.** No notification goes out, and the item does not appear in anyone's [`listShared()`](/FS/listShared/); whoever has the link opens it from the link. The share shows in [`getShares()`](/FS/getShares/) with `anyone: true` and a `null` `holder`. Sharing again with a different mode replaces it, as it does for a person.