feat: app sockets join their own events room (PUT-1671) (#3676)

This commit is contained in:
Daniel Salazar
2026-09-02 09:01:22 -07:00
committed by GitHub
parent b07d2e109f
commit 7ec674d33b
4 changed files with 463 additions and 26 deletions
@@ -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 <https://www.gnu.org/licenses/>.
*/
/**
* 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<string, unknown> = {},
): Promise<ClientSocket> =>
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<void> => {
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<void>;
};
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 },
);
});
});
@@ -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;
+78 -19
View File
@@ -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<void> {
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<boolean> {
@@ -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(
@@ -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();
});