Connector Rewrite

This commit is contained in:
Rokojori 2026-07-17 13:37:48 +02:00
parent 73e282af00
commit a8bfc95d1c
20 changed files with 740 additions and 226 deletions

3
.gitmodules vendored
View File

@ -1,3 +1,6 @@
[submodule "src/library-ts"] [submodule "src/library-ts"]
path = source/library-ts path = source/library-ts
url = git@development.rokojori.com:Josef/library-ts.git 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

View File

@ -61,18 +61,6 @@ function postJson( url: string, body: unknown ): Promise<unknown> {
} ); } );
} }
async function refreshTokens( refreshToken: string ): Promise<Tokens | null> {
try {
const result = await postJson( `${AUTH_HOST}/api/auth/refresh`, { refreshToken } ) as Record<string, unknown>;
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 { function registerHeaderInjector( getToken: () => string | null ): void {
session.defaultSession.webRequest.onBeforeSendHeaders( session.defaultSession.webRequest.onBeforeSendHeaders(
{ urls: [ `http://localhost:${PORT}/*` ] }, { 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; } ); mainWindow.on( 'closed', () => { mainWindow = null; } );
} }

1
source/auth-connector Submodule

@ -0,0 +1 @@
Subproject commit 73fe3a3c9a87070f9e8ecf9682caba9d77de3f6b

View File

@ -2,7 +2,7 @@ import express from 'express';
import cookieParser from 'cookie-parser'; import cookieParser from 'cookie-parser';
import path from 'path'; import path from 'path';
import { ROOT } from './rootDir'; import { ROOT } from './rootDir';
import { jwtMiddleware, requireAuth } from './middleware/auth'; import { jwtMiddleware, requireAuth } from 'auth-connector/server/auth';
import projectsRouter from './routes/projects'; import projectsRouter from './routes/projects';
import filesRouter from './routes/files'; import filesRouter from './routes/files';
import localesRouter from './routes/locales'; import localesRouter from './routes/locales';
@ -18,6 +18,7 @@ generateLocales();
const app = express(); const app = express();
app.use( '/api/deploy', deployRouter ); app.use( '/api/deploy', deployRouter );
app.set( 'trust proxy', 1 );
app.use( express.json() ); app.use( express.json() );
app.use( express.text( { type: 'text/plain' } ) ); app.use( express.text( { type: 'text/plain' } ) );
app.use( cookieParser() ); app.use( cookieParser() );
@ -32,7 +33,7 @@ app.use( '/api/layout', layoutRouter );
app.use( '/api/rojos', rojosRouter ); app.use( '/api/rojos', rojosRouter );
app.use( '/api/user/settings', userSettingsRouter ); 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' ) ); app.get( '/edit', ( _req, res ) => res.redirect( '/editor.html' ) );

View File

@ -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<string, unknown>;
}
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<RefreshResult | null> {
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<RefreshResult>;
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();
}

View File

@ -1,12 +1,12 @@
import { Project, ProjectMember, projects, projectMembers } from './db'; 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; 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; if ( member.member_type !== 'user' ) return false;
// Interim (Option B): member_id stores the email address. // 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; 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; 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; 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; if ( isOwner( project, user ) ) return true;
const role = getMemberRole( members, user ); 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 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 ); const project = projects.findById( projectId );
if ( !project ) return { status: 404, error: 'Not found' }; if ( !project ) return { status: 404, error: 'Not found' };

View File

@ -1,6 +1,6 @@
import { Router } from 'express'; import { Router } from 'express';
import { getFileTree, readProjectFile, writeProjectFile, createProjectFile, createProjectDirectory, renameProjectEntry, deleteProjectEntry } from '../storage'; 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'; import { checkAccess } from '../projectAccess';
const router = Router(); const router = Router();
@ -8,14 +8,14 @@ router.use( requireAuth );
router.get( '/:projectId/tree', ( req, res ) => 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; } if ( denied ) { res.status( denied.status ).json( { error: denied.error } ); return; }
res.json( getFileTree( req.params.projectId ) ); res.json( getFileTree( req.params.projectId ) );
} ); } );
router.get( '/:projectId/*', ( req, res ) => 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; } if ( denied ) { res.status( denied.status ).json( { error: denied.error } ); return; }
const filePath = ( req.params as Record<string, string> )[ 0 ]; const filePath = ( req.params as Record<string, string> )[ 0 ];
const content = readProjectFile( req.params.projectId, filePath ); const content = readProjectFile( req.params.projectId, filePath );
@ -25,7 +25,7 @@ router.get( '/:projectId/*', ( req, res ) =>
router.post( '/:projectId/create-file', ( 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; } if ( denied ) { res.status( denied.status ).json( { error: denied.error } ); return; }
const { path: filePath } = req.body as { path: string }; const { path: filePath } = req.body as { path: string };
if ( !filePath ) { res.status( 400 ).json( { error: 'path required' } ); return; } 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 ) => 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; } if ( denied ) { res.status( denied.status ).json( { error: denied.error } ); return; }
const { path: dirPath } = req.body as { path: string }; const { path: dirPath } = req.body as { path: string };
if ( !dirPath ) { res.status( 400 ).json( { error: 'path required' } ); return; } 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 ) => 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; } if ( denied ) { res.status( denied.status ).json( { error: denied.error } ); return; }
const { path: oldPath, newName } = req.body as { path: string; newName: string }; const { path: oldPath, newName } = req.body as { path: string; newName: string };
if ( !oldPath || !newName ) { res.status( 400 ).json( { error: 'path and newName required' } ); return; } 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 ) => 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; } if ( denied ) { res.status( denied.status ).json( { error: denied.error } ); return; }
const { path: targetPath } = req.body as { path: string }; const { path: targetPath } = req.body as { path: string };
if ( !targetPath ) { res.status( 400 ).json( { error: 'path required' } ); return; } 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 ) => 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; } if ( denied ) { res.status( denied.status ).json( { error: denied.error } ); return; }
const filePath = ( req.params as Record<string, string> )[ 0 ]; const filePath = ( req.params as Record<string, string> )[ 0 ];
if ( typeof req.body !== 'string' ) { res.status( 400 ).json( { error: 'Content must be text' } ); return; } if ( typeof req.body !== 'string' ) { res.status( 400 ).json( { error: 'Content must be text' } ); return; }

View File

@ -1,6 +1,6 @@
import { Router } from 'express'; import { Router } from 'express';
import { groups, groupMembers } from '../db'; import { groups, groupMembers } from '../db';
import { requireAuth } from '../middleware/auth'; import { requireAuth } from 'auth-connector/server/auth';
const router = Router(); const router = Router();
router.use(requireAuth); router.use(requireAuth);

View File

@ -1,7 +1,7 @@
import { Router } from 'express'; import { Router } from 'express';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; 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 { RJLog } from '../../library-ts/node/log/RJLog';
import { ROOT } from '../rootDir'; import { ROOT } from '../rootDir';
@ -23,7 +23,7 @@ router.get( '/', ( req, res ) =>
{ {
const deviceId = req.query.deviceId as string; const deviceId = req.query.deviceId as string;
if ( !deviceId ) { res.json( null ); return; } 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; } if ( !fs.existsSync( fp ) ) { res.json( null ); return; }
try { res.json( JSON.parse( fs.readFileSync( fp, 'utf8' ) ) ); } try { res.json( JSON.parse( fs.readFileSync( fp, 'utf8' ) ) ); }
catch { res.json( null ); } catch { res.json( null ); }
@ -33,7 +33,7 @@ router.put( '/', ( req, res ) =>
{ {
const deviceId = req.query.deviceId as string; const deviceId = req.query.deviceId as string;
if ( !deviceId ) { res.status( 400 ).json( { error: 'Missing deviceId' } ); return; } 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 try
{ {
fs.writeFileSync( fp, JSON.stringify( req.body ), 'utf8' ); fs.writeFileSync( fp, JSON.stringify( req.body ), 'utf8' );

View File

@ -1,6 +1,6 @@
import { Router } from 'express'; import { Router } from 'express';
import { projects, projectMembers } from '../db'; import { projects, projectMembers } from '../db';
import { requireAuth } from '../middleware/auth'; import { requireAuth } from 'auth-connector/server/auth';
import { createProjectStorage } from '../storage'; import { createProjectStorage } from '../storage';
import { isOwner, canView, getMemberRole } from '../projectAccess'; import { isOwner, canView, getMemberRole } from '../projectAccess';
@ -9,7 +9,7 @@ router.use( requireAuth );
router.get( '/', ( req, res ) => router.get( '/', ( req, res ) =>
{ {
const user = req.user!; const user = req.auth!;
const visible = projects.all().filter( p => const visible = projects.all().filter( p =>
{ {
if ( isOwner( p, user ) ) return true; if ( isOwner( p, user ) ) return true;
@ -22,7 +22,7 @@ router.post( '/', ( req, res ) =>
{ {
const { name } = req.body as { name?: string }; const { name } = req.body as { name?: string };
if ( !name ) { res.status( 400 ).json( { error: 'Name required' } ); return; } 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 ); createProjectStorage( project.id );
res.json( project ); res.json( project );
} ); } );
@ -31,7 +31,7 @@ router.delete( '/:id', ( req, res ) =>
{ {
const project = projects.findById( req.params.id ); const project = projects.findById( req.params.id );
if ( !project ) { res.status( 404 ).json( { error: 'Not found' } ); return; } 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 ); projects.delete( req.params.id );
res.json( { ok: true } ); res.json( { ok: true } );
} ); } );
@ -41,7 +41,7 @@ router.get( '/:id/members', ( req, res ) =>
const project = projects.findById( req.params.id ); const project = projects.findById( req.params.id );
if ( !project ) { res.status( 404 ).json( { error: 'Not found' } ); return; } if ( !project ) { res.status( 404 ).json( { error: 'Not found' } ); return; }
const members = projectMembers.forProject( req.params.id ); 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 ); res.json( members );
} ); } );
@ -49,7 +49,7 @@ router.post( '/:id/members', ( req, res ) =>
{ {
const project = projects.findById( req.params.id ); const project = projects.findById( req.params.id );
if ( !project ) { res.status( 404 ).json( { error: 'Not found' } ); return; } 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 }; const { email, role } = req.body as { email?: string; role?: string };
if ( !email ) { res.status( 400 ).json( { error: 'email required' } ); return; } 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' } ) ); 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 ); const project = projects.findById( req.params.id );
if ( !project ) { res.status( 404 ).json( { error: 'Not found' } ); return; } 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 ); projectMembers.remove( req.params.memberId );
res.json( { ok: true } ); res.json( { ok: true } );
} ); } );

View File

@ -2,7 +2,7 @@ import { Router } from "express";
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
import crypto from "crypto"; import crypto from "crypto";
import { requireAuth } from "../middleware/auth"; import { requireAuth } from "auth-connector/server/auth";
import { getAgentStream, updateAgentConversation, AgentConfig } from "../rojos/RojosAgent"; import { getAgentStream, updateAgentConversation, AgentConfig } from "../rojos/RojosAgent";
import { readProjectFile, writeProjectFile, createProjectDirectory } from "../storage"; import { readProjectFile, writeProjectFile, createProjectDirectory } from "../storage";
import { checkAccess } from "../projectAccess"; import { checkAccess } from "../projectAccess";
@ -125,7 +125,7 @@ router.get( "/tunnels/browse", async ( req, res ) =>
router.get( "/:projectId/list", ( 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; } if ( denied ) { res.status( denied.status ).json( { error: denied.error } ); return; }
const projectRoot = path.join( STORAGE, req.params.projectId, "root" ); 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 ) => 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; } if ( denied ) { res.status( denied.status ).json( { error: denied.error } ); return; }
const { parentDir } = req.body as { parentDir?: string }; const { parentDir } = req.body as { parentDir?: string };

View File

@ -1,17 +1,17 @@
import { Router } from 'express'; import { Router } from 'express';
import { requireAuth } from '../middleware/auth'; import { requireAuth } from 'auth-connector/server/auth';
import { userSettings } from '../db'; import { userSettings } from '../db';
const router = Router(); const router = Router();
router.use( requireAuth ); router.use( requireAuth );
router.get( '/', ( req, res ) => { router.get( '/', ( req, res ) => {
res.json( userSettings.forUser( req.user!.userId ) ?? {} ); res.json( userSettings.forUser( req.auth!.userId ) ?? {} );
} ); } );
router.put( '/', ( req, res ) => { router.put( '/', ( req, res ) => {
const settings = req.body as Record<string, unknown>; const settings = req.body as Record<string, unknown>;
res.json( userSettings.save( req.user!.userId, settings ) ); res.json( userSettings.save( req.auth!.userId, settings ) );
} ); } );
export default router; export default router;

View File

@ -1,7 +1,11 @@
{ {
"extends": "./tsconfig.json", "extends": "./tsconfig.json",
"compilerOptions": { "compilerOptions": {
"strictNullChecks": false "strictNullChecks": false,
"baseUrl": ".",
"paths": {
"auth-connector/*": ["./source/auth-connector/source/*"]
}
}, },
"include": ["source/server/**/*", "source/library-ts/node/**/*"] "include": ["source/server/**/*", "source/library-ts/node/**/*"]
} }

View File

@ -16,6 +16,7 @@ var NAV_DATA = {
path: 'outline/index.html', path: 'outline/index.html',
children: [ 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' }, { title: 'User-Based Local Tunneling', path: 'outline/tunneling.html' },
] ]
}, },

View File

@ -22,6 +22,55 @@
<div class="lane"> <div class="lane">
<div class="lane-header">To Do</div> <div class="lane-header">To Do</div>
<task-item class="blue hide-content">
<task-title>File tree double-click: auto-open or focus existing editor</task-title>
<task-content>
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).
</task-content>
</task-item>
<task-item class="blue hide-content">
<task-title>Tab-container: split function broken, panel border update unreliable</task-title>
<task-content>
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.
</task-content>
</task-item>
<task-item class="blue hide-content">
<task-title>Mobile: editor layout too tall, chat input not reachable</task-title>
<task-content>
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.
</task-content>
</task-item>
<task-item class="blue hide-content">
<task-title>Mobile: nav bar z-index too low on projects / index view</task-title>
<task-content>
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.
</task-content>
</task-item>
<task-item class="blue hide-content">
<task-title>Code syntax highlighting in rojo-chat (Highlight.js)</task-title>
<task-content>
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.
</task-content>
</task-item>
<task-item class="blue hide-content"> <task-item class="blue hide-content">
<task-title>Rojo Character Editor</task-title> <task-title>Rojo Character Editor</task-title>
<task-content> <task-content>
@ -62,18 +111,6 @@
</task-content> </task-content>
</task-item> </task-item>
<task-item class="blue hide-content">
<task-title>File tree double-click: auto-open or focus existing editor</task-title>
<task-content>
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).
</task-content>
</task-item>
<task-item class="blue hide-content"> <task-item class="blue hide-content">
<task-title>Remote Projects in Electron</task-title> <task-title>Remote Projects in Electron</task-title>
<task-content> <task-content>
@ -189,6 +226,43 @@
<div class="lane"> <div class="lane">
<div class="lane-header">Done</div> <div class="lane-header">Done</div>
<task-item class="green hide-content">
<task-title>rokojori-auth: page-level token refresh middleware</task-title>
<task-content>
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=&lt;url&gt; which rotates both cookies and
redirects back. Fixes the reload-to-login issue after the 1-hour TTL.
</task-content>
</task-item>
<task-item class="green hide-content">
<task-title>Tunnel Agent: token refresh on 401, logout, getToken getter</task-title>
<task-content>
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: () =&gt; 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.
</task-content>
</task-item>
<task-item class="green hide-content">
<task-title>rojo-chat-panel: mobile layout fix + animated thinking indicator</task-title>
<task-content>
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.
</task-content>
</task-item>
<task-item class="green hide-content"> <task-item class="green hide-content">
<task-title>Fix session logout after ~1 hour — transparent token refresh</task-title> <task-title>Fix session logout after ~1 hour — transparent token refresh</task-title>
<task-content> <task-content>

View File

@ -38,7 +38,7 @@
<h3>1 — Create the route file</h3> <h3>1 — Create the route file</h3>
<p>Create <code>server/routes/&lt;name&gt;.ts</code>. All route files follow the same skeleton:</p> <p>Create <code>server/routes/&lt;name&gt;.ts</code>. All route files follow the same skeleton:</p>
<pre><code>import { Router } from 'express'; <pre><code>import { Router } from 'express';
import { requireAuth } from '../middleware/auth'; import { requireAuth } from 'auth-connector/server/auth';
const router = Router(); const router = Router();
router.use( requireAuth ); router.use( requireAuth );
@ -57,16 +57,16 @@ export default router;</code></pre>
</div> </div>
<div class="card"> <div class="card">
<h3>2 — Access the session</h3> <h3>2 — Access the authenticated user</h3>
<p> <p>
The session is typed via a <code>declare module</code> in <code>req.auth</code> is typed via a <code>declare module</code> in
<code>server/middleware/auth.ts</code>. The available fields are <code>source/auth-connector/source/server/auth.ts</code>. The available fields are
<code>req.session.userId</code> and <code>req.session.username</code>, both <code>userId</code>, <code>email</code>, <code>roles</code>, and <code>products</code>.
<code>string | undefined</code>. After <code>requireAuth</code> they are After <code>requireAuth</code> they are guaranteed to be set — use the non-null
guaranteed to be set — use the non-null assertion (<code>!</code>) freely: assertion (<code>!</code>) freely:
</p> </p>
<pre><code>const userId = req.session.userId!; <pre><code>const userId = req.auth!.userId;
const username = req.session.username!;</code></pre> const email = req.auth!.email;</code></pre>
</div> </div>
<div class="card"> <div class="card">

View File

@ -156,6 +156,71 @@
</section> </section>
<section>
<h2>Session 4 — Token refresh fixes, Tunnel Agent improvements, chat UI</h2>
<div class="card">
<h3>rokojori-auth: page-level token refresh middleware</h3>
<p>
Added a <code>jwtMiddleware</code> to <code>rokojori-auth/source/server/index.ts</code>
before <code>express.static</code>. When a page request (non-<code>/api/</code>) arrives
with an expired <code>accessToken</code>, the middleware redirects to
<code>/api/auth/refresh-session?redirect=&lt;url&gt;</code>, which rotates both
cookies and redirects back. Fixes the reload-to-login loop after the 1-hour TTL.
</p>
</div>
<div class="card">
<h3>Roject auth.ts: transparent API token refresh + diagnostic logging</h3>
<p>
<code>jwtMiddleware</code> now handles <code>TokenExpiredError</code> on API routes:
reads the <code>refreshToken</code> cookie, calls
<code>POST AUTH_INTERNAL_HOST/api/auth/refresh</code> server-side, sets new cookies
on the response, decodes the new JWT into <code>req.user</code>, and calls
<code>next()</code>. Added <code>AUTH_INTERNAL_HOST</code> env var (defaults to
<code>AUTH_HOST</code>); production <code>.env</code> sets it to
<code>http://localhost:3001</code> to bypass nginx. Added <code>console.log</code>
diagnostics throughout <code>tryRefresh</code> for journalctl debugging.
</p>
</div>
<div class="card">
<h3>Tunnel Agent: token refresh, logout, getToken getter</h3>
<p>
Three improvements:
</p>
<ul style="line-height:1.9;margin-top:0.75rem">
<li><code>tryRefreshTokens()</code> — on any 401 from <code>apiFetch</code>,
calls <code>POST account.rokojori.com/api/auth/refresh</code>, saves new tokens,
retries once. Falls through to <code>handleLogout()</code> if refresh fails.</li>
<li><code>handleLogout()</code> — 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.</li>
<li><code>TunnelAgentConfig.token: string</code> replaced with
<code>getToken: () =&gt; string</code> 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.</li>
</ul>
</div>
<div class="card">
<h3>rojo-chat-panel: mobile layout + animated thinking indicator</h3>
<p>
CSS: <code>min-height: 0</code> on <code>rojo-chat-panel</code> and
<code>.rcp-history</code> so the history can shrink in flex; <code>overflow: hidden</code>
on the panel root. Focus listener on the input calls <code>scrollIntoView</code>
after 300 ms to push the input above the mobile keyboard.
</p>
<p style="margin-top:0.75rem">
Replaced the static <code></code> placeholder with a cycling animation
(frames customised by user) at 250 ms per frame via <code>setInterval</code>.
The interval is cleared and the bubble wiped the moment the first real response
chunk arrives.
</p>
</div>
</section>
<section> <section>
<h2>Session 3 — Fix session logout after ~1 hour</h2> <h2>Session 3 — Fix session logout after ~1 hour</h2>

View File

@ -21,7 +21,7 @@
<div class="card"> <div class="card">
<h3><a href="2026/07-July/16-Wednesday/index.html">Wednesday, 16 July 2026</a></h3> <h3><a href="2026/07-July/16-Wednesday/index.html">Wednesday, 16 July 2026</a></h3>
<p>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.</p> <p>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.</p>
</div> </div>
<div class="card"> <div class="card">

View File

@ -0,0 +1,512 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Auth Connector Rewrite — Roject Outline</title>
<link rel="stylesheet" href="../_assets_/styles.css">
<link rel="stylesheet" href="../_assets_/nav.css">
</head>
<body>
<div class="page">
<header>
<p class="date">Plan</p>
<h1>Auth Connector Rewrite</h1>
<p class="subtitle">
Per-repo change list for adopting <code>rokojori-auth-connector</code> 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.
</p>
</header>
<!-- ─── Overview ─────────────────────────────────────────────── -->
<section>
<h2>Overview</h2>
<div class="card">
<h3>What the connector replaces</h3>
<p>
Every service currently has its own copy of auth middleware. They have drifted in
three ways that cause constant breakage:
</p>
<ul style="margin-top:0.75rem;line-height:1.9;font-size:0.9rem;color:var(--muted)">
<li>
<strong style="color:var(--text)">No transparent refresh</strong> — tunnel and
styles only have a bare <code>requireAuth</code>. An expired access token always
returns 401 with no recovery attempt, even when a valid refresh token is present.
</li>
<li>
<strong style="color:var(--text)">Wrong property name</strong> — roject uses
<code>req.user</code>; tunnel, styles, and rokojori-auth use <code>req.auth</code>.
Routes cannot be safely copied between services.
</li>
<li>
<strong style="color:var(--text)">ACCESS_TOKEN_TTL set to 10 seconds</strong>
the single most likely cause of constant auth failures. Every session is broken
10 seconds after login unless the transparent refresh is working perfectly.
</li>
</ul>
</div>
<div class="card">
<h3>Repos in scope</h3>
<pre><code>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</code></pre>
</div>
<div class="card">
<h3>Do this first, before any other change</h3>
<p>
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.
</p>
</div>
</section>
<!-- ─── rokojori-auth ─────────────────────────────────────────── -->
<section>
<h2>rokojori-auth</h2>
<div class="card">
<h3>Does NOT get the submodule</h3>
<p>
rokojori-auth is the auth service itself. It cannot call itself for token
refresh, so <code>jwtMiddleware</code> makes no sense here. Its own
<code>requireAuth.ts</code> is correct for guarding its own API routes
(<code>/api/auth/me</code> etc.) and should not change.
</p>
</div>
<div class="card">
<h3>Change 1 — Fix ACCESS_TOKEN_TTL</h3>
<p>File: <code>source/server/routes/auth.ts</code>, line 13</p>
<pre><code>// Before
const ACCESS_TOKEN_TTL = '10s';
// After
const ACCESS_TOKEN_TTL = '1h';</code></pre>
<p style="margin-top:0.75rem">
Redeploy immediately after this change. Restart the service and confirm with
<code>journalctl -u rokojori-auth -n 20</code> that it came up cleanly.
</p>
</div>
<div class="card">
<h3>Nothing else changes</h3>
<p>
The page-level redirect middleware in <code>source/server/index.ts</code> is
correct for a pure server-rendered auth service — leave it as-is.
The <code>requireAuth.ts</code> middleware is correct for the auth service's own
routes — leave it as-is.
</p>
</div>
</section>
<!-- ─── roject ───────────────────────────────────────────────── -->
<section>
<h2>roject</h2>
<div class="card">
<h3>Current state</h3>
<p>
Roject is the most advanced of the three — it already has
<code>jwtMiddleware</code> with transparent refresh. The problems are:
</p>
<ul style="margin-top:0.75rem;line-height:1.9;font-size:0.9rem;color:var(--muted)">
<li>Uses <code>req.user</code> instead of <code>req.auth</code> (19 call sites across 6 files)</li>
<li>Uses local type <code>JwtUser</code> instead of <code>AuthPayload</code></li>
<li><code>jwtMiddleware</code> still redirects non-API requests to
<code>AUTH_HOST/api/auth/refresh-session</code> — unnecessary because
server-side transparent refresh already handles page requests silently</li>
<li>Missing <code>app.set('trust proxy', 1)</code> in <code>index.ts</code></li>
<li>Electron <code>main.ts</code> intercepts <code>will-redirect</code> to handle
the <code>refresh-session</code> redirect — can be removed once the redirect
branch is gone</li>
</ul>
</div>
<div class="card">
<h3>Change 1 — Add the submodule</h3>
<pre><code>git submodule add git@community.rokojori.com:Rokojori/rokojori-auth-connector.git source/auth-connector
git submodule update --init</code></pre>
</div>
<div class="card">
<h3>Change 2 — tsconfig path alias</h3>
<p>Add to <code>tsconfig.json</code> (or the relevant tsconfig for server compilation):</p>
<pre><code>"paths": {
"auth-connector/*": ["./source/auth-connector/source/*"]
}</code></pre>
</div>
<div class="card">
<h3>Change 3 — Delete source/server/middleware/auth.ts</h3>
<p>
The entire file is replaced by the connector. Delete it after the imports
in all dependents are updated (changes 47 below).
</p>
</div>
<div class="card">
<h3>Change 4 — Update source/server/index.ts</h3>
<ul style="margin-top:0.5rem;line-height:1.9;font-size:0.9rem;color:var(--muted)">
<li>Add <code>app.set( 'trust proxy', 1 );</code> before <code>app.use( express.json() )</code></li>
<li>Change import: <code>from 'auth-connector/server/auth'</code></li>
<li>Line 35: <code>req.user</code><code>req.auth</code></li>
</ul>
<pre><code>// Before
import { jwtMiddleware, requireAuth } from './middleware/auth';
...
app.get( '/api/auth/me', requireAuth, ( req, res ) =&gt; 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 ) =&gt; res.json( req.auth ) );</code></pre>
</div>
<div class="card">
<h3>Change 5 — Update source/server/projectAccess.ts</h3>
<p>
Replace the local <code>JwtUser</code> import with <code>AuthPayload</code>
from the connector. The type shape is identical — this is a rename only.
</p>
<pre><code>// 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</code></pre>
</div>
<div class="card">
<h3>Change 6 — Rename req.user → req.auth in route files</h3>
<p>19 occurrences across 5 files. All are mechanical replacements — the shape of
the object does not change.</p>
<ul style="margin-top:0.5rem;line-height:1.9;font-size:0.9rem;color:var(--muted)">
<li><code>source/server/routes/files.ts</code> — 7 occurrences</li>
<li><code>source/server/routes/projects.ts</code> — 7 occurrences</li>
<li><code>source/server/routes/layout.ts</code> — 2 occurrences</li>
<li><code>source/server/routes/rojos.ts</code> — 2 occurrences</li>
<li><code>source/server/routes/userSettings.ts</code> — 2 occurrences</li>
</ul>
<p style="margin-top:0.75rem">
The <code>checkAccess</code> calls pass <code>req.user!</code> as the second
argument. After change 5, <code>projectAccess.ts</code> expects <code>AuthPayload</code>
— the rename makes the types consistent. Change every <code>req.user</code> to
<code>req.auth</code> and every <code>req.user!</code> to <code>req.auth!</code>.
</p>
</div>
<div class="card">
<h3>Change 7 — Remove the non-API redirect branch from jwtMiddleware</h3>
<p>
In the old local <code>auth.ts</code> (now deleted), <code>jwtMiddleware</code>
redirected non-API requests with an expired token to
<code>AUTH_HOST/api/auth/refresh-session</code>.
The connector's <code>jwtMiddleware</code> 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.
</p>
</div>
<div class="card">
<h3>Change 8 — Remove the will-redirect intercept from electron/main.ts</h3>
<p>
The <code>mainWindow.webContents.on('will-redirect', ...)</code> block
(lines 136158) exists solely to intercept the <code>/api/auth/refresh-session</code>
redirect that the old <code>jwtMiddleware</code> emitted. Once the redirect is
gone, this intercept is dead code and should be removed.
</p>
<pre><code>// Remove this entire block from electron/main.ts:
mainWindow.webContents.on( 'will-redirect', async ( event, url ) =&gt;
{
if ( url.includes( '/api/auth/refresh-session' ) )
{
// ... entire block
}
} );</code></pre>
<p style="margin-top:0.75rem">
The <code>refreshTokens()</code> helper function defined above it can also
be deleted — Electron no longer needs to do its own refresh because the
server-side <code>jwtMiddleware</code> handles it transparently.
</p>
</div>
<div class="card">
<h3>Verify</h3>
<pre><code>journalctl -u roject -f | grep '\[auth\]'</code></pre>
<p style="margin-top:0.5rem">
Log into Roject, wait 65 minutes (or temporarily set <code>ACCESS_TOKEN_TTL=65s</code>
in the test environment), then make an API call. Expect to see the
<code>[auth] tryRefresh → ...</code> sequence and a transparent recovery.
</p>
</div>
</section>
<!-- ─── styles ───────────────────────────────────────────────── -->
<section>
<h2>styles</h2>
<div class="card">
<h3>Current state</h3>
<p>
styles has no <code>jwtMiddleware</code> at all. <code>requireAuth</code> 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.
<code>STYLES_RULES</code> is currently defined inside
<code>middleware/requireAccess.ts</code> — it needs to move to <code>index.ts</code>.
</p>
</div>
<div class="card">
<h3>Change 1 — Add the submodule</h3>
<pre><code>git submodule add git@community.rokojori.com:Rokojori/rokojori-auth-connector.git source/auth-connector
git submodule update --init</code></pre>
</div>
<div class="card">
<h3>Change 2 — tsconfig path alias</h3>
<pre><code>"paths": {
"auth-connector/*": ["./source/auth-connector/source/*"]
}</code></pre>
</div>
<div class="card">
<h3>Change 3 — Delete both middleware files</h3>
<pre><code>source/server/middleware/requireAuth.ts ← delete
source/server/middleware/requireAccess.ts ← delete</code></pre>
<p style="margin-top:0.5rem">
Do this after updating <code>index.ts</code> so the service never references
the deleted files.
</p>
</div>
<div class="card">
<h3>Change 4 — Rewrite source/server/index.ts imports and wiring</h3>
<pre><code>// 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' },
];</code></pre>
<p style="margin-top:0.75rem">
Then add <code>jwtMiddleware</code> as global middleware — place it after
<code>cookieParser()</code> and before the route registrations:
</p>
<pre><code>app.use( cookieParser() );
app.use( jwtMiddleware ); // ← add this line
// rest of routes unchanged...</code></pre>
<p style="margin-top:0.75rem">
The route registrations themselves do not change — they already use
<code>requireAuth</code> and <code>requireAccess( STYLES_RULES )</code>.
</p>
</div>
<div class="card">
<h3>Verify</h3>
<pre><code>journalctl -u styles-rokojori -f | grep '\[auth\]'</code></pre>
<p style="margin-top:0.5rem">
Hit <code>/api/fonts</code> with an expired token and a valid refresh cookie.
Expect transparent recovery in the log.
</p>
</div>
</section>
<!-- ─── tunnel ───────────────────────────────────────────────── -->
<section>
<h2>tunnel</h2>
<div class="card">
<h3>Current state</h3>
<p>
tunnel has no <code>jwtMiddleware</code>. Expired tokens on
<code>/api/tunnels</code> routes always return 401 with no recovery.
Two routes do their own inline JWT handling and must be treated carefully:
</p>
<ul style="margin-top:0.75rem;line-height:1.9;font-size:0.9rem;color:var(--muted)">
<li>
<strong style="color:var(--text)">routes/agent.ts</strong> — handles WebSocket
upgrades; cannot use Express middleware. Has its own <code>jwt.verify()</code>
call. This is correct and does not change — WebSocket upgrades bypass Express
middleware entirely.
</li>
<li>
<strong style="color:var(--text)">routes/proxy.ts</strong><code>softAuth()</code>
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 <code>jwtMiddleware</code>.
</li>
</ul>
</div>
<div class="card">
<h3>Change 1 — Add the submodule</h3>
<pre><code>git submodule add git@community.rokojori.com:Rokojori/rokojori-auth-connector.git source/auth-connector
git submodule update --init</code></pre>
</div>
<div class="card">
<h3>Change 2 — tsconfig path alias</h3>
<pre><code>"paths": {
"auth-connector/*": ["./source/auth-connector/source/*"]
}</code></pre>
</div>
<div class="card">
<h3>Change 3 — Delete source/server/middleware/requireAuth.ts</h3>
<p>Delete after updating all importers below.</p>
</div>
<div class="card">
<h3>Change 4 — Update source/server/index.ts</h3>
<p>
Add <code>jwtMiddleware</code> scoped to the <code>/api/tunnels</code> routes.
The proxy route (<code>/t</code>) must <strong style="color:var(--text)">not</strong>
get <code>jwtMiddleware</code> — it does its own soft auth.
</p>
<pre><code>// 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 );</code></pre>
</div>
<div class="card">
<h3>Change 5 — Update routes/tunnels.ts</h3>
<pre><code>// Before
import { requireAuth } from '../middleware/requireAuth';
// After
import { requireAuth } from 'auth-connector/server/auth';</code></pre>
<p style="margin-top:0.5rem">
All uses of <code>req.auth</code> in this file are already correct — no other
changes needed.
</p>
</div>
<div class="card">
<h3>Change 6 — Update routes/proxy.ts</h3>
<p>
<code>proxy.ts</code> imports <code>AuthPayload</code> and
<code>extractBearer</code> from the old local <code>requireAuth.ts</code>.
<code>extractBearer</code> is internal to the connector and not exported.
Inline it locally — it is three lines.
</p>
<pre><code>// 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;
}</code></pre>
<p style="margin-top:0.75rem">
The <code>softAuth()</code> function and all other logic in proxy.ts stays
unchanged.
</p>
</div>
<div class="card">
<h3>Change 7 — Update routes/agent.ts</h3>
<pre><code>// Before
import { AuthPayload } from '../middleware/requireAuth';
// After
import type { AuthPayload } from 'auth-connector/shared/types';</code></pre>
<p style="margin-top:0.5rem">
The inline <code>jwt.verify()</code> in the WebSocket upgrade handler is
correct and stays — WebSocket upgrades bypass Express middleware.
</p>
</div>
<div class="card">
<h3>Verify</h3>
<pre><code>journalctl -u tunnel-rokojori -f | grep '\[auth\]'</code></pre>
<p style="margin-top:0.5rem">
Make an authenticated API call to <code>/api/tunnels</code> with an expired
access token and a valid refresh cookie. Expect transparent recovery in the log.
Then make a request to a public tunnel (<code>/t/:id/...</code>) without any
token — expect it to pass through without hitting the auth log at all.
</p>
</div>
</section>
<!-- ─── Execution order ───────────────────────────────────────── -->
<section>
<h2>Execution order</h2>
<div class="card">
<ol style="line-height:2.2;font-size:0.9rem;color:var(--muted)">
<li>
<strong style="color:var(--text)">rokojori-auth</strong> — fix
<code>ACCESS_TOKEN_TTL</code> to <code>'1h'</code>, redeploy, verify startup log.
</li>
<li>
<strong style="color:var(--text)">Create the Gitea repo</strong> for
<code>rokojori-auth-connector</code> and push the local directory to it.
</li>
<li>
<strong style="color:var(--text)">roject</strong> — add submodule, make all
changes, redeploy. This is the most complex change (19 renames + Electron
cleanup). Test thoroughly before moving to the others.
</li>
<li>
<strong style="color:var(--text)">styles</strong> — add submodule, rewire
index.ts, delete old middleware files, redeploy.
</li>
<li>
<strong style="color:var(--text)">tunnel</strong> — add submodule, add
jwtMiddleware to API routes, update imports in tunnels/proxy/agent,
delete old requireAuth.ts, redeploy.
</li>
</ol>
</div>
<div class="card">
<h3>After each repo</h3>
<p>
Before moving to the next repo: confirm auth still works end-to-end —
login, wait for the access token to expire (check with
<code>journalctl ... | grep '[auth]'</code>), and confirm the first API call
after expiry succeeds transparently.
</p>
</div>
</section>
<footer>
Roject &mdash; auth connector rewrite plan
</footer>
</div>
<script>var NAV_ROOT = '../';</script>
<script src="../_assets_/nav-data.js"></script>
<script src="../_assets_/nav.js"></script>
</body>
</html>

View File

@ -45,9 +45,20 @@
<p> <p>
User accounts are managed by <strong>rokojori-auth</strong> at User accounts are managed by <strong>rokojori-auth</strong> at
<code>account.rokojori.com</code> — registration, login, JWT issuance, refresh <code>account.rokojori.com</code> — registration, login, JWT issuance, refresh
token rotation, roles, and account deletion. Roject verifies the shared token rotation, roles, and account deletion.
<code>accessToken</code> JWT cookie and transparently refreshes expired tokens </p>
via <code>account.rokojori.com/api/auth/refresh-session</code>. <p style="margin-top:0.75rem">
<strong>Token refresh — two layers:</strong>
rokojori-auth has a page-level middleware (before <code>express.static</code>) that
redirects any page request carrying an expired <code>accessToken</code> to
<code>/api/auth/refresh-session</code>, which rotates both cookies and redirects back.
Roject's <code>jwtMiddleware</code> handles mid-session API calls: when a
<code>TokenExpiredError</code> hits an <code>/api/</code> route and a
<code>refreshToken</code> cookie is present, it calls
<code>POST AUTH_INTERNAL_HOST/api/auth/refresh</code> server-side, sets the new
cookies on the response, and continues transparently. <code>AUTH_INTERNAL_HOST</code>
defaults to <code>AUTH_HOST</code>; set it to <code>http://localhost:3001</code>
in production to bypass nginx for the server-to-server call.
</p> </p>
</div> </div>
@ -95,7 +106,13 @@
The <strong>Electron Tunnel Agent</strong> (<code>electron-agent/</code>) is a The <strong>Electron Tunnel Agent</strong> (<code>electron-agent/</code>) is a
Windows system-tray app: login via account.rokojori.com, a tunnel list with 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 Start / Stop / Delete / Create, and a green status dot when the agent WebSocket
is connected. Tokens are persisted in <code>userData/tokens.json</code>. is connected. Tokens are persisted in <code>userData/tokens.json</code> and
automatically refreshed — any 401 from the tunnel API triggers
<code>POST /api/auth/refresh</code> 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
<code>TunnelAgent</code> uses a <code>getToken()</code> getter instead of a
static token so every WebSocket reconnect picks up the current access token.
</p> </p>
<p style="margin-top:0.75rem"> <p style="margin-top:0.75rem">
The relay uses a three-message streaming protocol over the agent WebSocket: The relay uses a three-message streaming protocol over the agent WebSocket: