Compare commits

..

5 Commits

Author SHA1 Message Date
Carlos Valente 542038e8f9 style: bump oxfmt to 0.63 and reformat
oxfmt is a 0.x, so the ^0.42.0 caret never crossed a minor and it sat 21
minors behind. The reformat turned out to be tiny — 2 files:

- mcp.server.ts: a call collapses onto fewer lines
- rundown.utils.ts: quoted numeric-enum members lose their quotes.
  `enum X { 'id' }` and `enum X { id }` compile to the same object, so
  the `key in RegenerateWhitelist` check is unaffected.

Kept separate from the dependency commits so it can be ignored in blame.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NWs1vf42msmoJ21ABvQMpm
2026-08-16 08:56:46 +00:00
Carlos Valente 3f4a919483 chore(deps): upgrade @mantine/hooks to v9
v9 splits useFullscreen into useFullscreenElement (ref-based) and
useFullscreenDocument. NavigationMenu never attached the ref, and v8's
useFullscreen fell back to document.documentElement in that case, so
useFullscreenDocument is a behaviourally identical swap.

Every other hook in use is unaffected. The one other breaking change —
useLocalStorage/useSessionStorage now return `T | undefined` when no
defaultValue is given — does not apply: all nine call sites pass one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NWs1vf42msmoJ21ABvQMpm
2026-08-15 14:49:01 +00:00
Carlos Valente e3f6c8295e chore(deps): update pinned dependencies
Bumps the exact-pinned deps, which caret ranges never pick up:
turbo, @playwright/test, @sentry/vite-plugin, vite-plugin-compression2,
react-icons, @base-ui/react, electron-builder, and the ts-essentials /
rimraf catalog entries. Plus dotenv ^16 -> ^17 (one consumer, `import
'dotenv/config'`) and wait-on ^7 -> ^9 (devDep, one CLI call).

No CI change needed for playwright: the workflow already derives its
browser cache key from `pnpm ls @playwright/test`.

Deliberately does NOT run a blanket `pnpm update -r`. Refreshing caret
drift pulls @types/express-serve-static-core 5.0.6 -> 5.1.3, which widens
`req.params` values to `string | string[]` and fails typecheck in 39
places across 8 routers. The widening is correct — express 5 can produce
array params — but adopting it is a source change of its own, and pnpm 11
cannot pin around it (overrides in package.json are silently ignored, and
the pnpm-workspace.yaml equivalent does not re-resolve).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NWs1vf42msmoJ21ABvQMpm
2026-08-15 14:45:51 +00:00
Carlos Valente c67dfb17e7 chore(deps): bump oxlint with matching tsgolint peer
oxlint was ranged ^1.57.0, so a routine `pnpm update` would have resolved
1.78.0 on its own — which declares `peerDependencies: { oxlint-tsgolint:
">=7.0.2001" }` against a root pin of ^0.17.4, breaking the --type-aware
lint every workspace runs. Move both together.

oxlint-tsgolint jumped from 0.25.0 to 7.0.2xxx to track the TypeScript-Go
version; 7.0.2001 lines up with the catalog's typescript 7.0.2.

Lint still passes with 0 errors. Warning counts rise (server 64 -> 129)
because the newer oxlint ships more rules; 64 of the 65 new server
warnings are vitest(require-mock-type-parameters) in test files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NWs1vf42msmoJ21ABvQMpm
2026-08-15 14:36:41 +00:00
Carlos Valente 8b4fd32048 chore(deps): dedupe vite and esbuild across the monorepo
vitest 4.0.17 declared `vite: ^6 || ^7`, so it pulled a second vite@7.3.1
into the install alongside the vite@8.0.1 the client builds with, meaning
tests transformed through a different vite than the build. vitest 4.1
widened the range to include ^8, collapsing both to a single vite@8.2.1.

Likewise the tree carried three esbuild copies (0.24.2 from the server,
0.25.5 via tsx, 0.27.4 via vite). vite 8 takes esbuild as a peer and
tsx 4.23 deps ~0.28, so aligning the server on ^0.28 leaves one copy.

Also pin @types/node to the catalog in the client and utils workspaces.
Neither declared it, so transitive @types/* packages resolved it to
25.4.0 and the client was type-checked against Node 25 typings while the
app ships on Node 22 (engines.node ~22).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NWs1vf42msmoJ21ABvQMpm
2026-08-15 14:34:36 +00:00
14 changed files with 1220 additions and 2319 deletions
+7 -6
View File
@@ -4,7 +4,7 @@
"private": true,
"type": "module",
"dependencies": {
"@base-ui/react": "1.6.0",
"@base-ui/react": "1.7.0",
"@codemirror/commands": "^6.0.0",
"@codemirror/lang-css": "^6.0.0",
"@codemirror/state": "^6.0.0",
@@ -13,7 +13,7 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fontsource/open-sans": "^5.2.7",
"@mantine/hooks": "^8.3.7",
"@mantine/hooks": "^9.5.1",
"@sentry/react": "^10.59.0",
"@table-nav/react": "^0.0.7",
"@tanstack/react-query": "^5.101.0",
@@ -29,7 +29,7 @@
"react-dom": "^19.2.7",
"react-fast-compare": "^3.2.2",
"react-hook-form": "^7.80.0",
"react-icons": "5.6.0",
"react-icons": "5.7.0",
"react-router": "^8.0.1",
"react-virtuoso": "^4.18.7",
"zustand": "^5.0.14"
@@ -60,7 +60,8 @@
]
},
"devDependencies": {
"@sentry/vite-plugin": "5.1.1",
"@sentry/vite-plugin": "5.4.0",
"@types/node": "catalog:",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",
@@ -72,8 +73,8 @@
"ontime-utils": "workspace:*",
"sass": "^1.57.1",
"typescript": "catalog:",
"vite": "8.0.1",
"vite-plugin-compression2": "2.5.1",
"vite": "8.2.1",
"vite-plugin-compression2": "2.5.3",
"vite-plugin-svgr": "4.5.0",
"vitest": "catalog:"
}
@@ -1,5 +1,5 @@
import { Dialog } from '@base-ui/react/dialog';
import { useDisclosure, useFullscreen } from '@mantine/hooks';
import { useDisclosure, useFullscreenDocument } from '@mantine/hooks';
import { memo } from 'react';
import { IoClose, IoContract, IoExpand, IoLockClosedOutline, IoSwapVertical } from 'react-icons/io5';
import { LuCoffee } from 'react-icons/lu';
@@ -33,7 +33,7 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
const isSmallScreen = useIsSmallScreen();
const [isRenameOpen, handlers] = useDisclosure(false);
const { fullscreen, toggle } = useFullscreen();
const { fullscreen, toggle } = useFullscreenDocument();
const { mirror, toggleMirror } = useViewOptionsStore();
const { keepAwake, toggleKeepAwake } = useKeepAwakeOptions();
const location = useLocation();
+2 -2
View File
@@ -14,8 +14,8 @@
"main": "src/main.js",
"devDependencies": {
"electron": "38.2.1",
"electron-builder": "26.9.1",
"wait-on": "^7.2.0"
"electron-builder": "26.15.3",
"wait-on": "^9.0.0"
},
"scripts": {
"dev:electron": "wait-on http://localhost:3000 && cross-env NODE_ENV=development electron .",
+3 -3
View File
@@ -10,7 +10,7 @@
"cookie": "1.0.2",
"cookie-parser": "1.4.7",
"cors": "2.8.6",
"dotenv": "^16.0.1",
"dotenv": "^17.0.0",
"express": "5.2.1",
"express-static-gzip": "3.0.1",
"express-validator": "7.3.2",
@@ -31,11 +31,11 @@
"@types/multer": "2.1.0",
"@types/node": "catalog:",
"@types/ws": "^8.5.10",
"esbuild": "^0.24.0",
"esbuild": "^0.28.0",
"ontime-types": "workspace:*",
"server-timing": "^3.3.3",
"ts-essentials": "catalog:",
"tsx": "^4.19.2",
"tsx": "^4.23.12",
"typescript": "catalog:",
"vitest": "catalog:"
},
@@ -353,19 +353,19 @@ export function isLoadedPlayable(loadedEventId: EntryId, rundown: Readonly<Rundo
/** List of event properties which do not need the rundown to be regenerated */
enum RegenerateWhitelist {
'id', // adding it for completeness, users cannot change ID
'type', // adding it for completeness, users cannot change ID
'cue',
'title',
'note',
'endAction',
'timerType',
'countToEnd',
'colour',
'timeWarning',
'timeDanger',
'custom',
'triggers',
id, // adding it for completeness, users cannot change ID
type, // adding it for completeness, users cannot change ID
cue,
title,
note,
endAction,
timerType,
countToEnd,
colour,
timeWarning,
timeDanger,
custom,
triggers,
}
/**
+21
View File
@@ -0,0 +1,21 @@
import type { NextFunction, Request, RequestHandler, Response } from 'express';
import { hasPassword, hashedPassword } from '../api-data/session/session.service.js';
/**
* Wraps the app authenticate middleware with support for the Authorization header.
* MCP clients conventionally authenticate with `Authorization: Bearer <token>`
* rather than cookies or query params; any other request falls through to the
* app middleware, keeping the behaviour of the shared middleware untouched.
*/
export function makeMcpAuthenticate(fallback: RequestHandler): RequestHandler {
return function mcpAuthenticate(req: Request, res: Response, next: NextFunction) {
if (hasPassword) {
const authHeader = req.headers.authorization;
if (authHeader?.startsWith('Bearer ') && authHeader.slice(7) === hashedPassword) {
return next();
}
}
return fallback(req, res, next);
};
}
+3 -6
View File
@@ -20,12 +20,9 @@ export function createMcpServer(): Server {
{ capabilities: { tools: {}, prompts: {}, resources: {} } },
);
server.setRequestHandler(
ListToolsRequestSchema,
async (): Promise<ListToolsResult> => ({
tools: TOOL_DEFINITIONS as unknown as ListToolsResult['tools'],
}),
);
server.setRequestHandler(ListToolsRequestSchema, async (): Promise<ListToolsResult> => ({
tools: TOOL_DEFINITIONS as unknown as ListToolsResult['tools'],
}));
server.setRequestHandler(CallToolRequestSchema, async (request): Promise<CallToolResult> => {
const { name, arguments: args = {} } = request.params;
+2 -1
View File
@@ -13,6 +13,7 @@ import { socket } from './adapters/WebsocketAdapter.js';
// Import Routers
import { appRouter } from './api-data/index.js';
import { integrationRouter } from './api-integration/integration.router.js';
import { makeMcpAuthenticate } from './api-mcp/mcp.auth.js';
import { mcpRouter } from './api-mcp/mcp.router.js';
import { flushPendingWrites, getDataProvider } from './classes/data-provider/DataProvider.js';
// Services
@@ -101,7 +102,7 @@ app.get(`${prefix}/ready`, (_req, res) => {
app.use(`${prefix}/login`, loginRouter); // router for login flow
app.use(`${prefix}/data`, authenticate, appRouter); // router for application data
app.use(`${prefix}/api`, authenticate, integrationRouter); // router for integrations
app.use(`${prefix}/mcp`, authenticate, mcpRouter); // router for MCP agent integration
app.use(`${prefix}/mcp`, makeMcpAuthenticate(authenticate), mcpRouter); // router for MCP agent integration
// serve static external files
app.use(
@@ -1,35 +1,6 @@
import type { IncomingMessage } from 'node:http';
import { describe, expect, it } from 'vitest';
import type { NextFunction, Request, Response } from 'express';
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../api-data/session/session.service.js', () => ({
hasPassword: true,
hashedPassword: 'valid-token',
}));
import {
authenticateSocket,
isPublicAssetRequest,
makeAuthenticateMiddleware,
} from '../authenticate.js';
function makeResponse() {
return {
redirect: vi.fn(),
send: vi.fn(),
status: vi.fn().mockReturnThis(),
} as unknown as Response;
}
function makeHeadersWithFailingAuthorization(cookie?: string) {
return {
cookie,
get authorization(): never {
throw new Error('Authorization header should not be read');
},
};
}
import { isPublicAssetRequest } from '../authenticate.js';
describe('isPublicAssetRequest()', () => {
it('allows root public assets without a prefix', () => {
@@ -47,102 +18,3 @@ describe('isPublicAssetRequest()', () => {
expect(isPublicAssetRequest('/backstage', '')).toBe(false);
});
});
describe('bearer authentication', () => {
const next = vi.fn() as NextFunction;
beforeEach(() => {
next.mockClear();
});
it('prioritises cookie authentication for API requests', () => {
const { authenticate } = makeAuthenticateMiddleware('');
const req = {
cookies: { token: JSON.stringify({ token: 'valid-token' }) },
headers: makeHeadersWithFailingAuthorization(),
query: {},
} as unknown as Request;
expect(() => authenticate(req, makeResponse(), next)).not.toThrow();
expect(next).toHaveBeenCalledOnce();
});
it('prioritises cookie authentication for redirecting routes', () => {
const { authenticateAndRedirect } = makeAuthenticateMiddleware('');
const req = {
cookies: { token: JSON.stringify({ token: 'valid-token' }) },
headers: makeHeadersWithFailingAuthorization(),
originalUrl: '/external/image.png',
query: {},
} as unknown as Request;
expect(() => authenticateAndRedirect(req, makeResponse(), next)).not.toThrow();
expect(next).toHaveBeenCalledOnce();
});
it('prioritises cookie authentication for WebSocket handshakes', () => {
const cookie = `token=${encodeURIComponent(JSON.stringify({ token: 'valid-token' }))}`;
const req = { headers: makeHeadersWithFailingAuthorization(cookie) } as IncomingMessage;
expect(() => authenticateSocket({} as never, req, next)).not.toThrow();
expect(next).toHaveBeenCalledOnce();
});
it('authenticates API requests with a bearer token', () => {
const { authenticate } = makeAuthenticateMiddleware('');
const req = {
cookies: {},
headers: { authorization: 'Bearer valid-token' },
query: {},
} as unknown as Request;
const res = makeResponse();
authenticate(req, res, next);
expect(next).toHaveBeenCalledOnce();
expect(res.status).not.toHaveBeenCalled();
});
it('authenticates redirecting routes with a bearer token', () => {
const { authenticateAndRedirect } = makeAuthenticateMiddleware('/stage');
const req = {
cookies: {},
headers: { authorization: 'Bearer valid-token' },
originalUrl: '/stage/external/image.png',
query: {},
} as unknown as Request;
const res = makeResponse();
authenticateAndRedirect(req, res, next);
expect(next).toHaveBeenCalledOnce();
expect(res.redirect).not.toHaveBeenCalled();
});
it('authenticates WebSocket handshakes with a bearer token', () => {
const req = {
headers: { authorization: 'Bearer valid-token' },
} as IncomingMessage;
authenticateSocket({} as never, req, next);
expect(next).toHaveBeenCalledOnce();
expect(next).toHaveBeenCalledWith();
});
it('rejects an invalid bearer token', () => {
const { authenticate } = makeAuthenticateMiddleware('');
const req = {
cookies: {},
headers: { authorization: 'Bearer invalid-token' },
query: {},
} as unknown as Request;
const res = makeResponse();
authenticate(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(401);
expect(res.send).toHaveBeenCalledWith('Unauthorized');
});
});
+48 -55
View File
@@ -77,16 +77,17 @@ export function makeAuthenticateMiddleware(prefix: string) {
const loginRedirectBase = `${prefix}/login?redirect=`;
function authenticate(req: Request, res: Response, next: NextFunction) {
if (getTokenFromCookies(req.cookies) === hashedPassword) {
return next();
if (req.query.token) {
if (req.query.token === hashedPassword) {
return next();
}
}
if (getTokenFromAuthHeader(req.headers.authorization) === hashedPassword) {
return next();
}
if (getTokenFromParams(req.query) === hashedPassword) {
return next();
if (req.cookies?.token) {
const tokenFromCookie = getTokenFromCookie(req.cookies.token);
if (tokenFromCookie === hashedPassword) {
return next();
}
}
res.status(401).send('Unauthorized');
@@ -104,17 +105,17 @@ export function makeAuthenticateMiddleware(prefix: string) {
return next();
}
if (getTokenFromCookies(req.cookies) === hashedPassword) {
return next();
}
if (getTokenFromAuthHeader(req.headers.authorization) === hashedPassword) {
return next();
// we expect the token to be in the cookies
if (req.cookies?.token) {
const tokenFromCookie = getTokenFromCookie(req.cookies.token);
if (tokenFromCookie === hashedPassword) {
return next();
}
}
// we use query params for generating authenticated URLs and for clients like the companion module
// if the user gives is a token in the query params, we set the cookie to be used in further requests
if (getTokenFromParams(req.query) === hashedPassword) {
if (req.query.token === hashedPassword) {
if (hashedPassword !== undefined) {
setSessionCookie(res, hashedPassword, prefix);
}
@@ -135,16 +136,33 @@ export function authenticateSocket(_ws: WebSocket, req: IncomingMessage, next: (
return next();
}
if (getTokenFromCookies(req.headers.cookie) === hashedPassword) {
// check if the token is in the cookie
const cookieString = req.headers.cookie;
if (typeof cookieString === 'string') {
const cookies = parseCookie(cookieString);
if (cookies.token) {
const token = getTokenFromCookie(cookies.token);
if (token === hashedPassword) {
return next();
}
}
}
// check if token is in the params - simple string check first
const urlString = req.url || '';
if (urlString.includes(`token=${hashedPassword}`)) {
return next();
}
if (getTokenFromAuthHeader(req.headers.authorization) === hashedPassword) {
return next();
}
if (getTokenFromParams(req.url, req.headers.host) === hashedPassword) {
return next();
// fallback to full URL parsing for other formats
try {
const url = new URL(urlString, `http://${req.headers.host}`);
const token = url.searchParams.get('token');
if (token === hashedPassword) {
return next();
}
} catch (_) {
// ignore URL parsing errors
}
return next(new Error('Unauthorized'));
@@ -163,18 +181,19 @@ function setSessionCookie(res: Response, token: string, prefix: string) {
});
}
function getTokenFromCookies(cookies: string | Record<string, unknown> | undefined): string | undefined {
const cookieContents = typeof cookies === 'string' ? parseCookie(cookies).token : cookies?.token;
if (typeof cookieContents !== 'string') {
return undefined;
}
// Fast path: avoid JSON parsing when the expected token can be found directly
/**
* When calling this function we already know a cookie called 'token' exists
* And want to extract its value
*/
function getTokenFromCookie(cookieContents: string): string | undefined {
// Fast path: check if the hashed password is directly in the cookie string
// This avoids JSON parsing for the common case
const cookieTokenString = '"token":"' + hashedPassword + '}"';
if (cookieTokenString && cookieContents.includes(cookieTokenString)) {
return hashedPassword;
}
// Fallback to JSON parsing for other cases or validation
try {
const cookie = JSON.parse(cookieContents);
if (cookie && typeof cookie.token === 'string') {
@@ -184,29 +203,3 @@ function getTokenFromCookies(cookies: string | Record<string, unknown> | undefin
// no error handling to do here
}
}
function getTokenFromAuthHeader(authorization: string | undefined): string | undefined {
if (authorization?.startsWith('Bearer ')) {
return authorization.slice(7);
}
}
function getTokenFromParams(
params: string | Record<string, unknown> | undefined,
host?: string,
): string | undefined {
if (typeof params !== 'string') {
return typeof params?.token === 'string' ? params.token : undefined;
}
// Fast path for WebSocket URLs
if (params.includes(`token=${hashedPassword}`)) {
return hashedPassword;
}
try {
return new URL(params, `http://${host}`).searchParams.get('token') ?? undefined;
} catch (_) {
return undefined;
}
}
+5 -5
View File
@@ -41,14 +41,14 @@
"format:check": "oxfmt --check"
},
"devDependencies": {
"@playwright/test": "1.60.0",
"@playwright/test": "1.62.1",
"@types/node": "catalog:",
"cross-env": "^7.0.3",
"oxfmt": "^0.42.0",
"oxlint": "^1.57.0",
"oxlint-tsgolint": "^0.17.4",
"oxfmt": "^0.63.0",
"oxlint": "^1.78.0",
"oxlint-tsgolint": "^7.0.2001",
"rimraf": "catalog:",
"turbo": "2.8.20",
"turbo": "2.10.10",
"typescript": "catalog:"
},
"packageManager": "pnpm@11.1.2+sha512.415a1cc25974731e75455c1468371be74c5aa5fb7621b50d4056d222451609f11412f23fd602e6169f1e060466641f798597e1be961a10688836a67b16569499",
+1
View File
@@ -14,6 +14,7 @@
"nanoid": "^6.0.0"
},
"devDependencies": {
"@types/node": "catalog:",
"ontime-types": "workspace:*",
"typescript": "catalog:",
"vitest": "catalog:"
+1108 -2093
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -4,10 +4,10 @@ packages:
catalog:
'@types/node': 22.19.11
rimraf: 6.0.1
ts-essentials: 10.1.1
rimraf: 6.1.3
ts-essentials: 10.2.1
typescript: 7.0.2
vitest: 4.0.17
vitest: 4.1.10
allowBuilds:
'@parcel/watcher': true