feat(push): device endpoints for the signed-in user

POST, GET and DELETE /push/devices on the api subdomain let a signed-in
Puter session register, list and remove its account's push devices. Apps,
access tokens and worker sessions get 403. Registration answers 503
push_disabled unless push.enabled is set; listing and removal always work.
An account keeps at most 20 devices, and a new one removes the least
recently seen. Responses never include the device token.
This commit is contained in:
Juan Castro committed 2026-10-24 16:03:45 -04:00
1 parent 61ed1ece42
commit a17ace4187
7 files changed
+734

No files matched your search

+2
View File
@@ -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,
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<TestUserCredentials> =>
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<string, unknown>,
): Promise<PublicPushDevice> => {
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<PublicPushDevice[]> => {
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);
});
});
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<string> = new Set<PushPlatform>([
'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<void> {
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<void> {
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<void> {
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 };
}
}
+17
View File
@@ -17,6 +17,10 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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<PushDevice> {
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<number> {
const days = this.#retentionDays();
@@ -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);
@@ -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<number> {
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<void> {
const list = unique(uids);
if (list.length === 0) return;
+15
View File
@@ -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.