mirror of
https://github.com/HeyPuter/puter.git
synced 2026-09-14 01:06:14 +00:00
feat: app sockets join their own events room (PUT-1671) (#3676)
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user