From 7ec674d33b97ae40d57f5486b652055b38bd3a23 Mon Sep 17 00:00:00 2001 From: Daniel Salazar Date: Wed, 2 Sep 2026 09:01:22 -0700 Subject: [PATCH] feat: app sockets join their own events room (PUT-1671) (#3676) --- .../events/appSockets.integration.test.ts | 307 ++++++++++++++++++ .../services/socket/SocketService.test.ts | 78 +++++ src/backend/services/socket/SocketService.ts | 97 ++++-- src/puter-js/src/modules/FileSystem/index.js | 7 - 4 files changed, 463 insertions(+), 26 deletions(-) create mode 100644 src/backend/services/events/appSockets.integration.test.ts diff --git a/src/backend/services/events/appSockets.integration.test.ts b/src/backend/services/events/appSockets.integration.test.ts new file mode 100644 index 000000000..16c282fae --- /dev/null +++ b/src/backend/services/events/appSockets.integration.test.ts @@ -0,0 +1,307 @@ +/* + * 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 . + */ + +/** + * What an app connection can and cannot hear, over a real socket. + * + * The isolation claim is the whole point of letting an app hold one: it sees + * its own subscriptions' deliveries and nothing else — not the desktop's + * filesystem fan, not another app's deliveries. Only a live server can show + * that, so this drives real socket.io clients against real HTTP writes. + */ + +import { io as ioClient, type Socket as ClientSocket } from 'socket.io-client'; +import { v4 as uuidv4 } from 'uuid'; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js'; +import type { IConfig } from '../../types.js'; +import { + appSocketRoom, + SocketService, +} from '../socket/SocketService.js'; +import { + EVENTS_DELIVERY_CHANNEL, + EVENTS_SUBSCRIBE_VERB, + type DeliveryEnvelope, +} from './EventsService.js'; + +const BOOT_TIMEOUT_MS = 120_000; + +let env: PuterTestEnv; +let userId: number; +let username: string; +let sessionToken: string; + +/** Two apps, so "only its own" has something to be measured against. */ +let appOne: { uid: string; token: string }; +let appTwo: { uid: string; token: string }; + +const openSockets: ClientSocket[] = []; + +const socketService = () => + env.server.services.socket as unknown as SocketService; + +const makeApp = async ( + forUserId = userId, + forUsername = username, +): Promise<{ uid: string; token: string }> => { + const uid = `app-${uuidv4()}`; + await env.server.clients.db.write( + 'INSERT INTO `apps` (`uid`, `name`, `title`, `index_url`, `owner_user_id`) VALUES (?, ?, ?, ?, ?)', + [uid, uid, uid, `https://${uid}.example/`, forUserId], + ); + const user = await env.server.stores.user.getByUsername(forUsername); + const token = await env.server.services.auth.getUserAppToken( + { user: user as never, effectiveApp: null }, + uid, + ); + // An app's own data directory is the one place it can always list, which + // is what makes it the honest anchor for these. + await env.server.services.fs.mkdir(forUserId, { + path: `/${forUsername}/AppData/${uid}`, + createMissingParents: true, + }); + return { uid, token }; +}; + +const connect = ( + token: string, + opts: Record = {}, +): Promise => + new Promise((resolve, reject) => { + const socket = ioClient(env.origin, { + auth: { auth_token: token }, + transports: ['websocket'], + reconnection: false, + ...opts, + }); + openSockets.push(socket); + socket.on('connect', () => resolve(socket)); + socket.on('connect_error', (err: Error) => reject(err)); + }); + +const subscribe = ( + socket: ClientSocket, + subject: string, +): Promise<{ ok: boolean; sub?: { subId: string } }> => + new Promise((resolve) => + socket.emit(EVENTS_SUBSCRIBE_VERB, { subject }, resolve), + ); + +/** Every message a socket saw on one channel, in arrival order. */ +const collect = (socket: ClientSocket, channel: string): unknown[] => { + const seen: unknown[] = []; + socket.on(channel, (payload: unknown) => seen.push(payload)); + return seen; +}; + +const deliveries = (socket: ClientSocket): DeliveryEnvelope[] => + collect(socket, EVENTS_DELIVERY_CHANNEL) as DeliveryEnvelope[]; + +/** Create a directory the way a client does — the write that fans out. */ +const mkdirOverHttp = async (path: string): Promise => { + const response = await fetch(new URL('/mkdir', env.apiOrigin), { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${sessionToken}`, + }, + body: JSON.stringify({ path, create_missing_parents: true }), + }); + expect(response.status).toBe(200); +}; + +const settle = (seen: unknown[], count = 1) => + vi.waitFor(() => expect(seen.length).toBeGreaterThanOrEqual(count), { + timeout: 5_000, + interval: 25, + }); + +beforeAll(async () => { + env = await setupPuterTestEnv({ events: { enabled: true } } as IConfig); + username = env.users.user.username; + sessionToken = env.users.user.token; + const user = await env.server.stores.user.getByUsername(username); + userId = user!.id; + + appOne = await makeApp(); + appTwo = await makeApp(); +}, BOOT_TIMEOUT_MS); + +afterEach(async () => { + while (openSockets.length) openSockets.pop()?.disconnect(); + await vi.waitFor(() => { + for (const room of [ + String(userId), + appSocketRoom(userId, appOne.uid), + appSocketRoom(userId, appTwo.uid), + ]) + expect(socketService().has({ room })).toBe(false); + }); +}); + +afterAll(async () => { + await env?.shutdown(); +}); + +describe('an app connection', () => { + it('is admitted, and only into its own room', async () => { + const socket = await connect(appOne.token); + + expect(socket.connected).toBe(true); + expect( + socketService().has({ room: appSocketRoom(userId, appOne.uid) }), + ).toBe(true); + // The user room is what carries the desktop's filesystem fan. + expect(socketService().has({ room: userId })).toBe(false); + }); + + it('hears its own subscription and nothing the desktop hears', async () => { + const app = await connect(appOne.token); + const desktop = await connect(sessionToken); + const appDelivered = deliveries(app); + const appItems = collect(app, 'item.added'); + const appCache = collect(app, 'cache.updated'); + const desktopItems = collect(desktop, 'item.added'); + const desktopDelivered = deliveries(desktop); + + const anchor = `/${username}/AppData/${appOne.uid}`; + const ack = await subscribe(app, `fs:${anchor}`); + expect(ack.ok).toBe(true); + + const created = `${anchor}/reports-${uuidv4().slice(0, 8)}`; + await mkdirOverHttp(created); + await settle(appDelivered); + await settle(desktopItems); + + expect(appDelivered).toHaveLength(1); + expect(appDelivered[0].subId).toBe(ack.sub!.subId); + expect(appDelivered[0].event).toMatchObject({ + op: 'add', + path: created, + self: true, + }); + // The legacy channel still reaches the desktop, and only the desktop; + // the desktop, subscribed to nothing, gains nothing new. + expect(desktopItems.length).toBeGreaterThan(0); + expect(appItems).toEqual([]); + expect(appCache).toEqual([]); + expect(desktopDelivered).toEqual([]); + }); + + it('delivers only the projected shape', async () => { + const app = await connect(appOne.token); + const delivered = deliveries(app); + const anchor = `/${username}/AppData/${appOne.uid}`; + await subscribe(app, `fs:${anchor}`); + + await mkdirOverHttp(`${anchor}/shape-${uuidv4().slice(0, 8)}`); + await settle(delivered); + + expect(Object.keys(delivered[0]).sort()).toEqual(['event', 'subId']); + expect(Object.keys(delivered[0].event).sort()).toEqual([ + 'id', + 'op', + 'path', + 'self', + 'seq', + 'subject', + 'ts', + 'uid', + ]); + }); + + it('never sees another app`s deliveries', async () => { + const one = await connect(appOne.token); + const two = await connect(appTwo.token); + const oneDelivered = deliveries(one); + const twoDelivered = deliveries(two); + + const oneAnchor = `/${username}/AppData/${appOne.uid}`; + await subscribe(one, `fs:${oneAnchor}`); + await subscribe(two, `fs:/${username}/AppData/${appTwo.uid}`); + + await mkdirOverHttp(`${oneAnchor}/private-${uuidv4().slice(0, 8)}`); + await settle(oneDelivered); + + expect(oneDelivered).toHaveLength(1); + expect(twoDelivered).toEqual([]); + }); + + it('works from a standalone origin', async () => { + const app = await connect(appOne.token, { + extraHeaders: { Origin: 'https://standalone.example' }, + }); + const delivered = deliveries(app); + const anchor = `/${username}/AppData/${appOne.uid}`; + await subscribe(app, `fs:${anchor}`); + + await mkdirOverHttp(`${anchor}/standalone-${uuidv4().slice(0, 8)}`); + await settle(delivered); + + expect(delivered).toHaveLength(1); + }); + + it('spends the account`s per-origin allowance like any other socket', async () => { + const previous = SocketService.MAX_SOCKETS_PER_ORIGIN; + SocketService.MAX_SOCKETS_PER_ORIGIN = 1; + try { + const origin = { extraHeaders: { Origin: 'https://capped.example' } }; + const first = await connect(appOne.token, origin); + const second = await connect(appTwo.token, origin); + + await new Promise((resolve) => setTimeout(resolve, 500)); + expect(first.connected).toBe(true); + expect(second.connected).toBe(false); + } finally { + SocketService.MAX_SOCKETS_PER_ORIGIN = previous; + } + }); + + it('is dropped together with the desktop when the account`s sessions are revoked', async () => { + // A separate account: revoking sessions must not disturb the shared + // fixtures the rest of this file connects with. + const other = env.users.other; + const otherRow = await env.server.stores.user.getByUsername( + other.username, + ); + const otherApp = await makeApp(otherRow!.id, other.username); + + const desktop = await connect(other.token); + const app = await connect(otherApp.token); + expect(desktop.connected).toBe(true); + expect(app.connected).toBe(true); + + const authService = env.server.services.auth as unknown as { + revokeAllSessionsForUserId: (id: number) => Promise; + }; + await authService.revokeAllSessionsForUserId(otherRow!.id); + + // Eviction goes by account, not by which session minted the socket — + // the app connection is not in the desktop's room, so it has to be + // reached the same way the desktop is. + await vi.waitFor( + () => { + expect(desktop.connected).toBe(false); + expect(app.connected).toBe(false); + }, + { timeout: 5_000 }, + ); + }); +}); diff --git a/src/backend/services/socket/SocketService.test.ts b/src/backend/services/socket/SocketService.test.ts index 5bdbc492c..50695233e 100644 --- a/src/backend/services/socket/SocketService.test.ts +++ b/src/backend/services/socket/SocketService.test.ts @@ -18,6 +18,7 @@ */ import { io as ioClient, type Socket as ClientSocket } from 'socket.io-client'; +import { v4 as uuidv4 } from 'uuid'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import type { Actor } from '../../core/actor.js'; import type { PuterServer } from '../../server.js'; @@ -29,8 +30,10 @@ import { } from '../../testUtil.js'; import type { AuthResult } from '../auth/AuthService.js'; import { + accountSocketRoom, buildSocketReauthError, decideSocketAuth, + socketRoomsFor, SocketService, type SocketReauthError, } from './SocketService.js'; @@ -106,6 +109,13 @@ describe('decideSocketAuth', () => { expect((decision.reject as { data?: unknown }).data).toBeUndefined(); }); + it('admits an app-under-user actor where app sockets are allowed', () => { + const decision = decideSocketAuth({ actor: appActor } as AuthResult, { + allowAppActors: true, + }); + expect(decision).toEqual({ accept: appActor }); + }); + it('rejects an access-token actor with a specific message', () => { const decision = decideSocketAuth({ actor: accessTokenActor, @@ -114,6 +124,38 @@ describe('decideSocketAuth', () => { expect(decision.reject.message).toMatch(/only user tokens/); }); + it('keeps rejecting an access-token actor where app sockets are allowed', () => { + // Including one an app issued: the allowance is for app-under-user + // credentials, not for anything that resolves to an app. + const issuedByApp: Actor = { + ...accessTokenActor, + accessToken: { + uid: 'tok-2', + issuer: appActor, + authorized: null, + }, + effectiveApp: appActor.app, + }; + for (const actor of [accessTokenActor, issuedByApp]) { + const decision = decideSocketAuth({ actor } as AuthResult, { + allowAppActors: true, + }); + if (!('reject' in decision)) throw new Error('expected reject'); + expect(decision.reject.message).toMatch(/only user tokens/); + } + }); + + it('applies the account gates to an admitted app actor', () => { + const decision = decideSocketAuth( + { + actor: { ...appActor, user: { ...appActor.user, suspended: 1 } }, + } as unknown as AuthResult, + { allowAppActors: true }, + ); + if (!('reject' in decision)) throw new Error('expected reject'); + expect(decision.reject.message).toMatch(/suspended/i); + }); + it('rejects a suspended user — the HTTP gate the handshake never runs', () => { const decision = decideSocketAuth({ actor: { @@ -161,6 +203,25 @@ describe('decideSocketAuth', () => { }); }); +// -- socketRoomsFor ---------------------------------------------------- + +describe('socketRoomsFor', () => { + it('puts a session in the user room', () => { + expect( + socketRoomsFor({ user: { id: 7, uuid: 'u-7', username: 'u' } }), + ).toEqual(['7', accountSocketRoom(7)]); + }); + + it('keeps an app out of the user room and in its own', () => { + const rooms = socketRoomsFor({ + user: { id: 7, uuid: 'u-7', username: 'u' }, + app: { uid: 'app-1' }, + }); + expect(rooms).toEqual(['u7:aapp-1', accountSocketRoom(7)]); + expect(rooms).not.toContain('7'); + }); +}); + // -- Live socket.io integration -------------------------------------- describe('SocketService (live socket.io)', () => { @@ -234,6 +295,23 @@ describe('SocketService (live socket.io)', () => { ); }); + it('rejects an app-under-user token while events are off', async () => { + const row = await server.stores.user.getByUsername(user.username); + const appUid = `app-${uuidv4()}`; + await server.clients.db.write( + 'INSERT INTO `apps` (`uid`, `name`, `title`, `index_url`, `owner_user_id`) VALUES (?, ?, ?, ?, ?)', + [appUid, appUid, appUid, `https://${appUid}.example/`, row!.id], + ); + const appToken = await server.services.auth.getUserAppToken( + { user: row as never, effectiveApp: null }, + appUid, + ); + + await expect(connect({ auth_token: appToken })).rejects.toThrow( + /only user tokens/, + ); + }); + it('accepts a user session token, joins the per-user room, and announces the connect', async () => { const row = await server.stores.user.getByUsername(user.username); const userId = row!.id; diff --git a/src/backend/services/socket/SocketService.ts b/src/backend/services/socket/SocketService.ts index 0ebcb7eb8..b61ade1f2 100644 --- a/src/backend/services/socket/SocketService.ts +++ b/src/backend/services/socket/SocketService.ts @@ -60,6 +60,15 @@ export const buildSocketReauthError = (reauth: { /** Pure decision from an `AuthResult` to a socket-side accept/reject. */ export type SocketAuthDecision = { accept: Actor } | { reject: Error }; +export interface SocketAuthOptions { + /** + * Whether an app-under-user actor may hold a connection. Off, the handshake + * is exactly what it has always been. On, an app socket is admitted — into + * its own room and nothing else (see `socketRoomsFor`). + */ + allowAppActors?: boolean; +} + /** * Map an `AuthService.authenticate()` result onto the socket-handshake verdict. * Order matters: @@ -67,8 +76,9 @@ export type SocketAuthDecision = { accept: Actor } | { reject: Error }; * 1. `reauth` → structured `reauth_required` error so the client can drive the * same migration / re-login flow it does for HTTP. * 2. Missing actor → generic `socket auth failed`. - * 3. App-under-user / access-token actor → rejected with a specific message; - * sockets only accept plain user actors. + * 3. Access-token actor → rejected, always. App-under-user actor → rejected unless + * `allowAppActors`, since a subscription feed is the only thing an app + * connection is for. * 4. Suspended, or pending a verification → rejected. A socket carries the same * filesystem entries, upload paths and notification bodies as the HTTP * routes, which get these two from `requireAuthGate` / @@ -78,7 +88,10 @@ export type SocketAuthDecision = { accept: Actor } | { reject: Error }; * * Pure / no side effects — the middleware logs the reauth event. */ -export const decideSocketAuth = (result: AuthResult): SocketAuthDecision => { +export const decideSocketAuth = ( + result: AuthResult, + options: SocketAuthOptions = {}, +): SocketAuthDecision => { if (result.reauth) { return { reject: buildSocketReauthError(result.reauth) }; } @@ -86,7 +99,10 @@ export const decideSocketAuth = (result: AuthResult): SocketAuthDecision => { if (!actor || !actor.user) { return { reject: new Error('socket auth failed') }; } - if (isAppActor(actor) || isAccessTokenActor(actor)) { + if ( + isAccessTokenActor(actor) || + (isAppActor(actor) && !options.allowAppActors) + ) { return { reject: new Error('socket auth: only user tokens accepted') }; } try { @@ -111,6 +127,34 @@ export interface SocketSpecifier { socket?: string; } +/** The room an app-under-user socket receives its own deliveries in. */ +export const appSocketRoom = ( + userId: number | string, + appUid: string, +): string => `u${userId}:a${appUid}`; + +/** + * A room every one of an account's sockets joins and nothing is ever emitted + * to. The user room is what a session revoke drops, and an app socket is + * deliberately not in it — this is the handle that reaches those. + */ +export const accountSocketRoom = (userId: number | string): string => + `u${userId}:all`; + +/** + * Which rooms a socket joins. An app socket gets its own per-(user, app) room + * and never the user room, which carries the whole `outer.gui.*` fan and is the + * reason app actors were refused outright. + */ +export const socketRoomsFor = (actor: Actor): string[] => { + const userId = String(actor.user!.id); + const appUid = isAppActor(actor) ? actor.app?.uid : undefined; + return [ + appUid ? appSocketRoom(userId, appUid) : userId, + accountSocketRoom(userId), + ]; +}; + // -- Redis key format for cross-node FS-cache invalidation ---------- // // puter-js (browser) polls `GET /cache/last-change-timestamp` and purges @@ -161,9 +205,9 @@ interface AuthenticatedSocket extends Socket { * Socket.io wrapper with: * * 1. Auth middleware — reads `handshake.auth.auth_token`, validates it via - * `AuthService`, rejects anything other than plain user actors (no - * app-under-user, no access-token), and joins the socket to a per-user room - * keyed by `user.id`. + * `AuthService`, rejects access-token actors (and app-under-user actors + * unless events are enabled), and joins the socket to its room: the per-user + * room keyed by `user.id` for a session, a per-(user, app) room for an app. * 2. Event bus → socket fan-out — subscribes to the known set of `outer.gui.*` * mutation events and pushes each to the affected users' rooms. Strips the * `outer.gui.` prefix before emitting. @@ -314,6 +358,14 @@ export class SocketService extends PuterService { // -- Auth + connection wiring ----------------------------------- + /** + * An app connection exists to carry event subscriptions, so it is admitted + * only where those are switched on. + */ + #authOptions(): SocketAuthOptions { + return { allowAppActors: this.config.events?.enabled === true }; + } + #installAuthMiddleware(): void { if (!this.#io) return; const authService = this.services.auth as AuthService | undefined; @@ -369,7 +421,7 @@ export class SocketService extends PuterService { ); } - const decision = decideSocketAuth(result); + const decision = decideSocketAuth(result, this.#authOptions()); if ('reject' in decision) { next(decision.reject); return; @@ -379,7 +431,7 @@ export class SocketService extends PuterService { socket.authToken = token; // user.id is numeric in the DB; stringify for room name // so adapter lookups key on a stable type. - socket.join(String(decision.accept.user!.id)); + socket.join(socketRoomsFor(decision.accept)); next(); } catch (err) { console.warn('[socket] auth error', err); @@ -424,13 +476,12 @@ export class SocketService extends PuterService { /** * Simultaneous connections per (user, origin). * - * The natural split would be per app, but there isn't one to key on: - * `decideSocketAuth` accepts only plain user actors, so an app-token actor - * never reaches this code and every socket here belongs to a session. The - * requesting origin is the next-best proxy — it separates our own pages - * from a third-party site embedding the SDK against the same session, which - * is the split that matters. Without it a single looping page consumes the - * account's whole allowance and takes every other window offline with it. + * The natural split would be per app, but most sockets have no app to key + * on — a session carries none, and an app connection is the minority case. + * The requesting origin covers both: it separates our own pages from a + * third-party site embedding the SDK against the same session, and an app + * connects from its own origin. Without it a single looping page consumes + * the account's whole allowance and takes every other window offline. * * A browser sets `Origin` itself, so a page can't lie about its own; a * non-browser client can put anything there, which is exactly why the @@ -563,6 +614,7 @@ export class SocketService extends PuterService { try { decision = decideSocketAuth( await authService.authenticate(token, {}), + this.#authOptions(), ); } catch { decision = { reject: new Error('socket reauth failed') }; @@ -594,8 +646,9 @@ export class SocketService extends PuterService { * Cluster-wide: `disconnectSockets` publishes through the adapter, so a * revoke handled on one node reaches sockets terminated on another. * - * Every connection for the account goes, not just the revoked session's. - * Narrowing would mean matching each socket to its session via + * Every connection for the account goes, not just the revoked session's — + * which is what the account room is for, since an app socket is not in the + * user room. Narrowing would mean matching each socket to its session via * `fetchSockets`, which the adapter implements on top of `serverCount()` — * and that path is unavailable with our Redis client. Dropping the room is * the safe direction: a connection whose session survived reconnects on its @@ -604,7 +657,7 @@ export class SocketService extends PuterService { async #evictUserSockets(userId: number): Promise { const io = this.#io; if (!io || !userId) return; - await io.in(String(userId)).disconnectSockets(true); + await io.in(accountSocketRoom(userId)).disconnectSockets(true); } async #allowSocketEvent(userId: number, event: string): Promise { @@ -637,6 +690,12 @@ export class SocketService extends PuterService { // answer with `events_disabled` rather than going unanswered. this.services.events.attachSocket(socket, actor); + // Everything below is the desktop session's own traffic: two verbs + // that reach the user's other tabs, and a connect announcement + // whose listeners read it as "the UI is up". An app connection is + // none of those things. + if (isAppActor(actor)) return; + // Peer-echo: one tab notifies others that trash is empty. socket.on('trash.is_empty', (msg: unknown) => { void this.#allowSocketEvent(userId, 'trash.is_empty').then( diff --git a/src/puter-js/src/modules/FileSystem/index.js b/src/puter-js/src/modules/FileSystem/index.js index d2b613473..8c17e11a4 100644 --- a/src/puter-js/src/modules/FileSystem/index.js +++ b/src/puter-js/src/modules/FileSystem/index.js @@ -130,13 +130,6 @@ export class PuterJSFileSystemModule extends PuterModule { } bindSocketEvents () { - // this.socket.on('cache.updated', (msg) => { - // // check original_client_socket_id and if it matches this.socket.id, don't post update - // if (msg.original_client_socket_id !== this.socket.id) { - // this.invalidateCache(); - // } - // }); - this.socket.on('item.renamed', (item) => { puter._cache.flushall(); });