diff --git a/src/backend/controllers/index.ts b/src/backend/controllers/index.ts
index 4c8bebff2..b7924a886 100644
--- a/src/backend/controllers/index.ts
+++ b/src/backend/controllers/index.ts
@@ -30,6 +30,7 @@ import { HostingController } from './hosting/HostingController.js';
import { LegacyFSController } from './fs/LegacyFSController.js';
import { NotificationController } from './notification/NotificationController.js';
import { OIDCController } from './oidc/OIDCController.js';
+import { PushController } from './push/PushController.js';
import { PuterAIController } from './puterai/PuterAIController.js';
import { ShareController } from './share/ShareController.js';
import { StaticAssetsController } from './static/StaticAssetsController.js';
@@ -57,6 +58,7 @@ export const puterControllers = {
drivers: DriverController,
broadcast: BroadcastController,
notification: NotificationController,
+ push: PushController,
events: EventsController,
share: ShareController,
webdav: WebDAVController,
diff --git a/src/backend/controllers/push/PushController.http.test.ts b/src/backend/controllers/push/PushController.http.test.ts
new file mode 100644
index 000000000..966baff0c
--- /dev/null
+++ b/src/backend/controllers/push/PushController.http.test.ts
@@ -0,0 +1,463 @@
+/*
+ * Copyright (C) 2024-present Puter Technologies Inc.
+ *
+ * This file is part of Puter.
+ *
+ * Puter is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
+import { makeActor } from '../../core/actor.js';
+import type { IConfig } from '../../types.js';
+import {
+ createTestUser,
+ setupPuterTestEnv,
+ type PuterTestEnv,
+ type TestUserCredentials,
+} from '../../testUtil.js';
+import type { PublicPushDevice } from './PushController.js';
+
+type DeviceBody = { device: PublicPushDevice };
+type ListBody = { devices: PublicPushDevice[] };
+
+const call = (
+ env: PuterTestEnv,
+ method: string,
+ path: string,
+ token?: string,
+ body?: unknown,
+) =>
+ fetch(new URL(path, env.apiOrigin), {
+ method,
+ headers: {
+ 'content-type': 'application/json',
+ 'user-agent': 'push-test-agent',
+ ...(token ? { authorization: `Bearer ${token}` } : {}),
+ },
+ ...(body === undefined || method === 'GET'
+ ? {}
+ : { body: JSON.stringify(body) }),
+ });
+
+let userSeq = 0;
+const freshUser = (env: PuterTestEnv): Promise =>
+ createTestUser(env.server, {
+ username: `pushuser${Date.now().toString(36)}${userSeq++}`,
+ password: 'push-test-password',
+ });
+
+describe('push device routes over HTTP', () => {
+ let env: PuterTestEnv;
+
+ beforeAll(async () => {
+ env = await setupPuterTestEnv({
+ push: { enabled: true },
+ } as IConfig);
+ }, 120_000);
+
+ afterAll(async () => {
+ await env?.shutdown();
+ });
+
+ const register = async (
+ token: string,
+ body: Record,
+ ): Promise => {
+ const res = await call(env, 'POST', '/push/devices', token, body);
+ expect(res.status).toBe(200);
+ return ((await res.json()) as DeviceBody).device;
+ };
+
+ const list = async (token: string): Promise => {
+ const res = await call(env, 'GET', '/push/devices', token);
+ expect(res.status).toBe(200);
+ return ((await res.json()) as ListBody).devices;
+ };
+
+ it('registers a device and returns it without the token', async () => {
+ const user = await freshUser(env);
+ const res = await call(env, 'POST', '/push/devices', user.token, {
+ platform: 'android',
+ token: 'fcm-token-register',
+ label: ' Pixel ',
+ });
+
+ expect(res.status).toBe(200);
+ const text = await res.text();
+ expect(text).not.toContain('fcm-token-register');
+ expect(text).not.toContain('token_hash');
+ const { device } = JSON.parse(text) as DeviceBody;
+ expect(Object.keys(device).sort()).toEqual(
+ [
+ 'created_at',
+ 'current',
+ 'label',
+ 'last_seen_at',
+ 'platform',
+ 'uid',
+ 'user_agent',
+ ].sort(),
+ );
+ expect(device).toMatchObject({
+ platform: 'android',
+ label: 'Pixel',
+ user_agent: 'push-test-agent',
+ current: true,
+ });
+ });
+
+ it('updates the same device when its token is registered again', async () => {
+ const user = await freshUser(env);
+ const first = await register(user.token, {
+ platform: 'ios',
+ token: 'apns-token-again',
+ });
+ const second = await register(user.token, {
+ platform: 'ios',
+ token: 'apns-token-again',
+ label: 'iPhone',
+ });
+
+ expect(second.uid).toBe(first.uid);
+ const devices = await list(user.token);
+ expect(devices).toHaveLength(1);
+ expect(devices[0]?.label).toBe('iPhone');
+ });
+
+ it('moves a token to the user who registers it last', async () => {
+ const first = await freshUser(env);
+ const second = await freshUser(env);
+ await register(first.token, { platform: 'web', token: 'moving-token' });
+ await register(second.token, {
+ platform: 'web',
+ token: 'moving-token',
+ });
+
+ expect(await list(first.token)).toHaveLength(0);
+ expect(await list(second.token)).toHaveLength(1);
+ });
+
+ it.each([
+ ['an unknown platform', { platform: 'windows', token: 't' }],
+ ['a missing platform', { token: 't' }],
+ ['an empty token', { platform: 'web', token: '' }],
+ ['a non-string token', { platform: 'web', token: 42 }],
+ [
+ 'a token over 4096 characters',
+ { platform: 'web', token: 'a'.repeat(4097) },
+ ],
+ ['a token with whitespace', { platform: 'web', token: 'abc def' }],
+ [
+ 'a token with a control character',
+ { platform: 'web', token: 'abc\u0007' },
+ ],
+ [
+ 'a label over 64 characters',
+ { platform: 'web', token: 't', label: 'l'.repeat(65) },
+ ],
+ ['a non-string label', { platform: 'web', token: 't', label: 5 }],
+ ])('rejects %s with 400', async (_name, body) => {
+ const user = await freshUser(env);
+ const res = await call(env, 'POST', '/push/devices', user.token, body);
+ expect(res.status).toBe(400);
+ expect(await list(user.token)).toHaveLength(0);
+ });
+
+ it('accepts a 4096-character token and a 64-character label', async () => {
+ const user = await freshUser(env);
+ const device = await register(user.token, {
+ platform: 'web',
+ token: 'b'.repeat(4096),
+ label: 'l'.repeat(64),
+ });
+ expect(device.label).toHaveLength(64);
+ });
+
+ it('refuses anonymous callers with 401', async () => {
+ for (const [method, path] of [
+ ['POST', '/push/devices'],
+ ['GET', '/push/devices'],
+ ['DELETE', '/push/devices/some-uid'],
+ ] as const) {
+ const res = await call(env, method, path, undefined, {
+ platform: 'web',
+ token: 'anon',
+ });
+ expect(res.status).toBe(401);
+ }
+ });
+
+ it('refuses access tokens and worker sessions with 403', async () => {
+ const user = await freshUser(env);
+ for (const token of [user.apiToken, user.workerToken]) {
+ for (const [method, path] of [
+ ['POST', '/push/devices'],
+ ['GET', '/push/devices'],
+ ['DELETE', '/push/devices/some-uid'],
+ ] as const) {
+ const res = await call(env, method, path, token, {
+ platform: 'web',
+ token: 'delegated',
+ });
+ expect(res.status).toBe(403);
+ }
+ }
+ expect(await list(user.token)).toHaveLength(0);
+ });
+
+ it('refuses app tokens with 403', async () => {
+ const user = await freshUser(env);
+ const row = await env.server.stores.user.getByUsername(user.username);
+ const actor = makeActor({ user: row! });
+ const app = await env.server.stores.app.create(
+ {
+ name: `push-app-${crypto.randomUUID()}`,
+ title: 'Push app',
+ index_url: `https://push-${crypto.randomUUID()}.test/`,
+ },
+ { ownerUserId: row!.id },
+ );
+ const appToken = await env.server.services.auth.getUserAppToken(
+ actor,
+ app.uid,
+ );
+
+ for (const [method, path] of [
+ ['POST', '/push/devices'],
+ ['GET', '/push/devices'],
+ ['DELETE', '/push/devices/some-uid'],
+ ] as const) {
+ const res = await call(env, method, path, appToken, {
+ platform: 'web',
+ token: 'from-app',
+ });
+ expect(res.status).toBe(403);
+ }
+ expect(await list(user.token)).toHaveLength(0);
+ });
+
+ it('keeps 20 devices, dropping the least recently seen for a new one', async () => {
+ const user = await freshUser(env);
+ const uids: string[] = [];
+ for (let i = 0; i < 20; i++) {
+ const d = await register(user.token, {
+ platform: 'android',
+ token: `cap-token-${i}`,
+ });
+ uids.push(d.uid);
+ }
+ // Device 5 is the stalest; device 0 was seen most recently.
+ for (const [i, uid] of uids.entries()) {
+ await env.server.clients.db.write(
+ 'UPDATE `push_device` SET `last_seen_at` = ? WHERE `uid` = ?',
+ [i === 5 ? 1_000 : i === 0 ? 3_000 : 2_000, uid],
+ );
+ }
+
+ await register(user.token, {
+ platform: 'android',
+ token: 'cap-token-0',
+ });
+ expect(await list(user.token)).toHaveLength(20);
+
+ await register(user.token, {
+ platform: 'android',
+ token: 'cap-token-20',
+ });
+ const after = (await list(user.token)).map((d) => d.uid);
+ expect(after).toHaveLength(20);
+ expect(after).not.toContain(uids[5]);
+ expect(after).toContain(uids[0]);
+ });
+
+ it('lists only the caller’s devices and marks the current session', async () => {
+ const user = await freshUser(env);
+ const other = await freshUser(env);
+ const row = await env.server.stores.user.getByUsername(user.username);
+ const { token: secondSession } =
+ await env.server.services.auth.createSessionToken(row!, {
+ user_agent: 'second-session',
+ });
+
+ const here = await register(user.token, {
+ platform: 'web',
+ token: 'list-a',
+ });
+ const there = await register(secondSession, {
+ platform: 'ios',
+ token: 'list-b',
+ });
+ await register(other.token, { platform: 'web', token: 'list-c' });
+
+ const devices = await list(user.token);
+ expect(devices.map((d) => d.uid).sort()).toEqual(
+ [here.uid, there.uid].sort(),
+ );
+ expect(devices.find((d) => d.uid === here.uid)?.current).toBe(true);
+ expect(devices.find((d) => d.uid === there.uid)?.current).toBe(false);
+ });
+
+ it('removes the caller’s own device', async () => {
+ const user = await freshUser(env);
+ const device = await register(user.token, {
+ platform: 'web',
+ token: 'remove-own',
+ });
+
+ const res = await call(
+ env,
+ 'DELETE',
+ `/push/devices/${device.uid}`,
+ user.token,
+ );
+ expect(res.status).toBe(200);
+ await expect(res.json()).resolves.toEqual({ removed: true });
+ expect(await list(user.token)).toHaveLength(0);
+
+ const again = await call(
+ env,
+ 'DELETE',
+ `/push/devices/${device.uid}`,
+ user.token,
+ );
+ expect(again.status).toBe(404);
+ });
+
+ it('answers 404 for another user’s device and leaves it in place', async () => {
+ const owner = await freshUser(env);
+ const stranger = await freshUser(env);
+ const device = await register(owner.token, {
+ platform: 'web',
+ token: 'remove-other',
+ });
+
+ const res = await call(
+ env,
+ 'DELETE',
+ `/push/devices/${device.uid}`,
+ stranger.token,
+ );
+ const missing = await call(
+ env,
+ 'DELETE',
+ '/push/devices/no-such-uid',
+ stranger.token,
+ );
+
+ expect(res.status).toBe(404);
+ expect(missing.status).toBe(404);
+ expect(await res.json()).toEqual(await missing.json());
+ expect((await list(owner.token)).map((d) => d.uid)).toEqual([
+ device.uid,
+ ]);
+ });
+
+ it('drops a device when the session that registered it signs out', async () => {
+ const user = await freshUser(env);
+ const row = await env.server.stores.user.getByUsername(user.username);
+ const { token: leaving } =
+ await env.server.services.auth.createSessionToken(row!, {
+ user_agent: 'leaving-session',
+ });
+ const gone = await register(leaving, { platform: 'ios', token: 'bye' });
+ const kept = await register(user.token, {
+ platform: 'web',
+ token: 'hi',
+ });
+
+ await env.server.services.auth.removeSessionByToken(leaving);
+
+ await vi.waitFor(async () => {
+ const uids = (await list(user.token)).map((d) => d.uid);
+ expect(uids).not.toContain(gone.uid);
+ expect(uids).toContain(kept.uid);
+ });
+ });
+
+ it('answers 429 past 30 registrations a minute', async () => {
+ const user = await freshUser(env);
+ for (let i = 0; i < 30; i++) {
+ const res = await call(env, 'POST', '/push/devices', user.token, {
+ platform: 'web',
+ token: 'rate-token',
+ });
+ expect(res.status).toBe(200);
+ }
+ const res = await call(env, 'POST', '/push/devices', user.token, {
+ platform: 'web',
+ token: 'rate-token',
+ });
+ expect(res.status).toBe(429);
+ });
+});
+
+describe('push device routes with push disabled', () => {
+ let env: PuterTestEnv;
+
+ beforeAll(async () => {
+ env = await setupPuterTestEnv();
+ }, 120_000);
+
+ afterAll(async () => {
+ await env?.shutdown();
+ });
+
+ it('refuses registration with 503 push_disabled', async () => {
+ const res = await call(
+ env,
+ 'POST',
+ '/push/devices',
+ env.users.user.token,
+ {
+ platform: 'web',
+ token: 'disabled-token',
+ },
+ );
+ expect(res.status).toBe(503);
+ expect(await res.json()).toMatchObject({ code: 'push_disabled' });
+ });
+
+ it('still lists and removes devices', async () => {
+ const row = await env.server.stores.user.getByUsername(
+ env.users.user.username,
+ );
+ const device = await env.server.services.push.registerDevice({
+ userId: row!.id,
+ sessionUid: 'session-from-before',
+ platform: 'android',
+ token: 'kept-from-before',
+ label: null,
+ userAgent: null,
+ });
+
+ const listed = await call(
+ env,
+ 'GET',
+ '/push/devices',
+ env.users.user.token,
+ );
+ expect(listed.status).toBe(200);
+ expect(
+ ((await listed.json()) as ListBody).devices.map((d) => d.uid),
+ ).toEqual([device.uid]);
+
+ const removed = await call(
+ env,
+ 'DELETE',
+ `/push/devices/${device.uid}`,
+ env.users.user.token,
+ );
+ expect(removed.status).toBe(200);
+ });
+});
diff --git a/src/backend/controllers/push/PushController.ts b/src/backend/controllers/push/PushController.ts
new file mode 100644
index 000000000..cfc43600d
--- /dev/null
+++ b/src/backend/controllers/push/PushController.ts
@@ -0,0 +1,195 @@
+/*
+ * Copyright (C) 2024-present Puter Technologies Inc.
+ *
+ * This file is part of Puter.
+ *
+ * Puter is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import type { Request, Response } from 'express';
+import type { Actor } from '../../core/actor.js';
+import { Controller, Delete, Get, Post } from '../../core/http/decorators.js';
+import { HttpError } from '../../core/http/HttpError.js';
+import { bodyRecord } from '../../core/http/requestBody.js';
+import type {
+ PushDevice,
+ PushPlatform,
+} from '../../stores/push/PushDeviceStore.js';
+import { PuterController } from '../types.js';
+
+const PLATFORMS: ReadonlySet = new Set([
+ 'web',
+ 'ios',
+ 'android',
+]);
+const TOKEN_MAX_LENGTH = 4096;
+const LABEL_MAX_LENGTH = 64;
+const USER_AGENT_MAX_LENGTH = 256;
+const LIST_LIMIT = 50;
+const WHITESPACE_OR_CONTROL = /[\s\p{Cc}]/u;
+
+/** A device as the routes return it; the provider token never leaves. */
+export interface PublicPushDevice {
+ uid: string;
+ platform: PushPlatform;
+ label: string | null;
+ user_agent: string | null;
+ created_at: number;
+ last_seen_at: number;
+ /** Registered from the session making this request. */
+ current: boolean;
+}
+
+const badRequest = (message: string) =>
+ new HttpError(400, message, { legacyCode: 'bad_request' });
+
+const toPublicDevice = (
+ device: PushDevice,
+ sessionUid: string,
+): PublicPushDevice => ({
+ uid: device.uid,
+ platform: device.platform,
+ label: device.label,
+ user_agent: device.userAgent,
+ created_at: device.createdAt,
+ last_seen_at: device.lastSeenAt,
+ current: device.sessionUid === sessionUid,
+});
+
+/** Push devices of the signed-in user, for Puter's own web and mobile apps. */
+@Controller('/push')
+export class PushController extends PuterController {
+ /** POST /push/devices `{ platform, token, label? }` — add or refresh. */
+ @Post('/devices', {
+ subdomain: 'api',
+ requireUserActor: true,
+ bodyJson: { limit: '8kb' },
+ rateLimit: {
+ scope: 'push-device-register',
+ limit: 30,
+ window: 60_000,
+ key: 'user',
+ },
+ })
+ async register(req: Request, res: Response): Promise {
+ const { userId, sessionUid } = this.#signedIn(req.actor);
+ if (this.config.push?.enabled !== true) {
+ throw new HttpError(503, 'Push notifications are not enabled', {
+ legacyCode: 'push_disabled',
+ });
+ }
+
+ const body = bodyRecord(req);
+ const { platform, token, label } = body;
+ if (typeof platform !== 'string' || !PLATFORMS.has(platform)) {
+ throw badRequest('`platform` must be one of web, ios, android');
+ }
+ if (
+ typeof token !== 'string' ||
+ token.length === 0 ||
+ token.length > TOKEN_MAX_LENGTH ||
+ WHITESPACE_OR_CONTROL.test(token)
+ ) {
+ throw badRequest(
+ `\`token\` must be 1 to ${TOKEN_MAX_LENGTH} characters with no whitespace`,
+ );
+ }
+ if (label !== undefined && label !== null && typeof label !== 'string')
+ throw badRequest('`label` must be a string');
+ const trimmedLabel = typeof label === 'string' ? label.trim() : '';
+ if (trimmedLabel.length > LABEL_MAX_LENGTH) {
+ throw badRequest(
+ `\`label\` must be at most ${LABEL_MAX_LENGTH} characters`,
+ );
+ }
+
+ const userAgent = req
+ .get('user-agent')
+ ?.slice(0, USER_AGENT_MAX_LENGTH);
+ const device = await this.services.push.registerDevice({
+ userId,
+ sessionUid,
+ platform: platform as PushPlatform,
+ token,
+ label: trimmedLabel || null,
+ userAgent: userAgent || null,
+ });
+ res.json({ device: toPublicDevice(device, sessionUid) });
+ }
+
+ /** GET /push/devices — the caller's devices, newest first. */
+ @Get('/devices', {
+ subdomain: 'api',
+ requireUserActor: true,
+ rateLimit: {
+ scope: 'push-device-list',
+ limit: 60,
+ window: 60_000,
+ key: 'user',
+ },
+ })
+ async list(req: Request, res: Response): Promise {
+ const { userId, sessionUid } = this.#signedIn(req.actor);
+ const devices = await this.stores.pushDevice.listByUser(userId, {
+ limit: LIST_LIMIT,
+ });
+ res.json({
+ devices: devices.map((d) => toPublicDevice(d, sessionUid)),
+ });
+ }
+
+ /** DELETE /push/devices/:uid — 404 unless the device is the caller's. */
+ @Delete('/devices/:uid', {
+ subdomain: 'api',
+ requireUserActor: true,
+ rateLimit: {
+ scope: 'push-device-remove',
+ limit: 30,
+ window: 60_000,
+ key: 'user',
+ },
+ })
+ async remove(req: Request, res: Response): Promise {
+ const { userId } = this.#signedIn(req.actor);
+ const uid = String(req.params.uid ?? '');
+ const removed = await this.stores.pushDevice.removeByUid(userId, uid);
+ if (!removed) {
+ throw new HttpError(404, 'Device not found', {
+ legacyCode: 'not_found',
+ });
+ }
+ res.json({ removed: true });
+ }
+
+ #signedIn(actor: Actor | undefined): {
+ userId: number;
+ sessionUid: string;
+ } {
+ const userId = actor?.user?.id;
+ if (!userId) {
+ throw new HttpError(401, 'Authentication required', {
+ legacyCode: 'unauthorized',
+ });
+ }
+ const session = actor.session;
+ if (!session?.uid || session.kind !== 'web') {
+ throw new HttpError(
+ 403,
+ 'Devices can only be managed from a signed-in Puter session',
+ { legacyCode: 'forbidden' },
+ );
+ }
+ return { userId, sessionUid: session.uid };
+ }
+}
diff --git a/src/backend/services/push/PushService.ts b/src/backend/services/push/PushService.ts
index 32578ea1c..e93d9ee76 100644
--- a/src/backend/services/push/PushService.ts
+++ b/src/backend/services/push/PushService.ts
@@ -17,6 +17,10 @@
* along with this program. If not, see .
*/
+import type {
+ PushDevice,
+ PushDeviceRegistration,
+} from '../../stores/push/PushDeviceStore.js';
import { PuterService } from '../types.js';
const DEFAULT_DEVICE_RETENTION_DAYS = 60;
@@ -25,6 +29,7 @@ const STALE_SWEEP_INTERVAL_MS = 60 * 60 * 1000;
const STALE_BATCH_SIZE = 500;
/** Batches one sweep takes, so a large backlog drains over several passes. */
const STALE_MAX_BATCHES = 50;
+const MAX_DEVICES_PER_USER = 20;
/** Keeps push devices in step with their sessions; runs even with push off. */
export class PushService extends PuterService {
@@ -62,6 +67,18 @@ export class PushService extends PuterService {
this.#staleSweep = null;
}
+ /** A new device past 20 per user removes the least recently seen. */
+ async registerDevice(
+ registration: PushDeviceRegistration,
+ ): Promise {
+ const device = await this.stores.pushDevice.register(registration);
+ await this.stores.pushDevice.removeOldestBeyond(
+ registration.userId,
+ MAX_DEVICES_PER_USER,
+ );
+ return device;
+ }
+
/** Every node sweeps; an overlap costs only empty indexed batches. */
async sweepStaleDevices(): Promise {
const days = this.#retentionDays();
diff --git a/src/backend/stores/push/PushDeviceStore.test.ts b/src/backend/stores/push/PushDeviceStore.test.ts
index b873ea181..ad301b825 100644
--- a/src/backend/stores/push/PushDeviceStore.test.ts
+++ b/src/backend/stores/push/PushDeviceStore.test.ts
@@ -388,6 +388,31 @@ describe('PushDeviceStore', () => {
expect(await store().getByUid(others.uid)).not.toBeNull();
});
+ it('keeps only the most recently seen devices of one user', async () => {
+ const user = await makeUser();
+ const other = await makeUser();
+ const stalest = await register(user.id);
+ const stale = await register(user.id);
+ const fresh = await register(user.id);
+ const freshest = await register(user.id);
+ const others = await register(other.id);
+ // Later than any cutoff the other tests sweep with.
+ const now = Math.floor(Date.now() / 1000);
+ await setLastSeen(stalest.uid, now + 1);
+ await setLastSeen(stale.uid, now + 2);
+ await setLastSeen(fresh.uid, now + 3);
+ await setLastSeen(freshest.uid, now + 4);
+ await setLastSeen(others.uid, now);
+
+ expect(await store().removeOldestBeyond(user.id, 4)).toBe(0);
+ expect(await store().removeOldestBeyond(user.id, 2)).toBe(2);
+ const left = await store().listByUser(user.id);
+ expect(left.map((d) => d.uid).sort()).toEqual(
+ [fresh.uid, freshest.uid].sort(),
+ );
+ expect(await store().getByUid(others.uid)).not.toBeNull();
+ });
+
it('touches last_seen_at and counts failures', async () => {
const user = await makeUser();
const device = await register(user.id);
diff --git a/src/backend/stores/push/PushDeviceStore.ts b/src/backend/stores/push/PushDeviceStore.ts
index 6302aadb4..ed49ab3e1 100644
--- a/src/backend/stores/push/PushDeviceStore.ts
+++ b/src/backend/stores/push/PushDeviceStore.ts
@@ -290,6 +290,23 @@ export class PushDeviceStore extends PuterStore {
return result.affectedRows;
}
+ /** Remove up to 500 devices past the user's `keep` most recently seen. */
+ async removeOldestBeyond(userId: number, keep: number): Promise {
+ const offset = safeLimit(keep, 0);
+ const rows = await this.clients.db.pread(
+ 'SELECT `id` FROM `push_device` WHERE `user_id` = ? ' +
+ 'ORDER BY `last_seen_at` DESC, `id` DESC LIMIT ? OFFSET ?',
+ [userId, 500, offset],
+ );
+ const ids = rows.map((row) => Number(row.id));
+ if (ids.length === 0) return 0;
+ const result = await this.clients.db.write(
+ `DELETE FROM \`push_device\` WHERE \`id\` IN (${placeholders(ids)})`,
+ ids,
+ );
+ return result.affectedRows;
+ }
+
async touch(userId: number, uids: string[]): Promise {
const list = unique(uids);
if (list.length === 0) return;
diff --git a/src/docs/src/rate-limits-and-quotas.md b/src/docs/src/rate-limits-and-quotas.md
index eb5b1e7e8..f86cd9e5e 100644
--- a/src/docs/src/rate-limits-and-quotas.md
+++ b/src/docs/src/rate-limits-and-quotas.md
@@ -595,6 +595,21 @@ Relay traffic a guest sends is billed to the account that issued the grant. Issu
An account can also hold at most **150** open connections from one origin, so a single page can't use up the whole allowance. Over either limit, the new connection is closed as soon as it opens; connections already open stay up.
+### Push devices
+
+The Puter desktop and the Puter mobile apps register the devices that receive an account's push notifications. These routes only accept a signed-in Puter session, so apps and API tokens can't call them.
+
+| Limit | Value |
+| --------------------------------------------- | ---------------- |
+| Device registrations (`POST /push/devices`) | 30/min |
+| Device list reads (`GET /push/devices`) | 60/min |
+| Device removals (`DELETE /push/devices/:uid`) | 30/min |
+| Devices per account | 20 |
+| Device token | 4,096 characters |
+| Device label | 64 characters |
+
+Registering a new device when the account already has 20 removes the device that was seen least recently.
+
### All driver calls
Every driver call also counts toward **8,000 calls/min**, before the per-API limits above. It only exists to catch runaway loops; a client that hits it is looping.