diff --git a/src/backend/controllers/fs/FSController.ts b/src/backend/controllers/fs/FSController.ts index 8766dc05c..c20e31a18 100644 --- a/src/backend/controllers/fs/FSController.ts +++ b/src/backend/controllers/fs/FSController.ts @@ -52,7 +52,9 @@ import { consumeRouteRateLimit } from '../../core/http/middleware/rateLimit.js'; import { PuterController } from '../types.js'; import { STORAGE_OP_COSTS } from '../../services/metering/costs.js'; import { + FS_BATCH_WRITE_MAX_ITEMS, FS_MULTIPART_LIMIT, + FS_MULTIPART_MAX_PARTS, FS_MUTATE_LIMIT, FS_READ_CONCURRENT, FS_READ_LIMIT, @@ -222,6 +224,7 @@ export class FSController extends PuterController { const userId = this.#getActorUserId(req); const storageAllowanceMax = this.#getStorageAllowanceMaxOverride(req); const appUidLookupCache = new Map>(); + this.#assertBatchItemCount(req.body); const requests = Array.isArray(req.body) ? await Promise.all( req.body.map(async (requestBody) => { @@ -367,6 +370,7 @@ export class FSController extends PuterController { res: Response, ) { const userId = this.#getActorUserId(req); + this.#assertBatchItemCount(req.body); const requests = Array.isArray(req.body) ? req.body.map((requestBody) => { return this.#withGuiMetadata( @@ -443,6 +447,17 @@ export class FSController extends PuterController { res: Response, ) { const userId = this.#getActorUserId(req); + const partNumbers = req.body?.partNumbers; + if ( + Array.isArray(partNumbers) && + partNumbers.length > FS_MULTIPART_MAX_PARTS + ) { + throw new HttpError( + 400, + `Too many partNumbers in one request (max ${FS_MULTIPART_MAX_PARTS})`, + { legacyCode: 'bad_request' }, + ); + } await this.#assertUploadSessionWriteAccess(req, userId, [ req.body?.uploadId, ]); @@ -863,6 +878,7 @@ export class FSController extends PuterController { return; } + this.#assertBatchItemCount(req.body); const requests = Array.isArray(req.body) ? await Promise.all( req.body.map(async (requestBody) => { @@ -2382,6 +2398,16 @@ export class FSController extends PuterController { return guiMetadata; } + #assertBatchItemCount(items: unknown): void { + if (Array.isArray(items) && items.length > FS_BATCH_WRITE_MAX_ITEMS) { + throw new HttpError( + 400, + `Too many items in one request (max ${FS_BATCH_WRITE_MAX_ITEMS})`, + { legacyCode: 'bad_request' }, + ); + } + } + /** * The write handlers build on `req.body` being an object. A request whose * body never parsed leaves it undefined, and the first field read after @@ -3019,6 +3045,7 @@ export class FSController extends PuterController { { legacyCode: 'bad_request' }, ); } + this.#assertBatchItemCount(manifest.items); const manifestGuiMetadata = this.#extractGuiMetadata( manifest, diff --git a/src/backend/controllers/fs/FSController.write.test.ts b/src/backend/controllers/fs/FSController.write.test.ts index 3e76a1bdf..41649fd25 100644 --- a/src/backend/controllers/fs/FSController.write.test.ts +++ b/src/backend/controllers/fs/FSController.write.test.ts @@ -28,6 +28,7 @@ import { PuterServer } from '../../server.js'; import { setupTestServer } from '../../testUtil.js'; import { generateDefaultFsentries } from '../../util/userProvisioning.js'; import type { FSController } from './FSController.js'; +import { FS_BATCH_WRITE_MAX_ITEMS, FS_MULTIPART_MAX_PARTS } from './limits.js'; import type { ClientSignedWriteResponse, CompleteWriteRequest, @@ -661,6 +662,60 @@ describe('batch write handlers with a malformed element', () => { }); }); +describe('batch write handlers at the item cap', () => { + // Null elements fail the per-item body check, so which 400 comes back + // shows whether the cap ran before any item was read. + const handlers: Array< + [string, (c: typeof controller, r: Request, res: Response) => unknown] + > = [ + [ + 'startBatchWrites', + (c, r, res) => c.startBatchWrites(r as never, res as never), + ], + ['batchWrites', (c, r, res) => c.batchWrites(r as never, res as never)], + [ + 'completeBatchWrites', + (c, r, res) => c.completeBatchWrites(r as never, res as never), + ], + ]; + + const callWith = async ( + call: (typeof handlers)[number][1], + length: number, + ) => { + const { actor } = await makeUser(); + const req = makeReq({ actor }); + (req as { body?: unknown }).body = Array(length).fill(null); + const { res } = makeRes(); + return withActor(actor, async () => call(controller, req, res)); + }; + + it.each(handlers)( + '%s rejects one item over the cap up front', + async (_label, call) => { + await expect( + callWith(call, FS_BATCH_WRITE_MAX_ITEMS + 1), + ).rejects.toMatchObject({ + statusCode: 400, + legacyCode: 'bad_request', + message: `Too many items in one request (max ${FS_BATCH_WRITE_MAX_ITEMS})`, + }); + }, + ); + + it.each(handlers)( + '%s lets exactly the cap through to the per-item checks', + async (_label, call) => { + await expect( + callWith(call, FS_BATCH_WRITE_MAX_ITEMS), + ).rejects.toMatchObject({ + statusCode: 400, + message: 'A request body is required', + }); + }, + ); +}); + describe('FSController.write storage allowance', () => { let limitedServer: PuterServer; let limitedController: FSController; @@ -1283,6 +1338,29 @@ describe('FSController.signMultipartParts', () => { ), ).rejects.toMatchObject({ statusCode: 404 }); }); + + // The session is unknown, so reaching the lookup would answer 404. + it.each([ + [FS_MULTIPART_MAX_PARTS + 1, 400], + [FS_MULTIPART_MAX_PARTS, 404], + ])('answers %i part numbers with %i', async (length, statusCode) => { + const { actor } = await makeUser(); + const { res } = makeRes(); + await expect( + withActor(actor, () => + controller.signMultipartParts( + makeReq({ + body: { + uploadId: uuidv4(), + partNumbers: Array(length).fill(1), + }, + actor, + }), + res, + ), + ), + ).rejects.toMatchObject({ statusCode }); + }); }); // -- /fs/batchWrite (JSON mode) --------------------------------------- @@ -1716,6 +1794,35 @@ describe('FSController.batchWrites (multipart)', () => { }); }); + it('rejects a manifest over the item cap', async () => { + const { actor } = await makeUser(); + const { res } = makeRes(); + await expect( + withActor(actor, () => + controller.batchWrites( + makeMultipartReq( + [ + { + kind: 'field', + name: 'manifest', + value: JSON.stringify({ + items: Array( + FS_BATCH_WRITE_MAX_ITEMS + 1, + ).fill(null), + }), + }, + ], + actor, + ), + res, + ), + ), + ).rejects.toMatchObject({ + statusCode: 400, + message: `Too many items in one request (max ${FS_BATCH_WRITE_MAX_ITEMS})`, + }); + }); + it('rejects a manifest item without fileMetadata', async () => { const { actor } = await makeUser(); const { res } = makeRes(); diff --git a/src/backend/controllers/fs/LegacyFSController.test.ts b/src/backend/controllers/fs/LegacyFSController.test.ts index b52768f41..29924c479 100644 --- a/src/backend/controllers/fs/LegacyFSController.test.ts +++ b/src/backend/controllers/fs/LegacyFSController.test.ts @@ -33,7 +33,7 @@ import { signFile } from '../../util/fileSigning.js'; import { generateDefaultFsentries } from '../../util/userProvisioning.js'; import { SHARE_LIST_LIMIT } from '../share/limits.js'; import type { LegacyFSController } from './LegacyFSController.js'; -import { FS_MUTATE_LIMIT } from './limits.js'; +import { FS_MUTATE_LIMIT, FS_SIGN_MAX_ITEMS } from './limits.js'; // ── Test harness ──────────────────────────────────────────────────── // @@ -1920,6 +1920,53 @@ describe('LegacyFSController.sign', () => { ).rejects.toMatchObject({ statusCode: 400 }); }); + // The missing app would 404 if any item or the grant were looked at first. + it('rejects items over the cap before resolving anything', async () => { + const { actor } = await makeUser(); + const { res } = makeRes(); + await expect( + withActor(actor, () => + controller.sign( + makeReq({ + body: { + items: Array(FS_SIGN_MAX_ITEMS + 1).fill({ + path: '/x', + action: 'read', + }), + app_uid: `does-not-exist-${uuidv4()}`, + }, + actor, + }), + res, + ), + ), + ).rejects.toMatchObject({ + statusCode: 400, + legacyCode: 'bad_request', + message: `Too many items in one request (max ${FS_SIGN_MAX_ITEMS})`, + }); + }); + + it('accepts exactly the cap', async () => { + const { actor } = await makeUser(); + const { res, captured } = makeRes(); + await withActor(actor, () => + controller.sign( + makeReq({ + body: { + items: Array(FS_SIGN_MAX_ITEMS).fill({ + action: 'read', + }), + }, + actor, + }), + res, + ), + ); + const body = captured.body as { signatures: Array }; + expect(body.signatures).toHaveLength(FS_SIGN_MAX_ITEMS); + }); + it('signs a valid entry by path and returns a signature', async () => { const { actor } = await makeUser(); const username = actor.user!.username!; diff --git a/src/backend/controllers/fs/LegacyFSController.ts b/src/backend/controllers/fs/LegacyFSController.ts index 2918e8ed0..d1a533cb4 100644 --- a/src/backend/controllers/fs/LegacyFSController.ts +++ b/src/backend/controllers/fs/LegacyFSController.ts @@ -72,6 +72,7 @@ import { FS_SEARCH_CONCURRENT, FS_SEARCH_LIMIT, FS_SIGN_LIMIT, + FS_SIGN_MAX_ITEMS, FS_SIGNED_CONCURRENT, FS_SIGNED_READ_LIMIT, FS_SIGNED_WRITE_LIMIT, @@ -1351,6 +1352,12 @@ export class LegacyFSController extends PuterController { throw new HttpError(400, '`items` is required', { legacyCode: 'bad_request', }); + if (items.length > FS_SIGN_MAX_ITEMS) + throw new HttpError( + 400, + `Too many items in one request (max ${FS_SIGN_MAX_ITEMS})`, + { legacyCode: 'bad_request' }, + ); const actingApp = actor.effectiveApp; const signingCfg = signingConfigFromAppConfig(this.config); diff --git a/src/backend/controllers/fs/limits.test.ts b/src/backend/controllers/fs/limits.test.ts index 53b71171b..8fb2b769c 100644 --- a/src/backend/controllers/fs/limits.test.ts +++ b/src/backend/controllers/fs/limits.test.ts @@ -28,7 +28,10 @@ import type { RouteOptions, RouteRateLimit } from '../../core/http/types'; type Spec = RouteRateLimit | NonNullable; -const all = Object.entries(limits) as Array<[string, Spec | RouteRateLimit[]]>; +// Rate and concurrency specs only; the per-request item caps are plain numbers. +const all = Object.entries(limits).filter( + ([, value]) => typeof value === 'object', +) as Array<[string, Spec | RouteRateLimit[]]>; const flat: Array<[string, Spec]> = all.flatMap(([name, value]) => Array.isArray(value) ? value.map((v, i): [string, Spec] => [`${name}[${i}]`, v]) diff --git a/src/backend/controllers/fs/limits.ts b/src/backend/controllers/fs/limits.ts index 56f2f907d..4b4b17cc1 100644 --- a/src/backend/controllers/fs/limits.ts +++ b/src/backend/controllers/fs/limits.ts @@ -185,6 +185,27 @@ export const FS_HELPER_LIMIT = userWindow('fs:helper', 120, 60, 30); */ export const FS_POLL_LIMIT = userWindow('fs:poll', 240, 240, 120); +// -- Per-request item caps ------------------------------------------- +// +// The windows above count requests; these bound what one request can fan out +// to. Each item costs its own path resolution and ACL check, and an upload item +// also a pending session and presigned URLs. Checked before any per-item work. + +/** + * `startBatchWrite` / `completeBatchWrite` / `batchWrite`. Puter.js chunks + * uploads at this size. + */ +export const FS_BATCH_WRITE_MAX_ITEMS = 500; + +/** Entries per `/sign`. Each may also write an app grant. */ +export const FS_SIGN_MAX_ITEMS = 500; + +/** + * Part numbers per `signMultipartParts`: every part a multipart upload can have + * (1-10,000). + */ +export const FS_MULTIPART_MAX_PARTS = 10_000; + // -- Legacy multipart upload ----------------------------------------- /** diff --git a/src/backend/drivers/kv/KVStoreDriver.test.ts b/src/backend/drivers/kv/KVStoreDriver.test.ts index c5e8d1adb..583ae252d 100644 --- a/src/backend/drivers/kv/KVStoreDriver.test.ts +++ b/src/backend/drivers/kv/KVStoreDriver.test.ts @@ -1156,7 +1156,7 @@ describe('KVStoreDriver', () => { ).toBe('from-calendar'); }); - it('keeps delete orthogonal to write', async () => { + it('lets delete imply write, but not write imply delete', async () => { const write = await setup(); await grant( write.owner, @@ -1175,11 +1175,16 @@ describe('KVStoreDriver', () => { del.calendar.uid, appDataPermission(del.contacts.uid, 'kv', 'delete'), ); - await expect( - crossApp(del.calendarActor, del.contacts.uid, (optConfig) => - target.set({ key: 'entry', value: 'nope', optConfig }), + await crossApp(del.calendarActor, del.contacts.uid, (optConfig) => + target.set({ key: 'entry', value: 'changed', optConfig }), + ); + expect( + await crossApp( + del.calendarActor, + del.contacts.uid, + (optConfig) => target.get({ key: 'entry', optConfig }), ), - ).rejects.toMatchObject({ statusCode: 403 }); + ).toBe('changed'); }); it('permits every delete op with the delete class', async () => { diff --git a/src/backend/services/apps/AppPermissionService.test.ts b/src/backend/services/apps/AppPermissionService.test.ts index a9a19f6a1..a47497e8a 100644 --- a/src/backend/services/apps/AppPermissionService.test.ts +++ b/src/backend/services/apps/AppPermissionService.test.ts @@ -938,7 +938,7 @@ describe('AppPermissionService — app-data cross-app permissions', () => { ); }); - it('keeps delete orthogonal to write', async () => { + it('ranks the classes delete > write > read', async () => { const owner = await makeUser(); const DELETE_OPS = ['del', 'remove', 'expire', 'expireAt'] as const; @@ -959,15 +959,15 @@ describe('AppPermissionService — app-data cross-app permissions', () => { ).toBe(false); } - // ...and a delete grant covers every deletion without conferring - // write, so cancelling an entry doesn't imply rewriting the rest. + // ...and a delete grant covers every deletion plus what it implies: + // nothing can be removed that couldn't be read or changed. const d = await makeGranteeAndTarget(owner); await grant( owner, d.grantee.uid, appDataPermission(d.target.uid, 'kv', 'delete'), ); - for (const op of DELETE_OPS) { + for (const op of [...DELETE_OPS, 'set', 'update', 'get', 'list']) { expect( await permissions.check( d.granteeActor, @@ -975,12 +975,21 @@ describe('AppPermissionService — app-data cross-app permissions', () => { ), ).toBe(true); } - expect( - await permissions.check( - d.granteeActor, - appDataPermission(d.target.uid, 'kv', 'set'), - ), - ).toBe(false); + + const f = await makeGranteeAndTarget(owner); + await grant( + owner, + f.grantee.uid, + appDataPermission(f.target.uid, 'fs', 'delete'), + ); + for (const cls of ['read', 'write', 'delete']) { + expect( + await permissions.check( + f.granteeActor, + appDataPermission(f.target.uid, 'fs', cls), + ), + ).toBe(true); + } }); }); diff --git a/src/backend/services/apps/AppPermissionService.ts b/src/backend/services/apps/AppPermissionService.ts index 2367a3073..ea1415bbd 100644 --- a/src/backend/services/apps/AppPermissionService.ts +++ b/src/backend/services/apps/AppPermissionService.ts @@ -285,9 +285,11 @@ export class AppPermissionService extends PuterService { push(cls); } } - // fs:read is satisfied by fs:write. `delete` is orthogonal — - // it implies neither, and neither implies it. + // Same ladder as kv: fs:delete > fs:write > fs:read. if (store === 'fs' && op === 'read') push('write'); + if (store === 'fs' && (op === 'read' || op === 'write')) { + push('delete'); + } return out; }, }); diff --git a/src/backend/services/apps/RecommendedAppsService.ts b/src/backend/services/apps/RecommendedAppsService.ts index 603b6c681..9af664e83 100644 --- a/src/backend/services/apps/RecommendedAppsService.ts +++ b/src/backend/services/apps/RecommendedAppsService.ts @@ -27,34 +27,33 @@ import { PuterService } from '../types.js'; * at call time against the apps table. */ const RECOMMENDED_APP_NAMES = [ - 'app-center', 'builder', - 'editor', - 'camera', - 'recorder', - 'calculator', 'contacts', 'calendar', 'meetings', - 'blockarena', - 'music-player', - 'word-processor', + 'teamchat', + 'email', + 'whiteboard', 'spreadsheet', + 'word-processor', 'presentation', 'pdf-editor', - 'diagram', - 'memos', - 'audio-editor', - 'browser', - 'ai-image-project', + 'cap-table', + 'invoices', + 'crm', + 'signatures', + 'projects', + 'point-of-sale', + 'inventory', + 'hiring', + 'time-tracking', + 'shift-planner', + 'equipment', 'chess', 'checkers', 'backgammon', - 'klondike', 'sudoku', - 'blockup', - 'basketball-tap', - 'dev-center', + 'klondike', ]; export class RecommendedAppsService extends PuterService { diff --git a/src/backend/services/fs/FSService.test.ts b/src/backend/services/fs/FSService.test.ts index 2c161bb1c..f81f05c53 100644 --- a/src/backend/services/fs/FSService.test.ts +++ b/src/backend/services/fs/FSService.test.ts @@ -19,7 +19,7 @@ */ import { Readable } from 'node:stream'; -import { CreateBucketCommand } from '@aws-sdk/client-s3'; +import { CreateBucketCommand, PutObjectCommand } from '@aws-sdk/client-s3'; import { v4 as uuidv4 } from 'uuid'; import { afterAll, @@ -503,6 +503,75 @@ describe('FSService write payload handling', () => { // Generous timeout: the object-store client retries the torn-off body // before giving up, which puts the rejection just past the default. }, 20_000); + + /** + * Answer every PutObject the way the real store does when the body ends + * before the length it was sent with; the in-memory store doesn't check. + */ + const rejectShortPuts = () => { + const client = server.clients.s3.get( + server.stores.s3Object.resolveRegion(null), + ); + const original = client.send.bind(client); + return vi.spyOn(client, 'send').mockImplementation((async ( + command: unknown, + ) => { + if (!(command instanceof PutObjectCommand)) { + return original(command as never); + } + const body = command.input.Body; + if (body instanceof Readable) { + for await (const _chunk of body) { + // drain + } + } + throw Object.assign( + new Error( + 'You did not provide the number of bytes specified by the Content-Length HTTP header', + ), + { name: 'IncompleteBody', Code: 'IncompleteBody' }, + ); + }) as never); + }; + + it('rejects a stream shorter than its declared size as a bad request', async () => { + const send = rejectShortPuts(); + try { + const error = await caught(() => + fs.write(user.userId, { + fileMetadata: { + path: `${user.home}/Documents/short.bin`, + size: 57, + contentType: 'text/plain', + }, + fileContent: Readable.from(['x'.repeat(54)]), + }), + ); + expect(error.statusCode).toBe(400); + expect(error.message).toBe( + 'File content is shorter than its declared size', + ); + } finally { + send.mockRestore(); + } + expect(await entryAt(user, '/Documents/short.bin')).toBeNull(); + }); + + it('leaves a short-body rejection of a measured payload as a server error', async () => { + // A buffer's length is measured here, not declared by the caller, so + // the store refusing it is not the caller's fault. + const send = rejectShortPuts(); + try { + const error = await write('measured.bin', Buffer.from('abc')).then( + () => null, + (e: unknown) => e, + ); + expect(error).not.toBeInstanceOf(HttpError); + expect((error as { name?: string })?.name).toBe('IncompleteBody'); + } finally { + send.mockRestore(); + } + }); }); describe('FSService overwrite and dedupe resolution', () => { diff --git a/src/backend/services/fs/FSService.ts b/src/backend/services/fs/FSService.ts index 86af1b918..08c8b217d 100644 --- a/src/backend/services/fs/FSService.ts +++ b/src/backend/services/fs/FSService.ts @@ -53,11 +53,13 @@ import { } from '../../stores/fs/FSEntry.js'; import { clampSignedUploadExpirySeconds, + isIncompleteBodyError, isMissingObjectError, } from '../../stores/fs/S3ObjectStore.js'; import { toUploadReservationBytes } from '../../stores/fs/UploadReservationStore.js'; import type { MultipartCompletePart, + ServerUploadInput, SignedUploadResult, } from '../../stores/fs/s3Types.js'; import type { puterStores } from '../../stores/index.js'; @@ -1533,6 +1535,30 @@ export class FSService extends PuterService { }); } + async #uploadContent( + input: ServerUploadInput, + region: string, + uploadBody: UploadPayload, + ): Promise { + try { + await this.stores.s3Object.uploadFromServer(input, region); + } catch (error) { + // A stream is sent with the caller's declared size as its length, + // so a body that ends short of it is a bad request. + if ( + uploadBody.contentLength === undefined && + isIncompleteBodyError(error) + ) { + throw new HttpError( + 400, + 'File content is shorter than its declared size', + { legacyCode: 'bad_request' }, + ); + } + throw error; + } + } + async #cleanupPreparedBatchUploads( preparedBatch: PreparedBatchWrite, uploadedItems: UploadedBatchWriteItem[], @@ -1896,7 +1922,7 @@ export class FSService extends PuterService { input.uploadTracker, ); - await this.stores.s3Object.uploadFromServer( + await this.#uploadContent( { bucket: preparedItem.normalizedInput.bucket, objectKey: preparedItem.objectKey, @@ -1910,6 +1936,7 @@ export class FSService extends PuterService { : {}), }, preparedItem.normalizedInput.bucketRegion, + uploadBody, ); const uploadedSize = uploadBody.uploadedSize(); @@ -3360,7 +3387,7 @@ export class FSService extends PuterService { uploadTracker, ); const objectKey = existingEntry?.uuid ?? uuidv4(); - await this.stores.s3Object.uploadFromServer( + await this.#uploadContent( { bucket: normalizedInput.bucket, objectKey, @@ -3374,6 +3401,7 @@ export class FSService extends PuterService { : {}), }, normalizedInput.bucketRegion, + uploadBody, ); const uploadedSize = uploadBody.uploadedSize(); diff --git a/src/backend/services/metering/MeteringService.test.ts b/src/backend/services/metering/MeteringService.test.ts index 6f27700c1..fd0ad59fa 100644 --- a/src/backend/services/metering/MeteringService.test.ts +++ b/src/backend/services/metering/MeteringService.test.ts @@ -1694,6 +1694,206 @@ describe('MeteringService', () => { }); }); + describe('setMonthAllowance / getMonthAllowance', () => { + const paidPolicy = (id: string, dollars: number) => { + const policy = { + id, + monthUsageAllowance: toMicroCents(dollars), + monthlyStorageAllowance: 1024 * 1024 * 1024, + }; + target.registerPolicy(policy); + return policy; + }; + + it('replaces the allowance for the rest of the month on that policy', async () => { + const paid = paidPolicy('month-paid', 10); + target.registerSubscriptionResolver(async () => 'month-paid'); + await target.incrementUsage(actor, 'kv:read', 1, 1_000); + // Cached before the override lands, so this also covers the drop. + expect((await target.getAllowedUsage(actor)).remaining).toBe( + paid.monthUsageAllowance - 1_000, + ); + + await target.setMonthAllowance( + actor.user.uuid!, + 'month-paid', + toMicroCents(4), + ); + + const allowed = await target.getAllowedUsage(actor); + expect(allowed.monthUsageAllowance).toBe(toMicroCents(4)); + expect(allowed.remaining).toBe(toMicroCents(4) - 1_000); + expect( + await target.getMonthAllowance(actor.user.uuid!, 'month-paid'), + ).toBe(toMicroCents(4)); + }); + + it('settles spend past the override against purchased credits', async () => { + paidPolicy('month-paid', 10); + target.registerSubscriptionResolver(async () => 'month-paid'); + await target.updateAddonCredit(actor.user.uuid!, 5_000_000); + await target.setMonthAllowance( + actor.user.uuid!, + 'month-paid', + 3_000_000, + ); + + await target.incrementUsage(actor, 'kv:read', 1, 4_000_000); + + await waitFor(async () => { + const addons = await target.getActorAddons(actor); + expect(addons.consumedPurchaseCredits).toBe(1_000_000); + }); + expect((await target.getAllowedUsage(actor)).remaining).toBe( + 4_000_000, + ); + }); + + it('never applies on another policy', async () => { + const basic = paidPolicy('month-basic', 5); + paidPolicy('month-pro', 50); + let tier = 'month-pro'; + target.registerSubscriptionResolver(async () => tier); + await target.setMonthAllowance( + actor.user.uuid!, + 'month-pro', + toMicroCents(20), + ); + + // Leaving the plan drops the override with it. + tier = 'month-basic'; + target.invalidateActorSubscription(actor.user.uuid!); + expect( + (await target.getActorSubscription(actor)).monthUsageAllowance, + ).toBe(basic.monthUsageAllowance); + expect( + await target.getMonthAllowance(actor.user.uuid!, 'month-basic'), + ).toBe(basic.monthUsageAllowance); + }); + + it('lapses when the month ends', async () => { + vi.useFakeTimers({ toFake: ['Date'] }); + try { + const paid = paidPolicy('month-paid', 10); + target.registerSubscriptionResolver(async () => 'month-paid'); + vi.setSystemTime(SEPTEMBER_MONTH_ISO); + await target.setMonthAllowance( + actor.user.uuid!, + 'month-paid', + toMicroCents(4), + ); + + vi.setSystemTime(OCTOBER_MONTH_ISO); + target.invalidateActorSubscription(actor.user.uuid!); + expect( + (await target.getActorSubscription(actor)) + .monthUsageAllowance, + ).toBe(paid.monthUsageAllowance); + } finally { + vi.useRealTimers(); + } + }); + + it('holds an override for a later month until it is due to end', async () => { + vi.useFakeTimers({ toFake: ['Date'] }); + try { + const paid = paidPolicy('month-paid', 10); + target.registerSubscriptionResolver(async () => 'month-paid'); + vi.setSystemTime(SEPTEMBER_MONTH_ISO); + await target.setMonthAllowance( + actor.user.uuid!, + 'month-paid', + toMicroCents(4), + { month: '2026-10', until: Date.UTC(2026, 9, 5) / 1000 }, + ); + const allowanceNow = async () => { + target.invalidateActorSubscription(actor.user.uuid!); + return (await target.getActorSubscription(actor)) + .monthUsageAllowance; + }; + + expect(await allowanceNow()).toBe(paid.monthUsageAllowance); + vi.setSystemTime('2026-10-02T12:00:00Z'); + expect(await allowanceNow()).toBe(toMicroCents(4)); + expect( + await target.getMonthAllowance( + actor.user.uuid!, + 'month-paid', + '2026-10', + ), + ).toBe(toMicroCents(4)); + vi.setSystemTime('2026-10-06T12:00:00Z'); + expect(await allowanceNow()).toBe(paid.monthUsageAllowance); + } finally { + vi.useRealTimers(); + } + }); + + it('leaves free policies alone without reading for an override', async () => { + await target.setMonthAllowance( + actor.user.uuid!, + DEFAULT_FREE_SUBSCRIPTION, + 1, + ); + const getSpy = vi.spyOn(server.stores.kv, 'get'); + + const policy = await target.getActorSubscription(actor); + + expect(policy.id).toBe(DEFAULT_FREE_SUBSCRIPTION); + expect(policy.monthUsageAllowance).toBeGreaterThan(1); + const allowanceReads = getSpy.mock.calls.filter(([{ key }]) => + String(key).includes(':allowance:'), + ); + expect(allowanceReads).toHaveLength(0); + getSpy.mockRestore(); + }); + + it('falls back to the policy allowance when the override cannot be read', async () => { + const paid = paidPolicy('month-paid', 10); + target.registerSubscriptionResolver(async () => 'month-paid'); + const getSpy = vi + .spyOn(server.stores.kv, 'get') + .mockRejectedValueOnce(new Error('kv down')); + + const policy = await target.getActorSubscription(actor); + + expect(policy.monthUsageAllowance).toBe(paid.monthUsageAllowance); + getSpy.mockRestore(); + }); + + it('refuses an allowance that would read as unmetered', async () => { + await expect( + target.setMonthAllowance(actor.user.uuid!, 'month-paid', 0), + ).rejects.toThrow(); + await expect( + target.setMonthAllowance(actor.user.uuid!, 'month-paid', 1, { + month: 'October', + }), + ).rejects.toThrow(); + // 0 would read as "never expires" to the store. + await expect( + target.setMonthAllowance(actor.user.uuid!, 'month-paid', 1, { + until: 0, + }), + ).rejects.toThrow(); + for (const bad of [Number.NaN, Number.POSITIVE_INFINITY, 0.4]) { + await expect( + target.setMonthAllowance( + actor.user.uuid!, + 'month-paid', + bad, + ), + ).rejects.toThrow(); + } + }); + + it('has no month allowance for a policy nobody registered', async () => { + expect( + await target.getMonthAllowance(actor.user.uuid!, 'ghost-plan'), + ).toBeNull(); + }); + }); + // ── hasAnyUsageCached ──────────────────────────────────────────── describe('hasAnyUsageCached', () => { diff --git a/src/backend/services/metering/MeteringService.ts b/src/backend/services/metering/MeteringService.ts index 5221751e0..76db11a77 100644 --- a/src/backend/services/metering/MeteringService.ts +++ b/src/backend/services/metering/MeteringService.ts @@ -27,6 +27,7 @@ import { DEFAULT_FREE_SUBSCRIPTION, DEFAULT_TEMP_SUBSCRIPTION, DETAIL_PATH_COUNTER, + FREE_SUBSCRIPTION_IDS, GLOBAL_APP_KEY, METRICS_PREFIX, METRICS_V2_PREFIX, @@ -2111,7 +2112,7 @@ 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 policy; + if (policy) return this.#withMonthAllowance(actor.user.uuid!, policy); console.warn( `[metering] no registered policy for '${resolvedUser}' or` + ` '${resolvedDefault}' — falling back to` + @@ -2120,6 +2121,67 @@ export class MeteringService extends PuterService { return REGISTERED_USER_FREE as SubscriptionPolicy; } + /** + * `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. + */ + async #withMonthAllowance( + userUuid: string, + policy: SubscriptionPolicy, + month: string = this.monthYearString(), + ): Promise { + if ( + FREE_SUBSCRIPTION_IDS.has(policy.id) || + policy.id === UNLIMITED_SUBSCRIPTION || + !(policy.monthUsageAllowance > 0) + ) { + return policy; + } + try { + const allowance = await this.#readMonthAllowance( + userUuid, + policy.id, + month, + ); + return allowance === null + ? policy + : ({ + ...policy, + monthUsageAllowance: allowance, + } as SubscriptionPolicy); + } 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; + } + } + + #monthAllowanceKey( + userUuid: string, + policyId: string, + month: string, + ): string { + return `${POLICY_PREFIX}:actor:${userUuid}:allowance:${policyId}:${month}`; + } + + async #readMonthAllowance( + userUuid: string, + policyId: string, + month: string, + ): Promise { + const { res } = await this.stores.kv.get({ + key: this.#monthAllowanceKey(userUuid, policyId, month), + }); + // Non-positive reads as unmetered everywhere downstream, so it is + // never applied. + return typeof res === 'number' && Number.isFinite(res) && res > 0 + ? res + : null; + } + async getActorAddons(actor: Actor): Promise { if (!actor.user?.uuid) throw new HttpError( @@ -2208,6 +2270,61 @@ export class MeteringService extends PuterService { this.invalidateActorCredits(userId); } + /** + * Replace one actor's allowance for a month (`YYYY-MM` UTC, the current one + * by default) while they resolve to `policyId`, until `until` (epoch + * seconds) if given. It never applies on any other policy, so leaving the + * plan drops it. For a change that should count for only part of a month, + * such as a plan change billed for part of a period. + */ + async setMonthAllowance( + userUuid: string, + policyId: string, + allowance: number, + opts: { month?: string; until?: number } = {}, + ): Promise { + if (!userUuid || !policyId) { + throw new Error('User and policy needed to set a month allowance'); + } + const value = Math.round(allowance); + if (!Number.isFinite(value) || value <= 0) { + throw new Error('Month allowance must be a positive number'); + } + const month = opts.month ?? this.monthYearString(); + const parsed = /^(\d{4})-(\d{2})$/.exec(month); + if (!parsed) throw new Error('Month must be YYYY-MM'); + // Nothing reads a month's key once the month is over. + const monthOver = + Date.UTC(Number(parsed[1]), Number(parsed[2]), 1) / 1000; + const until = opts.until; + if (until !== undefined && !(Number.isFinite(until) && until > 0)) { + throw new Error('`until` must be a timestamp in seconds'); + } + await this.stores.kv.set({ + key: this.#monthAllowanceKey(userUuid, policyId, month), + value, + expireAt: Math.min(until ?? monthOver, monthOver), + }); + // Every node drops its cached policy, and the budget answer with it. + this.invalidateActorSubscription(userUuid); + } + + /** + * A month's allowance for `policyId` (`YYYY-MM` UTC, the current one by + * default): the override `setMonthAllowance` left, else the policy's own. + * Null for a policy nobody registered. + */ + async getMonthAllowance( + userUuid: string, + policyId: string, + month?: string, + ): Promise { + const policy = this.getRegisteredPolicy(policyId); + if (!policy) return null; + return (await this.#withMonthAllowance(userUuid, policy, month)) + .monthUsageAllowance; + } + // -- Internals ---------------------------------------------------- private monthYearString(): string { diff --git a/src/backend/services/permission/appDataScopes.ts b/src/backend/services/permission/appDataScopes.ts index 066bac4aa..a54da65d4 100644 --- a/src/backend/services/permission/appDataScopes.ts +++ b/src/backend/services/permission/appDataScopes.ts @@ -27,12 +27,11 @@ export type AppDataStore = 'kv' | 'fs'; * Access classes a grant may be written at. Coarser than a concrete op, so a * single `…:kv:read` row covers `get` and `list`. * - * `delete` is orthogonal: `write` does not imply it and it does not imply - * `write`. That is what lets a grant say "may add invites but not remove them", - * and conversely "may cancel an invite" without handing over the ability to - * rewrite everything. Coarser grants (`app-data:X:kv`, `app-data:X`) still - * cover all three by prefix implication, which is why the consent dialog has to - * name deletion whenever it prompts for one. + * Each class implies the ones below it: `delete` > `write` > `read`. An app + * can't remove an entry it may not see or change, so `delete` carries both; + * `write` still never reaches a deletion. Coarser grants (`app-data:X:kv`, + * `app-data:X`) cover all three by prefix implication, which is why the consent + * dialog has to name deletion whenever it prompts for one. */ export const APP_DATA_CLASSES = ['read', 'write', 'delete'] as const; export type AppDataClass = (typeof APP_DATA_CLASSES)[number]; @@ -57,7 +56,7 @@ export type AppDataKvOp = (typeof APP_DATA_KV_OPS)[number]; * Parameters that turn a write into a deletion: both set an expiry, and an * expiry in the past makes the key vanish (the store filters it out on read and * DynamoDB reaps it later). A cross-app call carrying either one therefore - * needs the `delete` class on top of `write` — otherwise `kv:set` alone would + * needs the `delete` class, not just `write` — otherwise `kv:set` alone would * be a delete capability under another name. */ export const APP_DATA_KV_TTL_PARAMS = ['expireAt', 'ttl'] as const; @@ -67,13 +66,13 @@ export const APP_DATA_KV_OP_CLASSES: Record< AppDataKvOp, readonly AppDataClass[] > = { - get: ['read', 'write'], - list: ['read', 'write'], - set: ['write'], - add: ['write'], - incr: ['write'], - decr: ['write'], - update: ['write'], + get: ['read', 'write', 'delete'], + list: ['read', 'write', 'delete'], + set: ['write', 'delete'], + add: ['write', 'delete'], + incr: ['write', 'delete'], + decr: ['write', 'delete'], + update: ['write', 'delete'], del: ['delete'], remove: ['delete'], expire: ['delete'], diff --git a/src/backend/stores/fs/S3ObjectStore.test.ts b/src/backend/stores/fs/S3ObjectStore.test.ts index 37ce085fa..38112b7d5 100644 --- a/src/backend/stores/fs/S3ObjectStore.test.ts +++ b/src/backend/stores/fs/S3ObjectStore.test.ts @@ -29,6 +29,7 @@ import { setupTestServer } from '../../testUtil.js'; import type { IConfig } from '../../types.js'; import { clampSignedUploadExpirySeconds, + isIncompleteBodyError, isMissingObjectError, S3ObjectStore, } from './S3ObjectStore.js'; @@ -184,6 +185,21 @@ describe('isMissingObjectError', () => { }); }); +describe('isIncompleteBodyError', () => { + it('reads a short upload body off name or Code', () => { + expect(isIncompleteBodyError({ name: 'IncompleteBody' })).toBe(true); + expect(isIncompleteBodyError({ Code: 'IncompleteBody' })).toBe(true); + }); + + it('never reads other upload failures as a short body', () => { + expect(isIncompleteBodyError({ name: 'SlowDown' })).toBe(false); + expect(isIncompleteBodyError({ name: 'BadDigest' })).toBe(false); + expect(isIncompleteBodyError(new Error('socket hang up'))).toBe(false); + expect(isIncompleteBodyError(null)).toBe(false); + expect(isIncompleteBodyError('IncompleteBody')).toBe(false); + }); +}); + describe('S3ObjectStore region and bucket fallbacks', () => { it('falls back to configured region then to the built-in default', () => { expect(store().resolveRegion('eu-central-1')).toBe('eu-central-1'); diff --git a/src/backend/stores/fs/S3ObjectStore.ts b/src/backend/stores/fs/S3ObjectStore.ts index beb35a006..e44cbae33 100644 --- a/src/backend/stores/fs/S3ObjectStore.ts +++ b/src/backend/stores/fs/S3ObjectStore.ts @@ -73,6 +73,16 @@ export function isMissingObjectError(err: unknown): boolean { ); } +/** + * True when the store rejected an upload because the body ended before the + * length it was sent with. + */ +export function isIncompleteBodyError(err: unknown): boolean { + if (!err || typeof err !== 'object') return false; + const e = err as { name?: unknown; Code?: unknown }; + return e.name === 'IncompleteBody' || e.Code === 'IncompleteBody'; +} + /** * Store that owns S3 object I/O for fsentries: signed-URL minting, multipart * lifecycle, server-driven uploads, and object reads/copies/deletes. Wraps the diff --git a/src/docs/src/Perms/appData.md b/src/docs/src/Perms/appData.md index 99188fbd5..acfac9837 100644 --- a/src/docs/src/Perms/appData.md +++ b/src/docs/src/Perms/appData.md @@ -41,10 +41,10 @@ What access to ask for. Three equivalent forms: | Class | Covers | | --- | --- | | `read` | `get`, `list` | -| `write` | `set`, `add`, `incr`, `decr`, `update` | -| `delete` | `del`, `remove`, `expire`, `expireAt` | +| `write` | everything in `read`, plus `set`, `add`, `incr`, `decr`, `update` | +| `delete` | everything in `write`, plus `del`, `remove`, `expire`, `expireAt` | -**`delete` is separate from `write`.** An app granted `write` can add and change entries but cannot remove any — ask for `delete` explicitly when it needs to. Emptying another app's whole key-value store is never available at any scope. +**Each class includes the ones above it.** An app granted `write` can read, add and change entries but cannot remove any — ask for `delete` when it needs to. Emptying another app's whole key-value store is never available at any scope. ## Return value diff --git a/src/docs/src/rate-limits-and-quotas.md b/src/docs/src/rate-limits-and-quotas.md index 4001f66c5..249e68070 100644 --- a/src/docs/src/rate-limits-and-quotas.md +++ b/src/docs/src/rate-limits-and-quotas.md @@ -140,7 +140,9 @@ Per minute unless stated: | Limit | Value | Scope | | ----- | ----- | ----- | | Uploads in progress | 10,000 | Per user, all apps | -| Files in one `startBatchWrite` | 10,000 | Per request | +| Files and folders in one `startBatchWrite`, `completeBatchWrite` or `batchWrite` | 500 | Per request | +| Entries in one `/sign` request | 500 | Per request | +| Part numbers in one `signMultipartParts` | 10,000 | Per request | | Legacy `/batch` request | 256 multipart parts (fields and files combined) or 256 JSON operations, 64 files, 100 MiB per file, 1 MiB per field, 256 MiB in total | Per request | | Signed-URL reads | 3,000/min | Per network | | Signed-URL writes | 600/min | Per network | @@ -150,6 +152,8 @@ Per minute unless stated: Non-write operations (mkdir/shortcut/move/delete) inside a legacy `/batch` request also count against the Mutations limits above. +A request over one of the per-request caps fails as a whole before any item is processed, with `400 bad_request` (`413` for legacy `/batch`). `puter.fs.upload()` already splits larger uploads into requests of 500. + An upload is in progress from `startWrite`/`startBatchWrite` (which `puter.fs.upload()` and `puter.fs.write()` use) until it completes, is cancelled, or expires. The count includes uploads by collaborators into the account's folders. Over the limit, new uploads fail with `429 too_many_requests` until some finish. The Puter desktop makes PDF upload thumbnails locally within these budgets. Going over one skips the preview; the upload itself is unaffected. diff --git a/src/docs/src/recipes/add-contact-form.md b/src/docs/src/recipes/add-contact-form.md index 7e0f98129..2b98b153d 100644 --- a/src/docs/src/recipes/add-contact-form.md +++ b/src/docs/src/recipes/add-contact-form.md @@ -103,3 +103,75 @@ The request is a plain `fetch()`, so visitors do not need a Puter account to use the form. Every message sent through the form now arrives in your email inbox. + +## Limit How Often a Visitor Can Send + +Anyone who knows the worker's URL can call the `/contact` route, and every call +sends an email billed to you. To keep a bot from flooding your inbox, add a +rate limit. Record the visitor's IP address in [`puter.kv`](/KV/) with an +expiry time, and while the key exists, the route answers `429` and sends +nothing. + +Here is the complete worker file with a rate limit of one message every 3 +minutes. Change `COOLDOWN_SECONDS` to fit your app: + +```js +const CONTACT_TO = 'you@example.com'; +const COOLDOWN_SECONDS = 3 * 60; + +function toPrefix48(addr) { + if (!addr.includes(':')) return addr; // IPv4: leave unchanged + + const [head, tail = ''] = addr.split('%')[0].split('::'); // drop zone ID + const left = head ? head.split(':') : []; + const right = tail ? tail.split(':') : []; + const full = [...left, ...Array(8 - left.length - right.length).fill('0'), ...right]; + + const parts = full.slice(0, 3).map(h => parseInt(h || '0', 16).toString(16)); + while (parts.length && parts[parts.length - 1] === '0') parts.pop(); + + return `${parts.join(':')}::/48`; +} + +async function isRateLimited (request) { + const key = `contact:${toPrefix48(request.headers.get('x-real-ip'))}`; + + if ( await me.puter.kv.get(key) ) { + return true; + } + + const expireAt = Math.floor(Date.now() / 1000) + COOLDOWN_SECONDS; + await me.puter.kv.set(key, true, expireAt); + return false; +} + +router.post('/contact', async ({ request }) => { + const { name, email, message } = await request.json(); + if (!name || !email || !message) { + return new Response('name, email and message are required', { status: 400 }); + } + + if ( await isRateLimited(request) ) { + return new Response('Please wait a few minutes before sending another message', { status: 429 }); + } + + await me.puter.email.sendTransactional({ + to: CONTACT_TO, + replyTo: email, + subject: `Contact form: ${name}`, + text: `From: ${name} <${email}>\n\n${message}`, + }); + + return { ok: true }; +}); +``` + +The `x-real-ip` header carries the visitor's IP address, and it cannot be faked to get around +the limit. The `toPrefix48()` helper handles both IPv4 and IPv6. An IPv4 address is used +as is. One IPv6 connection usually comes with a whole block of addresses, so an +IPv6 address is rate limited by its /48 prefix instead of the single address. The [`puter.kv.set()`](/KV/set/) method takes the expiry as a Unix +timestamp in seconds, and once it passes, the key is removed and the visitor +can send again. + +The limit is checked after the fields are validated, so a form with a missing +field does not start the cooldown. diff --git a/src/gui/src/UI/UIPermissionDialog.js b/src/gui/src/UI/UIPermissionDialog.js index 61566627b..40776875d 100644 --- a/src/gui/src/UI/UIPermissionDialog.js +++ b/src/gui/src/UI/UIPermissionDialog.js @@ -710,13 +710,13 @@ async function get_permission_description (permission, options = {}) { * explicitly: a scope that can remove another app's entries must not read as * "change". */ -// `write` satisfies a read check via the exploder, so its wording names -// reading too — "change" alone would understate the grant. +// Classes imply the ones below them (delete > write > read), so their wording +// names those too: "change" or "delete" alone would understate the grant. const APP_DATA_VERBS = { read: 'read', get: 'read', list: 'read', write: 'change', set: 'change', add: 'change', incr: 'change', decr: 'change', update: 'change', - delete: 'delete', del: 'delete', remove: 'delete', + delete: 'store_all', del: 'delete', remove: 'delete', expire: 'delete', expireAt: 'delete', }; diff --git a/src/gui/src/UI/UIPermissionDialog.test.js b/src/gui/src/UI/UIPermissionDialog.test.js index f560897ce..984894197 100644 --- a/src/gui/src/UI/UIPermissionDialog.test.js +++ b/src/gui/src/UI/UIPermissionDialog.test.js @@ -52,8 +52,13 @@ describe('UIPermissionDialog app-data descriptions', () => { // Deletion must be named, not folded into "change". expect((await describeScope(`app-data:${CONTACTS}:kv:del`)).html) .toContain('perm_app_data_delete'); + }); + + it('names reading and changing for the delete class, which implies both', async () => { expect((await describeScope(`app-data:${CONTACTS}:kv:delete`)).html) - .toContain('perm_app_data_delete'); + .toContain('perm_app_data_store_all'); + expect((await describeScope(`app-data:${CONTACTS}:fs:delete`)).html) + .toContain('perm_app_data_store_all'); }); it('distinguishes files from saved data', async () => { diff --git a/src/puter-js/src/modules/perms/appData.js b/src/puter-js/src/modules/perms/appData.js index f69ba4f47..e9888598f 100644 --- a/src/puter-js/src/modules/perms/appData.js +++ b/src/puter-js/src/modules/perms/appData.js @@ -170,7 +170,7 @@ export async function appDataRequest (puter, appIdentifier, scopes) { /** * Ask the user to let this app use another app's KV namespace and AppData * directory. Scopes take a shorthand for both stores, `store:name` pairs, or a - * per-store object. `delete` is separate from `write` and must be asked for. + * per-store object. `delete` includes `write` and `read`, and must be asked for. * * await puter.perms.request('appData', { app: 'contacts', scopes: 'read' }); * diff --git a/src/puter-js/src/modules/perms/types.js b/src/puter-js/src/modules/perms/types.js index 78ea39491..4862a858d 100644 --- a/src/puter-js/src/modules/perms/types.js +++ b/src/puter-js/src/modules/perms/types.js @@ -109,8 +109,8 @@ */ /** - * The three access classes. `delete` is orthogonal to `write`: neither implies - * the other, so an app that only adds data cannot remove any. + * The three access classes. Each implies the ones before it (`delete` > + * `write` > `read`), so an app that only adds data cannot remove any. * * @typedef {'read' | 'write' | 'delete'} AppDataClass */