From a8bfc95d1c2bd24577a23bd1695cbe0d7bc9a29d Mon Sep 17 00:00:00 2001 From: Rokojori Date: Fri, 17 Jul 2026 13:37:48 +0200 Subject: [PATCH] Connector Rewrite --- .gitmodules | 3 + electron/main.ts | 37 -- source/auth-connector | 1 + source/server/index.ts | 5 +- source/server/middleware/auth.ts | 127 ----- source/server/projectAccess.ts | 14 +- source/server/routes/files.ts | 16 +- source/server/routes/groups.ts | 2 +- source/server/routes/layout.ts | 6 +- source/server/routes/projects.ts | 14 +- source/server/routes/rojos.ts | 6 +- source/server/routes/userSettings.ts | 6 +- tsconfig.ts-node.json | 6 +- workspace/_assets_/nav-data.js | 3 +- workspace/boards/tasks.html | 98 +++- .../guides/writing-backend-routes/index.html | 18 +- .../2026/07-July/16-Wednesday/index.html | 65 +++ workspace/history/index.html | 2 +- workspace/outline/auth-connector-rewrite.html | 512 ++++++++++++++++++ workspace/outline/index.html | 25 +- 20 files changed, 740 insertions(+), 226 deletions(-) create mode 160000 source/auth-connector delete mode 100644 source/server/middleware/auth.ts create mode 100644 workspace/outline/auth-connector-rewrite.html diff --git a/.gitmodules b/.gitmodules index a6e310a..3c48440 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,6 @@ [submodule "src/library-ts"] path = source/library-ts url = git@development.rokojori.com:Josef/library-ts.git +[submodule "source/auth-connector"] + path = source/auth-connector + url = git@community.rokojori.com:Rokojori/rokojori-auth-connector.git diff --git a/electron/main.ts b/electron/main.ts index e2d96f7..aeca60b 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -61,18 +61,6 @@ function postJson( url: string, body: unknown ): Promise { } ); } -async function refreshTokens( refreshToken: string ): Promise { - try { - const result = await postJson( `${AUTH_HOST}/api/auth/refresh`, { refreshToken } ) as Record; - if ( result.accessToken && result.refreshToken ) { - return { accessToken: result.accessToken as string, refreshToken: result.refreshToken as string }; - } - return null; - } catch { - return null; - } -} - function registerHeaderInjector( getToken: () => string | null ): void { session.defaultSession.webRequest.onBeforeSendHeaders( { urls: [ `http://localhost:${PORT}/*` ] }, @@ -132,31 +120,6 @@ function createMainWindow(): void { } } ); - // Intercept the browser-cookie refresh-session redirect and handle it ourselves - mainWindow.webContents.on( 'will-redirect', async ( event, url ) => { - if ( url.includes( '/api/auth/refresh-session' ) ) { - event.preventDefault(); - const redirectParam = new URL( url ).searchParams.get( 'redirect' ); - const fallback = `http://localhost:${PORT}/`; - - if ( currentTokens ) { - const fresh = await refreshTokens( currentTokens.refreshToken ); - if ( fresh ) { - currentTokens = fresh; - saveTokens( fresh ); - mainWindow?.loadURL( redirectParam ?? fallback ); - return; - } - } - - // Refresh failed — go back to login - clearTokens(); - currentTokens = null; - mainWindow?.close(); - createLoginWindow(); - } - } ); - mainWindow.on( 'closed', () => { mainWindow = null; } ); } diff --git a/source/auth-connector b/source/auth-connector new file mode 160000 index 0000000..73fe3a3 --- /dev/null +++ b/source/auth-connector @@ -0,0 +1 @@ +Subproject commit 73fe3a3c9a87070f9e8ecf9682caba9d77de3f6b diff --git a/source/server/index.ts b/source/server/index.ts index 58e725f..b6a7d3d 100644 --- a/source/server/index.ts +++ b/source/server/index.ts @@ -2,7 +2,7 @@ import express from 'express'; import cookieParser from 'cookie-parser'; import path from 'path'; import { ROOT } from './rootDir'; -import { jwtMiddleware, requireAuth } from './middleware/auth'; +import { jwtMiddleware, requireAuth } from 'auth-connector/server/auth'; import projectsRouter from './routes/projects'; import filesRouter from './routes/files'; import localesRouter from './routes/locales'; @@ -18,6 +18,7 @@ generateLocales(); const app = express(); app.use( '/api/deploy', deployRouter ); +app.set( 'trust proxy', 1 ); app.use( express.json() ); app.use( express.text( { type: 'text/plain' } ) ); app.use( cookieParser() ); @@ -32,7 +33,7 @@ app.use( '/api/layout', layoutRouter ); app.use( '/api/rojos', rojosRouter ); app.use( '/api/user/settings', userSettingsRouter ); -app.get( '/api/auth/me', requireAuth, ( req, res ) => res.json( req.user ) ); +app.get( '/api/auth/me', requireAuth, ( req, res ) => res.json( req.auth ) ); app.get( '/edit', ( _req, res ) => res.redirect( '/editor.html' ) ); diff --git a/source/server/middleware/auth.ts b/source/server/middleware/auth.ts deleted file mode 100644 index 989ae09..0000000 --- a/source/server/middleware/auth.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { Request, Response, NextFunction } from 'express'; -import jwt from 'jsonwebtoken'; - -const AUTH_HOST = process.env.AUTH_HOST ?? 'https://account.rokojori.com'; -const AUTH_INTERNAL_HOST = process.env.AUTH_INTERNAL_HOST ?? AUTH_HOST; -const JWT_SECRET = process.env.JWT_SECRET ?? ''; -const COOKIE_DOMAIN = process.env.COOKIE_DOMAIN ?? '.rokojori.com'; - -export interface JwtUser { - userId: string; - email: string; - roles: string[]; - products: string[]; - settings: Record; -} - -declare global { - namespace Express { - interface Request { - user?: JwtUser; - } - } -} - -function extractToken( req: Request ): string | undefined { - const cookie = req.cookies?.accessToken as string | undefined; - if ( cookie ) return cookie; - const header = req.headers.authorization; - if ( header?.startsWith( 'Bearer ' ) ) return header.slice( 7 ); - return undefined; -} - -function isApiRequest( req: Request ): boolean { - return req.path.startsWith( '/api/' ); -} - -function cookieOpts( maxAge: number ) { - return { - domain: COOKIE_DOMAIN, - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax' as const, - path: '/', - maxAge - }; -} - -interface RefreshResult { - accessToken: string; - refreshToken: string; -} - -async function tryRefresh( refreshToken: string ): Promise { - const url = `${AUTH_INTERNAL_HOST}/api/auth/refresh`; - console.log( '[auth] tryRefresh →', url ); - try { - const r = await fetch( url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify( { refreshToken } ) - } ); - console.log( '[auth] tryRefresh status:', r.status ); - if ( !r.ok ) { - const body = await r.text(); - console.log( '[auth] tryRefresh error body:', body ); - return null; - } - const data = await r.json() as Partial; - if ( !data.accessToken || !data.refreshToken ) { - console.log( '[auth] tryRefresh missing tokens in response:', Object.keys( data ) ); - return null; - } - console.log( '[auth] tryRefresh succeeded' ); - return { accessToken: data.accessToken, refreshToken: data.refreshToken }; - } catch ( err ) { - console.log( '[auth] tryRefresh fetch error:', err ); - return null; - } -} - -export function jwtMiddleware( req: Request, res: Response, next: NextFunction ): void { - const token = extractToken( req ); - - if ( !token ) { next(); return; } - - try { - req.user = jwt.verify( token, JWT_SECRET ) as JwtUser; - next(); - } catch ( err: unknown ) { - if ( !( err instanceof jwt.TokenExpiredError ) ) { next(); return; } - - if ( !isApiRequest( req ) ) { - const redirect = encodeURIComponent( req.protocol + '://' + req.get( 'host' ) + req.originalUrl ); - res.redirect( `${AUTH_HOST}/api/auth/refresh-session?redirect=${redirect}` ); - return; - } - - // API request with expired token — try transparent refresh via refreshToken cookie - console.log( '[auth] expired token on API route:', req.path ); - const refreshToken = req.cookies?.refreshToken as string | undefined; - if ( !refreshToken ) { - console.log( '[auth] no refreshToken cookie — cannot refresh' ); - next(); return; - } - - tryRefresh( refreshToken ).then( result => { - if ( !result ) { - console.log( '[auth] refresh failed, returning 401 for:', req.path ); - next(); return; - } - res.cookie( 'accessToken', result.accessToken, cookieOpts( 60 * 60 * 1000 ) ); - res.cookie( 'refreshToken', result.refreshToken, cookieOpts( 30 * 24 * 60 * 60 * 1000 ) ); - try { - req.user = jwt.verify( result.accessToken, JWT_SECRET ) as JwtUser; - } catch { /* fall through — requireAuth will return 401 */ } - next(); - } ).catch( () => next() ); - } -} - -export function requireAuth( req: Request, res: Response, next: NextFunction ): void { - if ( !req.user ) { - res.status( 401 ).json( { error: 'Not authenticated' } ); - return; - } - next(); -} diff --git a/source/server/projectAccess.ts b/source/server/projectAccess.ts index c3d2e9e..95e6c47 100644 --- a/source/server/projectAccess.ts +++ b/source/server/projectAccess.ts @@ -1,12 +1,12 @@ import { Project, ProjectMember, projects, projectMembers } from './db'; -import { JwtUser } from './middleware/auth'; +import type { AuthPayload } from 'auth-connector/shared/types'; -export function isOwner( project: Project, user: JwtUser ): boolean +export function isOwner( project: Project, user: AuthPayload ): boolean { return project.owner_id === user.userId; } -function memberMatchesUser( member: ProjectMember, user: JwtUser ): boolean +function memberMatchesUser( member: ProjectMember, user: AuthPayload ): boolean { if ( member.member_type !== 'user' ) return false; // Interim (Option B): member_id stores the email address. @@ -14,17 +14,17 @@ function memberMatchesUser( member: ProjectMember, user: JwtUser ): boolean return member.member_id === user.email; } -export function getMemberRole( members: ProjectMember[], user: JwtUser ): string | null +export function getMemberRole( members: ProjectMember[], user: AuthPayload ): string | null { return members.find( m => memberMatchesUser( m, user ) )?.role ?? null; } -export function canView( project: Project, members: ProjectMember[], user: JwtUser ): boolean +export function canView( project: Project, members: ProjectMember[], user: AuthPayload ): boolean { return isOwner( project, user ) || getMemberRole( members, user ) !== null; } -export function canEdit( project: Project, members: ProjectMember[], user: JwtUser ): boolean +export function canEdit( project: Project, members: ProjectMember[], user: AuthPayload ): boolean { if ( isOwner( project, user ) ) return true; const role = getMemberRole( members, user ); @@ -33,7 +33,7 @@ export function canEdit( project: Project, members: ProjectMember[], user: JwtUs export interface AccessDenial { status: number; error: string; } -export function checkAccess( projectId: string, user: JwtUser, mode: 'view' | 'edit' ): AccessDenial | null +export function checkAccess( projectId: string, user: AuthPayload, mode: 'view' | 'edit' ): AccessDenial | null { const project = projects.findById( projectId ); if ( !project ) return { status: 404, error: 'Not found' }; diff --git a/source/server/routes/files.ts b/source/server/routes/files.ts index 7134c5a..cdd3311 100644 --- a/source/server/routes/files.ts +++ b/source/server/routes/files.ts @@ -1,6 +1,6 @@ import { Router } from 'express'; import { getFileTree, readProjectFile, writeProjectFile, createProjectFile, createProjectDirectory, renameProjectEntry, deleteProjectEntry } from '../storage'; -import { requireAuth } from '../middleware/auth'; +import { requireAuth } from 'auth-connector/server/auth'; import { checkAccess } from '../projectAccess'; const router = Router(); @@ -8,14 +8,14 @@ router.use( requireAuth ); router.get( '/:projectId/tree', ( req, res ) => { - const denied = checkAccess( req.params.projectId, req.user!, 'view' ); + const denied = checkAccess( req.params.projectId, req.auth!, 'view' ); if ( denied ) { res.status( denied.status ).json( { error: denied.error } ); return; } res.json( getFileTree( req.params.projectId ) ); } ); router.get( '/:projectId/*', ( req, res ) => { - const denied = checkAccess( req.params.projectId, req.user!, 'view' ); + const denied = checkAccess( req.params.projectId, req.auth!, 'view' ); if ( denied ) { res.status( denied.status ).json( { error: denied.error } ); return; } const filePath = ( req.params as Record )[ 0 ]; const content = readProjectFile( req.params.projectId, filePath ); @@ -25,7 +25,7 @@ router.get( '/:projectId/*', ( req, res ) => router.post( '/:projectId/create-file', ( req, res ) => { - const denied = checkAccess( req.params.projectId, req.user!, 'edit' ); + const denied = checkAccess( req.params.projectId, req.auth!, 'edit' ); if ( denied ) { res.status( denied.status ).json( { error: denied.error } ); return; } const { path: filePath } = req.body as { path: string }; if ( !filePath ) { res.status( 400 ).json( { error: 'path required' } ); return; } @@ -36,7 +36,7 @@ router.post( '/:projectId/create-file', ( req, res ) => router.post( '/:projectId/create-directory', ( req, res ) => { - const denied = checkAccess( req.params.projectId, req.user!, 'edit' ); + const denied = checkAccess( req.params.projectId, req.auth!, 'edit' ); if ( denied ) { res.status( denied.status ).json( { error: denied.error } ); return; } const { path: dirPath } = req.body as { path: string }; if ( !dirPath ) { res.status( 400 ).json( { error: 'path required' } ); return; } @@ -47,7 +47,7 @@ router.post( '/:projectId/create-directory', ( req, res ) => router.post( '/:projectId/rename', ( req, res ) => { - const denied = checkAccess( req.params.projectId, req.user!, 'edit' ); + const denied = checkAccess( req.params.projectId, req.auth!, 'edit' ); if ( denied ) { res.status( denied.status ).json( { error: denied.error } ); return; } const { path: oldPath, newName } = req.body as { path: string; newName: string }; if ( !oldPath || !newName ) { res.status( 400 ).json( { error: 'path and newName required' } ); return; } @@ -58,7 +58,7 @@ router.post( '/:projectId/rename', ( req, res ) => router.post( '/:projectId/delete', ( req, res ) => { - const denied = checkAccess( req.params.projectId, req.user!, 'edit' ); + const denied = checkAccess( req.params.projectId, req.auth!, 'edit' ); if ( denied ) { res.status( denied.status ).json( { error: denied.error } ); return; } const { path: targetPath } = req.body as { path: string }; if ( !targetPath ) { res.status( 400 ).json( { error: 'path required' } ); return; } @@ -69,7 +69,7 @@ router.post( '/:projectId/delete', ( req, res ) => router.put( '/:projectId/*', ( req, res ) => { - const denied = checkAccess( req.params.projectId, req.user!, 'edit' ); + const denied = checkAccess( req.params.projectId, req.auth!, 'edit' ); if ( denied ) { res.status( denied.status ).json( { error: denied.error } ); return; } const filePath = ( req.params as Record )[ 0 ]; if ( typeof req.body !== 'string' ) { res.status( 400 ).json( { error: 'Content must be text' } ); return; } diff --git a/source/server/routes/groups.ts b/source/server/routes/groups.ts index f5acb6c..d292daf 100644 --- a/source/server/routes/groups.ts +++ b/source/server/routes/groups.ts @@ -1,6 +1,6 @@ import { Router } from 'express'; import { groups, groupMembers } from '../db'; -import { requireAuth } from '../middleware/auth'; +import { requireAuth } from 'auth-connector/server/auth'; const router = Router(); router.use(requireAuth); diff --git a/source/server/routes/layout.ts b/source/server/routes/layout.ts index caf8aaf..a59b653 100644 --- a/source/server/routes/layout.ts +++ b/source/server/routes/layout.ts @@ -1,7 +1,7 @@ import { Router } from 'express'; import fs from 'fs'; import path from 'path'; -import { requireAuth } from '../middleware/auth'; +import { requireAuth } from 'auth-connector/server/auth'; import { RJLog } from '../../library-ts/node/log/RJLog'; import { ROOT } from '../rootDir'; @@ -23,7 +23,7 @@ router.get( '/', ( req, res ) => { const deviceId = req.query.deviceId as string; if ( !deviceId ) { res.json( null ); return; } - const fp = layoutFilePath( req.user!.userId, deviceId ); + const fp = layoutFilePath( req.auth!.userId, deviceId ); if ( !fs.existsSync( fp ) ) { res.json( null ); return; } try { res.json( JSON.parse( fs.readFileSync( fp, 'utf8' ) ) ); } catch { res.json( null ); } @@ -33,7 +33,7 @@ router.put( '/', ( req, res ) => { const deviceId = req.query.deviceId as string; if ( !deviceId ) { res.status( 400 ).json( { error: 'Missing deviceId' } ); return; } - const fp = layoutFilePath( req.user!.userId, deviceId ); + const fp = layoutFilePath( req.auth!.userId, deviceId ); try { fs.writeFileSync( fp, JSON.stringify( req.body ), 'utf8' ); diff --git a/source/server/routes/projects.ts b/source/server/routes/projects.ts index da00486..e071926 100644 --- a/source/server/routes/projects.ts +++ b/source/server/routes/projects.ts @@ -1,6 +1,6 @@ import { Router } from 'express'; import { projects, projectMembers } from '../db'; -import { requireAuth } from '../middleware/auth'; +import { requireAuth } from 'auth-connector/server/auth'; import { createProjectStorage } from '../storage'; import { isOwner, canView, getMemberRole } from '../projectAccess'; @@ -9,7 +9,7 @@ router.use( requireAuth ); router.get( '/', ( req, res ) => { - const user = req.user!; + const user = req.auth!; const visible = projects.all().filter( p => { if ( isOwner( p, user ) ) return true; @@ -22,7 +22,7 @@ router.post( '/', ( req, res ) => { const { name } = req.body as { name?: string }; if ( !name ) { res.status( 400 ).json( { error: 'Name required' } ); return; } - const project = projects.create( { name, owner_id: req.user!.userId } ); + const project = projects.create( { name, owner_id: req.auth!.userId } ); createProjectStorage( project.id ); res.json( project ); } ); @@ -31,7 +31,7 @@ router.delete( '/:id', ( req, res ) => { const project = projects.findById( req.params.id ); if ( !project ) { res.status( 404 ).json( { error: 'Not found' } ); return; } - if ( !isOwner( project, req.user! ) ) { res.status( 403 ).json( { error: 'Forbidden' } ); return; } + if ( !isOwner( project, req.auth! ) ) { res.status( 403 ).json( { error: 'Forbidden' } ); return; } projects.delete( req.params.id ); res.json( { ok: true } ); } ); @@ -41,7 +41,7 @@ router.get( '/:id/members', ( req, res ) => const project = projects.findById( req.params.id ); if ( !project ) { res.status( 404 ).json( { error: 'Not found' } ); return; } const members = projectMembers.forProject( req.params.id ); - if ( !canView( project, members, req.user! ) ) { res.status( 403 ).json( { error: 'Forbidden' } ); return; } + if ( !canView( project, members, req.auth! ) ) { res.status( 403 ).json( { error: 'Forbidden' } ); return; } res.json( members ); } ); @@ -49,7 +49,7 @@ router.post( '/:id/members', ( req, res ) => { const project = projects.findById( req.params.id ); if ( !project ) { res.status( 404 ).json( { error: 'Not found' } ); return; } - if ( !isOwner( project, req.user! ) ) { res.status( 403 ).json( { error: 'Forbidden' } ); return; } + if ( !isOwner( project, req.auth! ) ) { res.status( 403 ).json( { error: 'Forbidden' } ); return; } const { email, role } = req.body as { email?: string; role?: string }; if ( !email ) { res.status( 400 ).json( { error: 'email required' } ); return; } res.json( projectMembers.add( { project_id: req.params.id, member_type: 'user', member_id: email, role: role ?? 'viewer' } ) ); @@ -59,7 +59,7 @@ router.delete( '/:id/members/:memberId', ( req, res ) => { const project = projects.findById( req.params.id ); if ( !project ) { res.status( 404 ).json( { error: 'Not found' } ); return; } - if ( !isOwner( project, req.user! ) ) { res.status( 403 ).json( { error: 'Forbidden' } ); return; } + if ( !isOwner( project, req.auth! ) ) { res.status( 403 ).json( { error: 'Forbidden' } ); return; } projectMembers.remove( req.params.memberId ); res.json( { ok: true } ); } ); diff --git a/source/server/routes/rojos.ts b/source/server/routes/rojos.ts index 46fbd54..dd2aee6 100644 --- a/source/server/routes/rojos.ts +++ b/source/server/routes/rojos.ts @@ -2,7 +2,7 @@ import { Router } from "express"; import fs from "fs"; import path from "path"; import crypto from "crypto"; -import { requireAuth } from "../middleware/auth"; +import { requireAuth } from "auth-connector/server/auth"; import { getAgentStream, updateAgentConversation, AgentConfig } from "../rojos/RojosAgent"; import { readProjectFile, writeProjectFile, createProjectDirectory } from "../storage"; import { checkAccess } from "../projectAccess"; @@ -125,7 +125,7 @@ router.get( "/tunnels/browse", async ( req, res ) => router.get( "/:projectId/list", ( req, res ) => { - const denied = checkAccess( req.params.projectId, req.user!, "view" ); + const denied = checkAccess( req.params.projectId, req.auth!, "view" ); if ( denied ) { res.status( denied.status ).json( { error: denied.error } ); return; } const projectRoot = path.join( STORAGE, req.params.projectId, "root" ); @@ -160,7 +160,7 @@ router.get( "/:projectId/list", ( req, res ) => router.post( "/:projectId/create", ( req, res ) => { - const denied = checkAccess( req.params.projectId, req.user!, "edit" ); + const denied = checkAccess( req.params.projectId, req.auth!, "edit" ); if ( denied ) { res.status( denied.status ).json( { error: denied.error } ); return; } const { parentDir } = req.body as { parentDir?: string }; diff --git a/source/server/routes/userSettings.ts b/source/server/routes/userSettings.ts index 6d173f0..4dfc4bb 100644 --- a/source/server/routes/userSettings.ts +++ b/source/server/routes/userSettings.ts @@ -1,17 +1,17 @@ import { Router } from 'express'; -import { requireAuth } from '../middleware/auth'; +import { requireAuth } from 'auth-connector/server/auth'; import { userSettings } from '../db'; const router = Router(); router.use( requireAuth ); router.get( '/', ( req, res ) => { - res.json( userSettings.forUser( req.user!.userId ) ?? {} ); + res.json( userSettings.forUser( req.auth!.userId ) ?? {} ); } ); router.put( '/', ( req, res ) => { const settings = req.body as Record; - res.json( userSettings.save( req.user!.userId, settings ) ); + res.json( userSettings.save( req.auth!.userId, settings ) ); } ); export default router; diff --git a/tsconfig.ts-node.json b/tsconfig.ts-node.json index 5d1b457..e475351 100644 --- a/tsconfig.ts-node.json +++ b/tsconfig.ts-node.json @@ -1,7 +1,11 @@ { "extends": "./tsconfig.json", "compilerOptions": { - "strictNullChecks": false + "strictNullChecks": false, + "baseUrl": ".", + "paths": { + "auth-connector/*": ["./source/auth-connector/source/*"] + } }, "include": ["source/server/**/*", "source/library-ts/node/**/*"] } diff --git a/workspace/_assets_/nav-data.js b/workspace/_assets_/nav-data.js index 5314f3b..adc9505 100644 --- a/workspace/_assets_/nav-data.js +++ b/workspace/_assets_/nav-data.js @@ -15,7 +15,8 @@ var NAV_DATA = { title: 'Outline', path: 'outline/index.html', children: [ - { title: 'rokojori-auth Restructure', path: 'outline/auth-restructure.html' }, + { title: 'rokojori-auth Restructure', path: 'outline/auth-restructure.html' }, + { title: 'Auth Connector Rewrite', path: 'outline/auth-connector-rewrite.html' }, { title: 'User-Based Local Tunneling', path: 'outline/tunneling.html' }, ] }, diff --git a/workspace/boards/tasks.html b/workspace/boards/tasks.html index e4c25b3..33bc7c8 100644 --- a/workspace/boards/tasks.html +++ b/workspace/boards/tasks.html @@ -22,6 +22,55 @@
To Do
+ + File tree double-click: auto-open or focus existing editor + + When a file is double-clicked in the file tree: + — If an editor panel that can handle the file type is already open and not pinned, + focus that panel's tab and load the file into it. + — If no suitable unpinned editor exists, open a new panel of the correct type + in the active section before loading the file. + Single-click keeps current behaviour (selection only, no open). + + + + + Tab-container: split function broken, panel border update unreliable + + The tab-container split function does not work correctly in its current state. + Additionally, the update and moving of panel borders is not always applied — + borders can appear stuck or misaligned after panel resize or split operations. + + + + + Mobile: editor layout too tall, chat input not reachable + + On mobile the overall editor layout is still too tall — panels overflow the + viewport and the chat input area is pushed out of view even after the + min-height: 0 fix on rojo-chat-panel. Needs a broader mobile layout pass + on the panel/section/editor structure. + + + + + Mobile: nav bar z-index too low on projects / index view + + On mobile the navigation bar on the project list (index) view has insufficient + z-index — it is rendered beneath other elements and the logout button and + other nav items are not clickable. + + + + + Code syntax highlighting in rojo-chat (Highlight.js) + + Code blocks in assistant responses are rendered as plain text inside pre/code tags. + Integrate Highlight.js to apply syntax highlighting after markdown-it renders each + response chunk. Apply highlighting to all code blocks in the assistant bubble. + + + Rojo Character Editor @@ -62,18 +111,6 @@ - - File tree double-click: auto-open or focus existing editor - - When a file is double-clicked in the file tree: - — If an editor panel that can handle the file type is already open and not pinned, - focus that panel's tab and load the file into it. - — If no suitable unpinned editor exists, open a new panel of the correct type - in the active section before loading the file. - Single-click keeps current behaviour (selection only, no open). - - - Remote Projects in Electron @@ -189,6 +226,43 @@
Done
+ + rokojori-auth: page-level token refresh middleware + + Added jwtMiddleware to rokojori-auth index.ts before express.static. + When a page request arrives with an expired accessToken, it redirects to + /api/auth/refresh-session?redirect=<url> which rotates both cookies and + redirects back. Fixes the reload-to-login issue after the 1-hour TTL. + + + + + Tunnel Agent: token refresh on 401, logout, getToken getter + + Three improvements to the Electron Tunnel Agent: + — tryRefreshTokens() calls POST /api/auth/refresh on 401, updates currentTokens, + retries the original request once; falls through to handleLogout() if refresh fails. + — handleLogout() clears tokens, stops all agents, closes main window, opens login. + — TunnelAgent config changed from static token: string to getToken: () => string, + so every WebSocket reconnect picks up the current (possibly refreshed) token + instead of the expired one baked in at connect time. + — Logout button added to window header and tray menu. + + + + + rojo-chat-panel: mobile layout fix + animated thinking indicator + + CSS: added min-height: 0 to rojo-chat-panel and .rcp-history so the history + can shrink in flex on mobile; added overflow: hidden to the panel root. + JS: focus listener calls scrollIntoView after 300ms when the input is focused + (accommodates keyboard animation on mobile). + Replaced static "…" with a cycling animation: ., .., ..., thinking, ., .., ..., + imagining (user-customised frames) at 250ms per frame. Interval cleared and + bubble wiped the moment the first real response chunk arrives. + + + Fix session logout after ~1 hour — transparent token refresh diff --git a/workspace/guides/writing-backend-routes/index.html b/workspace/guides/writing-backend-routes/index.html index f5a662a..09be1e7 100644 --- a/workspace/guides/writing-backend-routes/index.html +++ b/workspace/guides/writing-backend-routes/index.html @@ -38,7 +38,7 @@

1 — Create the route file

Create server/routes/<name>.ts. All route files follow the same skeleton:

import { Router } from 'express';
-import { requireAuth } from '../middleware/auth';
+import { requireAuth } from 'auth-connector/server/auth';
 
 const router = Router();
 router.use( requireAuth );
@@ -57,16 +57,16 @@ export default router;
-

2 — Access the session

+

2 — Access the authenticated user

- The session is typed via a declare module in - server/middleware/auth.ts. The available fields are - req.session.userId and req.session.username, both - string | undefined. After requireAuth they are - guaranteed to be set — use the non-null assertion (!) freely: + req.auth is typed via a declare module in + source/auth-connector/source/server/auth.ts. The available fields are + userId, email, roles, and products. + After requireAuth they are guaranteed to be set — use the non-null + assertion (!) freely:

-
const userId = req.session.userId!;
-const username = req.session.username!;
+
const userId = req.auth!.userId;
+const email  = req.auth!.email;
diff --git a/workspace/history/2026/07-July/16-Wednesday/index.html b/workspace/history/2026/07-July/16-Wednesday/index.html index 6511c3a..506523b 100644 --- a/workspace/history/2026/07-July/16-Wednesday/index.html +++ b/workspace/history/2026/07-July/16-Wednesday/index.html @@ -156,6 +156,71 @@ +
+

Session 4 — Token refresh fixes, Tunnel Agent improvements, chat UI

+ +
+

rokojori-auth: page-level token refresh middleware

+

+ Added a jwtMiddleware to rokojori-auth/source/server/index.ts + before express.static. When a page request (non-/api/) arrives + with an expired accessToken, the middleware redirects to + /api/auth/refresh-session?redirect=<url>, which rotates both + cookies and redirects back. Fixes the reload-to-login loop after the 1-hour TTL. +

+
+ +
+

Roject auth.ts: transparent API token refresh + diagnostic logging

+

+ jwtMiddleware now handles TokenExpiredError on API routes: + reads the refreshToken cookie, calls + POST AUTH_INTERNAL_HOST/api/auth/refresh server-side, sets new cookies + on the response, decodes the new JWT into req.user, and calls + next(). Added AUTH_INTERNAL_HOST env var (defaults to + AUTH_HOST); production .env sets it to + http://localhost:3001 to bypass nginx. Added console.log + diagnostics throughout tryRefresh for journalctl debugging. +

+
+ +
+

Tunnel Agent: token refresh, logout, getToken getter

+

+ Three improvements: +

+
    +
  • tryRefreshTokens() — on any 401 from apiFetch, + calls POST account.rokojori.com/api/auth/refresh, saves new tokens, + retries once. Falls through to handleLogout() if refresh fails.
  • +
  • handleLogout() — stops all agents, clears token file, closes main + window, opens login window. Wired to a ⏻ button in the window header and a + "Sign Out" item in the tray menu.
  • +
  • TunnelAgentConfig.token: string replaced with + getToken: () => string so every WebSocket reconnect calls + the getter and picks up the current (possibly refreshed) access token, + instead of being stuck with the expired one baked in at connect time.
  • +
+
+ +
+

rojo-chat-panel: mobile layout + animated thinking indicator

+

+ CSS: min-height: 0 on rojo-chat-panel and + .rcp-history so the history can shrink in flex; overflow: hidden + on the panel root. Focus listener on the input calls scrollIntoView + after 300 ms to push the input above the mobile keyboard. +

+

+ Replaced the static placeholder with a cycling animation + (frames customised by user) at 250 ms per frame via setInterval. + The interval is cleared and the bubble wiped the moment the first real response + chunk arrives. +

+
+ +
+

Session 3 — Fix session logout after ~1 hour

diff --git a/workspace/history/index.html b/workspace/history/index.html index 2647ebf..11930e0 100644 --- a/workspace/history/index.html +++ b/workspace/history/index.html @@ -21,7 +21,7 @@

Wednesday, 16 July 2026

-

rokojori-tunnel: Phase 1 relay server, Electron Tunnel Agent app, production deployment to tunnel.rokojori.com, streaming relay protocol (res_start/res_data/res_end), Roject browse-tunnels UI, tunnel-backed LLM chat, and client-side chunk animation for smooth streaming appearance. Session 3: fixed session logout after ~1 hour — transparent server-side token refresh in jwtMiddleware for API routes.

+

rokojori-tunnel: Phase 1 relay server, Electron Tunnel Agent, production deployment, streaming protocol, Roject browse-tunnels UI, LLM chat via tunnel, chunk animation. Token refresh fixes across three services: rokojori-auth page-level middleware, Roject transparent API refresh, Tunnel Agent 401 refresh + logout + getToken getter. rojo-chat-panel mobile layout and animated thinking indicator.

diff --git a/workspace/outline/auth-connector-rewrite.html b/workspace/outline/auth-connector-rewrite.html new file mode 100644 index 0000000..7cfe54d --- /dev/null +++ b/workspace/outline/auth-connector-rewrite.html @@ -0,0 +1,512 @@ + + + + + + Auth Connector Rewrite — Roject Outline + + + + +
+ +
+

Plan

+

Auth Connector Rewrite

+

+ Per-repo change list for adopting rokojori-auth-connector as a git + submodule. Read in full before touching any file. Changes are listed in dependency + order within each repo — do them top to bottom. +

+
+ + +
+

Overview

+ +
+

What the connector replaces

+

+ Every service currently has its own copy of auth middleware. They have drifted in + three ways that cause constant breakage: +

+
    +
  • + No transparent refresh — tunnel and + styles only have a bare requireAuth. An expired access token always + returns 401 with no recovery attempt, even when a valid refresh token is present. +
  • +
  • + Wrong property name — roject uses + req.user; tunnel, styles, and rokojori-auth use req.auth. + Routes cannot be safely copied between services. +
  • +
  • + ACCESS_TOKEN_TTL set to 10 seconds — + the single most likely cause of constant auth failures. Every session is broken + 10 seconds after login unless the transparent refresh is working perfectly. +
  • +
+
+ +
+

Repos in scope

+
rokojori-auth   — fix ACCESS_TOKEN_TTL only; does NOT get the submodule
+roject          — add submodule; delete local auth.ts; rename req.user → req.auth (19 places)
+styles          — add submodule; delete local requireAuth.ts + requireAccess.ts; add jwtMiddleware
+tunnel          — add submodule; delete local requireAuth.ts; add jwtMiddleware for API routes
+
+ +
+

Do this first, before any other change

+

+ Fix the TTL bug in rokojori-auth and redeploy. Every other fix depends on + transparent refresh working, and transparent refresh being tested every 10 seconds + instead of every hour makes everything harder to reason about. +

+
+
+ + +
+

rokojori-auth

+ +
+

Does NOT get the submodule

+

+ rokojori-auth is the auth service itself. It cannot call itself for token + refresh, so jwtMiddleware makes no sense here. Its own + requireAuth.ts is correct for guarding its own API routes + (/api/auth/me etc.) and should not change. +

+
+ +
+

Change 1 — Fix ACCESS_TOKEN_TTL

+

File: source/server/routes/auth.ts, line 13

+
// Before
+const ACCESS_TOKEN_TTL = '10s';
+
+// After
+const ACCESS_TOKEN_TTL = '1h';
+

+ Redeploy immediately after this change. Restart the service and confirm with + journalctl -u rokojori-auth -n 20 that it came up cleanly. +

+
+ +
+

Nothing else changes

+

+ The page-level redirect middleware in source/server/index.ts is + correct for a pure server-rendered auth service — leave it as-is. + The requireAuth.ts middleware is correct for the auth service's own + routes — leave it as-is. +

+
+
+ + +
+

roject

+ +
+

Current state

+

+ Roject is the most advanced of the three — it already has + jwtMiddleware with transparent refresh. The problems are: +

+
    +
  • Uses req.user instead of req.auth (19 call sites across 6 files)
  • +
  • Uses local type JwtUser instead of AuthPayload
  • +
  • jwtMiddleware still redirects non-API requests to + AUTH_HOST/api/auth/refresh-session — unnecessary because + server-side transparent refresh already handles page requests silently
  • +
  • Missing app.set('trust proxy', 1) in index.ts
  • +
  • Electron main.ts intercepts will-redirect to handle + the refresh-session redirect — can be removed once the redirect + branch is gone
  • +
+
+ +
+

Change 1 — Add the submodule

+
git submodule add git@community.rokojori.com:Rokojori/rokojori-auth-connector.git source/auth-connector
+git submodule update --init
+
+ +
+

Change 2 — tsconfig path alias

+

Add to tsconfig.json (or the relevant tsconfig for server compilation):

+
"paths": {
+  "auth-connector/*": ["./source/auth-connector/source/*"]
+}
+
+ +
+

Change 3 — Delete source/server/middleware/auth.ts

+

+ The entire file is replaced by the connector. Delete it after the imports + in all dependents are updated (changes 4–7 below). +

+
+ +
+

Change 4 — Update source/server/index.ts

+
    +
  • Add app.set( 'trust proxy', 1 ); before app.use( express.json() )
  • +
  • Change import: from 'auth-connector/server/auth'
  • +
  • Line 35: req.userreq.auth
  • +
+
// Before
+import { jwtMiddleware, requireAuth } from './middleware/auth';
+...
+app.get( '/api/auth/me', requireAuth, ( req, res ) => res.json( req.user ) );
+
+// After
+import { jwtMiddleware, requireAuth } from 'auth-connector/server/auth';
+...
+app.set( 'trust proxy', 1 );
+...
+app.get( '/api/auth/me', requireAuth, ( req, res ) => res.json( req.auth ) );
+
+ +
+

Change 5 — Update source/server/projectAccess.ts

+

+ Replace the local JwtUser import with AuthPayload + from the connector. The type shape is identical — this is a rename only. +

+
// Before
+import { JwtUser } from './middleware/auth';
+export function isOwner( project: Project, user: JwtUser ): boolean { ... }
+// ... all function signatures use JwtUser
+
+// After
+import type { AuthPayload } from 'auth-connector/shared/types';
+export function isOwner( project: Project, user: AuthPayload ): boolean { ... }
+// ... replace JwtUser with AuthPayload in all 4 function signatures
+
+ +
+

Change 6 — Rename req.user → req.auth in route files

+

19 occurrences across 5 files. All are mechanical replacements — the shape of + the object does not change.

+
    +
  • source/server/routes/files.ts — 7 occurrences
  • +
  • source/server/routes/projects.ts — 7 occurrences
  • +
  • source/server/routes/layout.ts — 2 occurrences
  • +
  • source/server/routes/rojos.ts — 2 occurrences
  • +
  • source/server/routes/userSettings.ts — 2 occurrences
  • +
+

+ The checkAccess calls pass req.user! as the second + argument. After change 5, projectAccess.ts expects AuthPayload + — the rename makes the types consistent. Change every req.user to + req.auth and every req.user! to req.auth!. +

+
+ +
+

Change 7 — Remove the non-API redirect branch from jwtMiddleware

+

+ In the old local auth.ts (now deleted), jwtMiddleware + redirected non-API requests with an expired token to + AUTH_HOST/api/auth/refresh-session. + The connector's jwtMiddleware does not do this — it handles + the refresh server-side and never redirects. This is correct behavior. + No explicit action needed here once the old file is deleted. +

+
+ +
+

Change 8 — Remove the will-redirect intercept from electron/main.ts

+

+ The mainWindow.webContents.on('will-redirect', ...) block + (lines 136–158) exists solely to intercept the /api/auth/refresh-session + redirect that the old jwtMiddleware emitted. Once the redirect is + gone, this intercept is dead code and should be removed. +

+
// Remove this entire block from electron/main.ts:
+mainWindow.webContents.on( 'will-redirect', async ( event, url ) =>
+{
+  if ( url.includes( '/api/auth/refresh-session' ) )
+  {
+    // ... entire block
+  }
+} );
+

+ The refreshTokens() helper function defined above it can also + be deleted — Electron no longer needs to do its own refresh because the + server-side jwtMiddleware handles it transparently. +

+
+ +
+

Verify

+
journalctl -u roject -f | grep '\[auth\]'
+

+ Log into Roject, wait 65 minutes (or temporarily set ACCESS_TOKEN_TTL=65s + in the test environment), then make an API call. Expect to see the + [auth] tryRefresh → ... sequence and a transparent recovery. +

+
+
+ + +
+

styles

+ +
+

Current state

+

+ styles has no jwtMiddleware at all. requireAuth is + wired directly onto routes. An expired access token causes an immediate 401 with + no recovery attempt, regardless of whether the refresh token is valid. + STYLES_RULES is currently defined inside + middleware/requireAccess.ts — it needs to move to index.ts. +

+
+ +
+

Change 1 — Add the submodule

+
git submodule add git@community.rokojori.com:Rokojori/rokojori-auth-connector.git source/auth-connector
+git submodule update --init
+
+ +
+

Change 2 — tsconfig path alias

+
"paths": {
+  "auth-connector/*": ["./source/auth-connector/source/*"]
+}
+
+ +
+

Change 3 — Delete both middleware files

+
source/server/middleware/requireAuth.ts    ← delete
+source/server/middleware/requireAccess.ts  ← delete
+

+ Do this after updating index.ts so the service never references + the deleted files. +

+
+ +
+

Change 4 — Rewrite source/server/index.ts imports and wiring

+
// Before
+import { requireAuth }                    from './middleware/requireAuth';
+import { requireAccess, STYLES_RULES }    from './middleware/requireAccess';
+
+// After
+import { jwtMiddleware, requireAuth, requireAccess } from 'auth-connector/server/auth';
+import type { AccessRule }                           from 'auth-connector/shared/types';
+
+const STYLES_RULES: AccessRule[] = [
+  { role: 'admin' },
+  { role: 'user', product: 'styles' },
+  { role: 'user', product: 'premium' },
+];
+

+ Then add jwtMiddleware as global middleware — place it after + cookieParser() and before the route registrations: +

+
app.use( cookieParser() );
+app.use( jwtMiddleware );    // ← add this line
+
+// rest of routes unchanged...
+

+ The route registrations themselves do not change — they already use + requireAuth and requireAccess( STYLES_RULES ). +

+
+ +
+

Verify

+
journalctl -u styles-rokojori -f | grep '\[auth\]'
+

+ Hit /api/fonts with an expired token and a valid refresh cookie. + Expect transparent recovery in the log. +

+
+
+ + +
+

tunnel

+ +
+

Current state

+

+ tunnel has no jwtMiddleware. Expired tokens on + /api/tunnels routes always return 401 with no recovery. + Two routes do their own inline JWT handling and must be treated carefully: +

+
    +
  • + routes/agent.ts — handles WebSocket + upgrades; cannot use Express middleware. Has its own jwt.verify() + call. This is correct and does not change — WebSocket upgrades bypass Express + middleware entirely. +
  • +
  • + routes/proxy.tssoftAuth() + does a non-blocking JWT check for the proxy route. Public tunnels pass even + without a token. This intentional soft auth stays local to proxy.ts — do not + replace it with jwtMiddleware. +
  • +
+
+ +
+

Change 1 — Add the submodule

+
git submodule add git@community.rokojori.com:Rokojori/rokojori-auth-connector.git source/auth-connector
+git submodule update --init
+
+ +
+

Change 2 — tsconfig path alias

+
"paths": {
+  "auth-connector/*": ["./source/auth-connector/source/*"]
+}
+
+ +
+

Change 3 — Delete source/server/middleware/requireAuth.ts

+

Delete after updating all importers below.

+
+ +
+

Change 4 — Update source/server/index.ts

+

+ Add jwtMiddleware scoped to the /api/tunnels routes. + The proxy route (/t) must not + get jwtMiddleware — it does its own soft auth. +

+
// Before
+import { requireAuth } from './middleware/requireAuth'; // (was unused at index level)
+
+// After
+import { jwtMiddleware } from 'auth-connector/server/auth';
+
+// Add jwtMiddleware only for the API routes section:
+app.use( '/api/tunnels', jwtMiddleware, express.json(), tunnelsRouter );
+
+// The /t proxy route stays unchanged — no jwtMiddleware
+app.use( '/t', proxyRouter );
+
+ +
+

Change 5 — Update routes/tunnels.ts

+
// Before
+import { requireAuth } from '../middleware/requireAuth';
+
+// After
+import { requireAuth } from 'auth-connector/server/auth';
+

+ All uses of req.auth in this file are already correct — no other + changes needed. +

+
+ +
+

Change 6 — Update routes/proxy.ts

+

+ proxy.ts imports AuthPayload and + extractBearer from the old local requireAuth.ts. + extractBearer is internal to the connector and not exported. + Inline it locally — it is three lines. +

+
// Before
+import { AuthPayload, extractBearer } from '../middleware/requireAuth';
+
+// After
+import type { AuthPayload } from 'auth-connector/shared/types';
+
+function extractBearer( req: Request ): string | undefined
+{
+  const h = req.headers.authorization;
+  return h?.startsWith( 'Bearer ' ) ? h.slice( 7 ) : undefined;
+}
+

+ The softAuth() function and all other logic in proxy.ts stays + unchanged. +

+
+ +
+

Change 7 — Update routes/agent.ts

+
// Before
+import { AuthPayload } from '../middleware/requireAuth';
+
+// After
+import type { AuthPayload } from 'auth-connector/shared/types';
+

+ The inline jwt.verify() in the WebSocket upgrade handler is + correct and stays — WebSocket upgrades bypass Express middleware. +

+
+ +
+

Verify

+
journalctl -u tunnel-rokojori -f | grep '\[auth\]'
+

+ Make an authenticated API call to /api/tunnels with an expired + access token and a valid refresh cookie. Expect transparent recovery in the log. + Then make a request to a public tunnel (/t/:id/...) without any + token — expect it to pass through without hitting the auth log at all. +

+
+
+ + +
+

Execution order

+ +
+
    +
  1. + rokojori-auth — fix + ACCESS_TOKEN_TTL to '1h', redeploy, verify startup log. +
  2. +
  3. + Create the Gitea repo for + rokojori-auth-connector and push the local directory to it. +
  4. +
  5. + roject — add submodule, make all + changes, redeploy. This is the most complex change (19 renames + Electron + cleanup). Test thoroughly before moving to the others. +
  6. +
  7. + styles — add submodule, rewire + index.ts, delete old middleware files, redeploy. +
  8. +
  9. + tunnel — add submodule, add + jwtMiddleware to API routes, update imports in tunnels/proxy/agent, + delete old requireAuth.ts, redeploy. +
  10. +
+
+ +
+

After each repo

+

+ Before moving to the next repo: confirm auth still works end-to-end — + login, wait for the access token to expire (check with + journalctl ... | grep '[auth]'), and confirm the first API call + after expiry succeeds transparently. +

+
+
+ +
+ Roject — auth connector rewrite plan +
+ +
+ + + + + diff --git a/workspace/outline/index.html b/workspace/outline/index.html index 9c17f65..aaa379c 100644 --- a/workspace/outline/index.html +++ b/workspace/outline/index.html @@ -45,9 +45,20 @@

User accounts are managed by rokojori-auth at account.rokojori.com — registration, login, JWT issuance, refresh - token rotation, roles, and account deletion. Roject verifies the shared - accessToken JWT cookie and transparently refreshes expired tokens - via account.rokojori.com/api/auth/refresh-session. + token rotation, roles, and account deletion. +

+

+ Token refresh — two layers: + rokojori-auth has a page-level middleware (before express.static) that + redirects any page request carrying an expired accessToken to + /api/auth/refresh-session, which rotates both cookies and redirects back. + Roject's jwtMiddleware handles mid-session API calls: when a + TokenExpiredError hits an /api/ route and a + refreshToken cookie is present, it calls + POST AUTH_INTERNAL_HOST/api/auth/refresh server-side, sets the new + cookies on the response, and continues transparently. AUTH_INTERNAL_HOST + defaults to AUTH_HOST; set it to http://localhost:3001 + in production to bypass nginx for the server-to-server call.

@@ -95,7 +106,13 @@ The Electron Tunnel Agent (electron-agent/) is a Windows system-tray app: login via account.rokojori.com, a tunnel list with Start / Stop / Delete / Create, and a green status dot when the agent WebSocket - is connected. Tokens are persisted in userData/tokens.json. + is connected. Tokens are persisted in userData/tokens.json and + automatically refreshed — any 401 from the tunnel API triggers + POST /api/auth/refresh before retrying; if the refresh token is + also expired the app returns to the login screen. A logout button in the window + header and the tray menu clears tokens and stops all agents. The + TunnelAgent uses a getToken() getter instead of a + static token so every WebSocket reconnect picks up the current access token.

The relay uses a three-message streaming protocol over the agent WebSocket: