Merge remote-tracking branch 'origin/main' into juancastro/full-access-token-route-gates

This commit is contained in:
Juan Castro committed 2026-10-02 17:48:07 -04:00
commit 2f16ee42aa
24 files changed
+812 -65

No files matched your search

@@ -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<string, Promise<number | null>>();
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<ClientCompleteWriteResponse[]>,
) {
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<ClientSignMultipartPartsResponse>,
) {
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,
@@ -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<SignMultipartPartsRequest>({
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();
@@ -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<unknown> };
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!;
@@ -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);
+4 -1
View File
@@ -28,7 +28,10 @@ import type { RouteOptions, RouteRateLimit } from '../../core/http/types';
type Spec = RouteRateLimit | NonNullable<RouteOptions['concurrent']>;
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])
+21
View File
@@ -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 -----------------------------------------
/**
+10 -5
View File
@@ -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 () => {
@@ -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);
}
});
});
@@ -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;
},
});
@@ -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 {
+70 -1
View File
@@ -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', () => {
+30 -2
View File
@@ -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<void> {
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();
@@ -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', () => {
@@ -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<SubscriptionPolicy> {
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<number | null> {
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<UsageAddons> {
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<void> {
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<number | null> {
const policy = this.getRegisteredPolicy(policyId);
if (!policy) return null;
return (await this.#withMonthAllowance(userUuid, policy, month))
.monthUsageAllowance;
}
// -- Internals ----------------------------------------------------
private monthYearString(): string {
@@ -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'],
@@ -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');
+10
View File
@@ -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
+3 -3
View File
@@ -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
+5 -1
View File
@@ -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.
+72
View File
@@ -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.
+3 -3
View File
@@ -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',
};
+6 -1
View File
@@ -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 () => {
+1 -1
View File
@@ -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' });
*
+2 -2
View File
@@ -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
*/