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.