Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions src/web/routes/webview-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,11 @@ function registerCrudRoutes(app: FastifyInstance, ctx: EventPort & TabLayoutPort
});
throw error;
}
ctx.broadcast(SseEvent.WebviewChanged, { action: 'created', id: created.id });
ctx.broadcast(SseEvent.WebviewChanged, {
action: 'created',
id: created.id,
owner: ownerLayoutKey(created.owner),
});
return { success: true, data: created };
});

Expand Down Expand Up @@ -195,7 +199,11 @@ function registerCrudRoutes(app: FastifyInstance, ctx: EventPort & TabLayoutPort
// Any edit invalidates the outstanding capability. Otherwise a token minted
// against the OLD url keeps proxying to it after the user repointed the tab.
webviewCapabilities.revokeWebview(id);
ctx.broadcast(SseEvent.WebviewChanged, { action: 'updated', id });
ctx.broadcast(SseEvent.WebviewChanged, {
action: 'updated',
id,
owner: ownerLayoutKey(updated.owner),
});
return { success: true, data: updated };
});

Expand Down Expand Up @@ -231,7 +239,11 @@ function registerCrudRoutes(app: FastifyInstance, ctx: EventPort & TabLayoutPort

webviewCapabilities.revokeWebview(id);
socketCounts.delete(id);
ctx.broadcast(SseEvent.WebviewChanged, { action: 'deleted', id });
ctx.broadcast(SseEvent.WebviewChanged, {
action: 'deleted',
id,
owner: ownerLayoutKey(result.owner),
});
return { success: true, data: { id } };
});

Expand Down
7 changes: 7 additions & 0 deletions src/web/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ import { PushSubscriptionStore } from '../push-store.js';
import webpush from 'web-push';
import { SseStreamManager } from './sse-stream-manager.js';
import { deriveTabLayoutSseHint } from './tab-layout-sse.js';
import { deriveWebviewSseHint } from './webview-sse.js';
import {
type SessionListenerRefs,
createSessionListeners,
Expand Down Expand Up @@ -2461,6 +2462,12 @@ export class WebServer extends EventEmitter {
if (event.startsWith('tab:')) {
return deriveTabLayoutSseHint(data);
}
// Saved-webview invalidations carry the trusted resource owner. Route them to
// that owner (plus admins), so an admin editing a user's web tab notifies the
// user, and no other user learns the ids of someone else's web tabs.
if (event.startsWith('webview:')) {
return deriveWebviewSseHint(data);
}
// Session-scoped families: resolve the owner from the payload's session id.
const SESSION_PREFIXES = [
'session:',
Expand Down
6 changes: 4 additions & 2 deletions src/web/sse-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -481,8 +481,10 @@ export const AuthPasswordChangeRequired = 'auth:passwordChangeRequired' as const
export const SessionOrderChanged = 'session:orderChanged' as const;

/** A saved web tab (dashboard URL) was created, updated or deleted.
* Payload: `{ action: 'created' | 'updated' | 'deleted', id }`. The client
* re-fetches the list rather than patching from the payload. */
* Payload: `{ action: 'created' | 'updated' | 'deleted', id, owner }`. The client
* re-fetches the list rather than patching from the payload. `owner` is the web
* tab's owner (`'@single'` when multi-user mode is off); in multi-user mode the
* event is delivered only to that owner and admins (`deriveWebviewSseHint`). */
export const WebviewChanged = 'webview:changed' as const;
/** Owner-scoped layout invalidation. Payload contains only `{ owner, version }`. */
export const TabLayoutChanged = 'tab:layoutChanged' as const;
Expand Down
6 changes: 6 additions & 0 deletions src/web/webview-sse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/** @fileoverview Trusted owner routing metadata for saved-webview invalidations. */
import type { SseRoutingHint } from './sse-stream-manager.js';

export function deriveWebviewSseHint(data: unknown): SseRoutingHint {
return { username: (data as { owner?: string }).owner, sessionScoped: true };
}
217 changes: 217 additions & 0 deletions test/webview-sse.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
/**
* @fileoverview Owner routing of the `webview:changed` SSE event.
*
* Saved web tabs are owner-scoped in multi-user mode (`canAccessOwned` on every
* CRUD route), but their invalidation event used to carry no owner and fell
* through `deriveSseHint`'s global branch, so every connected user learned the
* ids of every other user's web-tab creates, edits and deletes. The event now
* carries the resource owner and routes to that owner plus admins; single-user
* mode (no SSE identity) still delivers it to every client.
*/
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import Fastify, { type FastifyInstance, type FastifyReply } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import fastifyWebsocket from '@fastify/websocket';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { CleanupManager } from '../src/utils/index.js';
import type { AuthUser } from '../src/types.js';
import { installRouteErrorHandler } from '../src/web/route-error-handler.js';
import { registerWebviewRoutes } from '../src/web/routes/webview-routes.js';
import { WebServer } from '../src/web/server.js';
import { SseEvent } from '../src/web/sse-events.js';
import { SseStreamManager, type SseRoutingHint } from '../src/web/sse-stream-manager.js';
import { deriveWebviewSseHint } from '../src/web/webview-sse.js';

function client() {
const writes: string[] = [];
return {
writes,
reply: { raw: { write: (chunk: string) => (writes.push(chunk), true) } } as unknown as FastifyReply,
};
}

/** The server's real event → routing-hint derivation, without starting the server. */
function serverHint(event: string, data: unknown): SseRoutingHint | undefined {
const server = new WebServer(3999, false, true) as unknown as {
deriveSseHint(event: string, data: unknown): SseRoutingHint | undefined;
};
return server.deriveSseHint(event, data);
}

describe('deriveWebviewSseHint', () => {
it('routes to the exact owner and fails closed when the owner is missing', () => {
expect(deriveWebviewSseHint({ action: 'updated', id: 'w1', owner: 'alice' })).toEqual({
username: 'alice',
sessionScoped: true,
});
expect(deriveWebviewSseHint({ action: 'updated', id: 'w1' })).toEqual({
username: undefined,
sessionScoped: true,
});
});

it('is what the server derives for the webview: family (never the global branch)', () => {
const payload = { action: 'created', id: 'w1', owner: 'alice' };
expect(serverHint(SseEvent.WebviewChanged, payload)).toEqual({ username: 'alice', sessionScoped: true });
expect(serverHint(SseEvent.WebviewChanged, { action: 'deleted', id: 'w1' })).not.toBeUndefined();
});
});

describe('webview:changed delivery', () => {
it('reaches the owner and admins, never another ordinary user', () => {
const cleanup = new CleanupManager();
const manager = new SseStreamManager({ getSessionStateWithRespawn: () => null }, cleanup);
const alice = client();
const bob = client();
const admin = client();
manager.addClient(alice.reply, null, false, undefined, { username: 'alice', role: 'user' });
manager.addClient(bob.reply, null, false, undefined, { username: 'bob', role: 'user' });
manager.addClient(admin.reply, null, false, undefined, { username: 'root', role: 'admin' });
const payload = { action: 'deleted', id: 'w1', owner: 'alice' };

manager.broadcast(SseEvent.WebviewChanged, payload, serverHint(SseEvent.WebviewChanged, payload));

expect(alice.writes).toEqual(['event: webview:changed\ndata: {"action":"deleted","id":"w1","owner":"alice"}\n\n']);
expect(admin.writes).toEqual(alice.writes);
expect(bob.writes).toEqual([]);
cleanup.dispose();
});

it('single-user mode: clients without an identity all still receive it', () => {
const cleanup = new CleanupManager();
const manager = new SseStreamManager({ getSessionStateWithRespawn: () => null }, cleanup);
const tabA = client();
const tabB = client();
manager.addClient(tabA.reply, null, false, undefined, undefined);
manager.addClient(tabB.reply, null, false, undefined, undefined);
const payload = { action: 'created', id: 'w1', owner: '@single' };

manager.broadcast(SseEvent.WebviewChanged, payload, serverHint(SseEvent.WebviewChanged, payload));

expect(tabA.writes).toHaveLength(1);
expect(tabB.writes).toEqual(tabA.writes);
cleanup.dispose();
});
});

describe('webview routes → SSE, end to end', () => {
let tmpDir: string;
let savedDataDir: string | undefined;
let savedMode: string | undefined;
let cleanup: CleanupManager;
let manager: SseStreamManager;
const apps: FastifyInstance[] = [];

beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codeman-webview-sse-'));
savedDataDir = process.env.CODEMAN_DATA_DIR;
savedMode = process.env.CODEMAN_MULTIUSER;
process.env.CODEMAN_DATA_DIR = tmpDir;
cleanup = new CleanupManager();
manager = new SseStreamManager({ getSessionStateWithRespawn: () => null }, cleanup);
});

afterEach(async () => {
for (const app of apps.splice(0)) await app.close();
cleanup.dispose();
if (savedDataDir === undefined) delete process.env.CODEMAN_DATA_DIR;
else process.env.CODEMAN_DATA_DIR = savedDataDir;
if (savedMode === undefined) delete process.env.CODEMAN_MULTIUSER;
else process.env.CODEMAN_MULTIUSER = savedMode;
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
});

/** A route app acting as `authUser`, whose broadcasts go through the server's routing. */
async function appAs(authUser: AuthUser | undefined): Promise<FastifyInstance> {
const app = Fastify({ logger: false });
await app.register(fastifyCookie);
await app.register(fastifyWebsocket);
app.decorateRequest('authUser', undefined);
app.addHook('onRequest', async (req) => {
req.authUser = authUser;
});
registerWebviewRoutes(app, {
broadcast: (event: string, data: unknown) => manager.broadcast(event, data, serverHint(event, data)),
tabLayouts: { webviewCreated: async () => {}, webviewDeleted: async () => {} },
} as never);
installRouteErrorHandler(app);
await app.ready();
apps.push(app);
return app;
}

it("multi-user: another user's SSE stream never sees a web-tab create, edit or delete", async () => {
process.env.CODEMAN_MULTIUSER = '1';
const aliceApp = await appAs({ username: 'alice', role: 'user' });
const alice = client();
const bob = client();
const admin = client();
manager.addClient(alice.reply, null, false, undefined, { username: 'alice', role: 'user' });
manager.addClient(bob.reply, null, false, undefined, { username: 'bob', role: 'user' });
manager.addClient(admin.reply, null, false, undefined, { username: 'root', role: 'admin' });

const created = await aliceApp.inject({
method: 'POST',
url: '/api/webviews',
payload: { name: 'Grafana', url: 'http://127.0.0.1:4000/' },
});
expect(created.statusCode).toBe(200);
const id = created.json().data.id as string;
const patched = await aliceApp.inject({ method: 'PATCH', url: `/api/webviews/${id}`, payload: { name: 'G2' } });
expect(patched.statusCode).toBe(200);
expect((await aliceApp.inject({ method: 'DELETE', url: `/api/webviews/${id}` })).statusCode).toBe(200);

const expected = ['created', 'updated', 'deleted'].map(
(action) => `event: webview:changed\ndata: ${JSON.stringify({ action, id, owner: 'alice' })}\n\n`
);
expect(alice.writes).toEqual(expected);
expect(admin.writes).toEqual(expected);
expect(bob.writes).toEqual([]);
});

it("multi-user: an admin editing a user's web tab notifies that user, not a bystander", async () => {
process.env.CODEMAN_MULTIUSER = '1';
const aliceApp = await appAs({ username: 'alice', role: 'user' });
const adminApp = await appAs({ username: 'root', role: 'admin' });
const id = (
await aliceApp.inject({
method: 'POST',
url: '/api/webviews',
payload: { name: 'G', url: 'http://127.0.0.1:4000/' },
})
).json().data.id as string;
const alice = client();
const bob = client();
manager.addClient(alice.reply, null, false, undefined, { username: 'alice', role: 'user' });
manager.addClient(bob.reply, null, false, undefined, { username: 'bob', role: 'user' });

expect((await adminApp.inject({ method: 'DELETE', url: `/api/webviews/${id}` })).statusCode).toBe(200);

expect(alice.writes).toEqual([
`event: webview:changed\ndata: ${JSON.stringify({ action: 'deleted', id, owner: 'alice' })}\n\n`,
]);
expect(bob.writes).toEqual([]);
});

it('single-user: every client still receives the event', async () => {
const soloApp = await appAs(undefined);
const tabA = client();
const tabB = client();
manager.addClient(tabA.reply, null, false, undefined, undefined);
manager.addClient(tabB.reply, null, false, undefined, undefined);

const created = await soloApp.inject({
method: 'POST',
url: '/api/webviews',
payload: { name: 'G', url: 'http://127.0.0.1:4000/' },
});
expect(created.statusCode).toBe(200);

expect(tabA.writes).toEqual([
`event: webview:changed\ndata: ${JSON.stringify({ action: 'created', id: created.json().data.id, owner: '@single' })}\n\n`,
]);
expect(tabB.writes).toEqual(tabA.writes);
});
});
Loading