session 2026-07-18: Electron local dev fixes, nav z-index, board/outline/history update

Electron fixes:
- extractToken (auth-connector): Bearer header checked before cookie — prevents stale
  Electron session cookie from winning over injected token
- JWT_CLOCK_TOLERANCE env var: passed to jwt.verify as clockTolerance; set to 7200
  in .env to absorb ~65 min clock skew between Windows dev machine and prod auth server
- Startup token refresh: main.ts calls POST /api/auth/refresh before opening main window;
  shows login on failure instead of opening with expired tokens
- Quit-on-login fix: createMainWindow() is async; login-success now awaits it before
  closing the login window (zero windows → app.quit() race was killing the process)
- Credential persistence: email + password stored in userData; remember-me checkbox
  controls save behaviour; clear button removes saved files; fields pre-fill on load
- Electron session cookies cleared in createMainWindow() to avoid stale token reuse

CSS: z-index: 10 on .pld-nav (project-list-default) — fixed mobile nav buried under rows

Boards: cleared Done lane, added Electron fixes and nav z-index entries; backlog MVP
entry for local testing solution; bugs.html: 401-handling bug moved to Done.
Outline: auth card updated with extractToken order note and JWT_CLOCK_TOLERANCE docs.
History: Friday 18 July entry expanded with session 2 cards.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Rokojori 2026-07-18 08:37:47 +02:00
parent 6e3fe19395
commit 4a72363aaf
12 changed files with 552 additions and 128 deletions

View File

@ -45,7 +45,8 @@
letter-spacing: 0.05em;
}
input {
input[type="email"],
input[type="password"] {
width: 100%;
padding: 0.65rem 0.85rem;
background: #1a1a1a;
@ -58,9 +59,53 @@
transition: border-color 0.15s;
}
input:focus { border-color: #555; }
input[type="email"]:focus,
input[type="password"]:focus { border-color: #555; }
button {
.remember-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.2rem;
}
.remember-label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.82rem;
color: #777;
cursor: pointer;
text-transform: none;
letter-spacing: 0;
margin-bottom: 0;
}
.remember-label input[type="checkbox"] {
width: 14px;
height: 14px;
margin: 0;
accent-color: #aaa;
cursor: pointer;
}
.btn-clear {
font-size: 0.78rem;
color: #555;
background: none;
border: none;
padding: 0;
cursor: pointer;
width: auto;
margin: 0;
font-weight: 400;
transition: color 0.15s;
}
.btn-clear:hover { color: #e05555; background: none; }
.btn-clear:disabled { display: none; }
button#btn {
width: 100%;
padding: 0.7rem;
background: #e8e8e8;
@ -74,8 +119,8 @@
margin-top: 0.4rem;
}
button:hover { background: #fff; }
button:disabled { background: #333; color: #666; cursor: default; }
button#btn:hover { background: #fff; }
button#btn:disabled { background: #333; color: #666; cursor: default; }
.error {
font-size: 0.83rem;
@ -97,15 +142,25 @@
<label for="password">Password</label>
<input id="password" type="password" placeholder="••••••••" autocomplete="current-password">
<div class="remember-row">
<label class="remember-label">
<input type="checkbox" id="remember" checked>
Remember me
</label>
<button class="btn-clear" id="btn-clear">Clear saved</button>
</div>
<button id="btn">Sign in</button>
<p class="error" id="error"></p>
</div>
<script>
const emailEl = document.getElementById( 'email' );
const emailEl = document.getElementById( 'email' );
const passwordEl = document.getElementById( 'password' );
const btn = document.getElementById( 'btn' );
const errorEl = document.getElementById( 'error' );
const rememberEl = document.getElementById( 'remember' );
const btn = document.getElementById( 'btn' );
const btnClear = document.getElementById( 'btn-clear' );
const errorEl = document.getElementById( 'error' );
async function login() {
const email = emailEl.value.trim();
@ -116,7 +171,7 @@
btn.textContent = 'Signing in…';
errorEl.textContent = '';
const result = await window.electronAuth.login( email, password );
const result = await window.electronAuth.login( email, password, rememberEl.checked );
if ( result.ok ) {
window.electronAuth.loginSuccess();
@ -127,10 +182,28 @@
}
}
btnClear.addEventListener( 'click', async () => {
await window.electronAuth.clearCredentials();
emailEl.value = '';
passwordEl.value = '';
rememberEl.checked = true;
btnClear.disabled = true;
emailEl.focus();
} );
btn.addEventListener( 'click', login );
document.addEventListener( 'keydown', ( e ) => { if ( e.key === 'Enter' ) login(); } );
emailEl.focus();
Promise.all( [ window.electronAuth.lastEmail(), window.electronAuth.lastPassword() ] )
.then( ( [ email, password ] ) => {
const hasSaved = !!email;
if ( email ) emailEl.value = email;
if ( password ) passwordEl.value = password;
btnClear.disabled = !hasSaved;
if ( email && password ) btn.focus();
else if ( email ) passwordEl.focus();
else emailEl.focus();
} );
</script>
</body>
</html>

View File

@ -6,10 +6,16 @@ import https from 'https';
const AUTH_HOST = 'https://account.rokojori.com';
const PORT = 3000;
// ── Persistence helpers ────────────────────────────────────────────────────────
function tokenFile(): string {
return path.join( app.getPath( 'userData' ), 'tokens.json' );
}
function emailFile(): string {
return path.join( app.getPath( 'userData' ), 'last-email.txt' );
}
interface Tokens {
accessToken: string;
refreshToken: string;
@ -32,6 +38,33 @@ function clearTokens(): void {
try { fs.unlinkSync( tokenFile() ); } catch { /* already gone */ }
}
function passwordFile(): string {
return path.join( app.getPath( 'userData' ), 'last-password.txt' );
}
function loadLastEmail(): string {
try { return fs.readFileSync( emailFile(), 'utf-8' ).trim(); } catch { return ''; }
}
function saveLastEmail( email: string ): void {
fs.writeFileSync( emailFile(), email, 'utf-8' );
}
function loadLastPassword(): string {
try { return fs.readFileSync( passwordFile(), 'utf-8' ).trim(); } catch { return ''; }
}
function saveLastPassword( password: string ): void {
fs.writeFileSync( passwordFile(), password, 'utf-8' );
}
function clearCredentials(): void {
try { fs.unlinkSync( emailFile() ); } catch { /* already gone */ }
try { fs.unlinkSync( passwordFile() ); } catch { /* already gone */ }
}
// ── Network ────────────────────────────────────────────────────────────────────
function postJson( url: string, body: unknown ): Promise<unknown> {
return new Promise( ( resolve, reject ) => {
const data = JSON.stringify( body );
@ -61,6 +94,8 @@ function postJson( url: string, body: unknown ): Promise<unknown> {
} );
}
// ── Header injection ───────────────────────────────────────────────────────────
function registerHeaderInjector( getToken: () => string | null ): void {
session.defaultSession.webRequest.onBeforeSendHeaders(
{ urls: [ `http://localhost:${PORT}/*` ] },
@ -73,6 +108,8 @@ function registerHeaderInjector( getToken: () => string | null ): void {
);
}
// ── Windows ────────────────────────────────────────────────────────────────────
let mainWindow: BrowserWindow | null = null;
let loginWindow: BrowserWindow | null = null;
let currentTokens: Tokens | null = null;
@ -94,7 +131,9 @@ function createLoginWindow(): void {
loginWindow.on( 'closed', () => { loginWindow = null; } );
}
function createMainWindow(): void {
async function createMainWindow(): Promise<void> {
await session.defaultSession.clearStorageData( { storages: [ 'cookies' ] } );
mainWindow = new BrowserWindow( {
width: 1400,
height: 900,
@ -109,7 +148,6 @@ function createMainWindow(): void {
const localBase = `http://localhost:${PORT}/`;
// Keep the main window on localhost — any navigation away means the user needs to re-auth
mainWindow.webContents.on( 'will-navigate', ( event, url ) => {
if ( !url.startsWith( localBase ) ) {
event.preventDefault();
@ -123,6 +161,8 @@ function createMainWindow(): void {
mainWindow.on( 'closed', () => { mainWindow = null; } );
}
// ── Server ─────────────────────────────────────────────────────────────────────
function loadEnv(): void {
const envPath = path.join( __dirname, '..', '..', '.env' );
try {
@ -141,8 +181,6 @@ function loadEnv(): void {
function startExpressServer(): void {
loadEnv();
// Tell the server where the project root is so __dirname-relative paths work when compiled.
// build/electron/ → up two levels → project root.
process.env.ROJECT_ROOT = path.join( __dirname, '..', '..' );
const serverPath = path.join( __dirname, '..', 'server', 'server', 'index.js' );
// eslint-disable-next-line @typescript-eslint/no-require-imports
@ -150,15 +188,23 @@ function startExpressServer(): void {
startServer( PORT );
}
// ── App lifecycle ──────────────────────────────────────────────────────────────
app.whenReady().then( () => {
registerHeaderInjector( () => currentTokens?.accessToken ?? null );
ipcMain.handle( 'auth:login', async ( _event, email: string, password: string ) => {
ipcMain.handle( 'auth:login', async ( _event, email: string, password: string, remember: boolean ) => {
try {
const result = await postJson( `${AUTH_HOST}/api/auth/login`, { email, password } ) as Record<string, unknown>;
if ( result.accessToken && result.refreshToken ) {
currentTokens = { accessToken: result.accessToken as string, refreshToken: result.refreshToken as string };
saveTokens( currentTokens );
if ( remember ) {
saveLastEmail( email );
saveLastPassword( password );
} else {
clearCredentials();
}
return { ok: true };
}
return { ok: false, error: ( result.error as string ) ?? 'Login failed' };
@ -167,18 +213,40 @@ app.whenReady().then( () => {
}
} );
ipcMain.handle( 'auth:last-email', () => loadLastEmail() );
ipcMain.handle( 'auth:last-password', () => loadLastPassword() );
ipcMain.handle( 'auth:clear-credentials', () => { clearCredentials(); } );
ipcMain.on( 'auth:login-success', () => {
loginWindow?.close();
createMainWindow();
// Create the main window first, close login only after it exists.
// Closing login before main is ready triggers window-all-closed → app quit.
createMainWindow().then( () => loginWindow?.close() );
} );
startExpressServer();
// Give the server a moment to bind before loading the window
setTimeout( () => {
setTimeout( async () => {
currentTokens = loadTokens();
if ( currentTokens ) {
createMainWindow();
try {
const result = await postJson(
`${AUTH_HOST}/api/auth/refresh`,
{ refreshToken: currentTokens.refreshToken }
) as Record<string, unknown>;
if ( result.accessToken && result.refreshToken ) {
currentTokens = { accessToken: result.accessToken as string, refreshToken: result.refreshToken as string };
saveTokens( currentTokens );
createMainWindow();
} else {
clearTokens();
currentTokens = null;
createLoginWindow();
}
} catch {
clearTokens();
currentTokens = null;
createLoginWindow();
}
} else {
createLoginWindow();
}
@ -186,7 +254,7 @@ app.whenReady().then( () => {
app.on( 'activate', () => {
if ( BrowserWindow.getAllWindows().length === 0 ) {
if ( currentTokens ) createMainWindow();
if ( currentTokens ) void createMainWindow();
else createLoginWindow();
}
} );

View File

@ -1,8 +1,14 @@
import { contextBridge, ipcRenderer } from 'electron';
contextBridge.exposeInMainWorld( 'electronAuth', {
login: ( email: string, password: string ) =>
ipcRenderer.invoke( 'auth:login', email, password ),
login: ( email: string, password: string, remember: boolean ) =>
ipcRenderer.invoke( 'auth:login', email, password, remember ),
loginSuccess: () =>
ipcRenderer.send( 'auth:login-success' ),
lastEmail: () =>
ipcRenderer.invoke( 'auth:last-email' ),
lastPassword: () =>
ipcRenderer.invoke( 'auth:last-password' ),
clearCredentials: () =>
ipcRenderer.invoke( 'auth:clear-credentials' ),
} );

@ -1 +1 @@
Subproject commit 0b06c252a37b7e957be5767b1821c5cb63123918
Subproject commit cb2fba6e8b4a7c8ed236113cd40084e100df4021

View File

@ -18,7 +18,7 @@ project-list-default {
position: fixed;
overflow: visible;
width: 100vw;
z-index: 10;
}
/* Logo: wrapper = inner area (570×240 at 0.35x = 200×84).
@ -104,6 +104,7 @@ project-list-default {
flex-direction: column;
gap: 0.1rem;
margin-bottom: 3rem;
margin-top: 4em;
}
@keyframes pld-row-in {

View File

@ -51,6 +51,7 @@ var NAV_DATA = {
title: 'History',
path: 'history/index.html',
children: [
{ title: 'Friday, 18 July 2026', path: 'history/2026/07-July/18-Friday/index.html' },
{ title: 'Wednesday, 16 July 2026', path: 'history/2026/07-July/16-Wednesday/index.html' },
{ title: 'Tuesday, 15 July 2026', path: 'history/2026/07-July/15-Tuesday/index.html' },
{ title: 'Monday, 14 July 2026', path: 'history/2026/07-July/14-Monday/index.html' },

View File

@ -22,6 +22,18 @@
<div class="lane">
<div class="lane-header">MVP</div>
<task-item class="blue hide-content">
<task-title>Local testing solution for the whole rokojori network</task-title>
<task-content>
A local dev setup that runs all services together: rokojori-auth, styles, tunnel,
and roject. Needs hosts file entries for *.local.rokojori.com subdomains so auth
cookies flow correctly, per-service .env.local files pointing at each other, and
a startup script to launch everything at once. Optionally a local reverse proxy
(Caddy) to avoid port numbers in URLs. Currently, the Electron app is the preferred
workaround for local testing since it injects auth headers directly.
</task-content>
</task-item>
<task-item class="blue hide-content">
<task-title>Local Git Repository Integration</task-title>
<task-content>

View File

@ -22,16 +22,6 @@
<div class="lane">
<div class="lane-header">Critical</div>
<task-item class="red hide-content">
<task-title>401 Not Handled in Data-Fetching Components</task-title>
<task-content>
Components that fetch data do not handle 401 responses gracefully — they crash
when the API returns an error object instead of an array. Groups and the old
projects page have been removed; the current surfaces to check are project-home
and its theme components (project-list-default). Should show an appropriate
message or redirect to the refresh-session endpoint when a 401 is received.
</task-content>
</task-item>
</div>
@ -87,6 +77,16 @@
</task-content>
</task-item>
<task-item class="green hide-content">
<task-title>401 Not Handled in Data-Fetching Components</task-title>
<task-content>
Fixed. editor-shell checks GET /api/auth/me on startup and redirects to '/'
on 401. rokojori-auth's page-level middleware no longer redirects on an expired
token (was looping to the non-existent refresh-session route) — it calls next()
instead, so login is no longer blocked by an expired access token.
</task-content>
</task-item>
</div>
</div>

View File

@ -22,18 +22,6 @@
<div class="lane">
<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>
@ -53,14 +41,7 @@
</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>
@ -181,6 +162,26 @@
</task-content>
</task-item>
<task-item class="blue hide-content">
<task-title>Ensure time is not depending on the user's clock</task-title>
<task-content>
JWT verification on the local server failed because the Windows client clock was
~65 minutes ahead of the production auth server clock. Any time-based logic that
compares client-side time against server-issued timestamps (JWT exp, token TTL,
session validity) is broken when clocks diverge.
Work to do:
— Audit all places where Date.now() / new Date() is used for security or
session decisions; replace with server-authoritative time where possible.
— On the auth side: use clockTolerance in jwt.verify as a configurable
escape hatch (JWT_CLOCK_TOLERANCE env var, already added for local dev).
— Write a developer guide covering: why user/client clock cannot be trusted,
how to use server time for all authoritative checks, how to diagnose clock
skew issues, and the JWT_CLOCK_TOLERANCE workaround for local dev.
— Consider syncing advice in the local dev setup docs (future local-dev task).
</task-content>
</task-item>
</div>
<div class="lane">
@ -227,73 +228,30 @@
<div class="lane-header">Done</div>
<task-item class="green hide-content">
<task-title>rokojori-auth: page-level token refresh middleware</task-title>
<task-title>Mobile: nav bar z-index too low on projects / index view</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.
Added z-index: 10 to .pld-nav in project-list-default.css.
The nav has position: fixed but lacked a z-index, so stacking contexts
from position: relative project rows buried it on mobile.
Overlays remain above at z-index: 200.
</task-content>
</task-item>
<task-item class="green hide-content">
<task-title>Tunnel Agent: token refresh on 401, logout, getToken getter</task-title>
<task-title>Electron Roject app: local dev fixes</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-title>Fix session logout after ~1 hour — transparent token refresh</task-title>
<task-content>
Root cause: jwtMiddleware only redirected to refresh-session for page navigations.
API requests with an expired token fell through with req.user = undefined, causing
requireAuth to return 401 — no retry, no refresh, silent failure mid-session.
Fix: when TokenExpiredError hits an API route and a refreshToken cookie is present,
jwtMiddleware now calls POST account.rokojori.com/api/auth/refresh server-side,
sets the new accessToken and refreshToken cookies on the response, decodes the new
JWT into req.user, and calls next(). Completely transparent — no frontend changes.
If refresh fails (expired or missing refresh token) the request falls through to
requireAuth which returns 401 as before.
</task-content>
</task-item>
<task-item class="green hide-content">
<task-title>CI deploy email notification</task-title>
<task-content>
EmailService added to Roject (SMTP via Nodemailer, same credentials as rokojori-auth).
Startup email sent from startServer() listen callback; deploy email sent from
/api/deploy after signature verification passes. reportEmail defined as a static
field on EmailService.
</task-content>
</task-item>
<task-item class="green hide-content">
<task-title>Electron Desktop App Shell</task-title>
<task-content>
Login window, JWT auth via API, Authorization header injection, token persistence,
ROJECT_ROOT path fix, ELECTRON_RUN_AS_NODE workaround. Full local-only Electron app working.
Several fixes to make the Electron app usable for local development:
— extractToken now checks Authorization Bearer before the accessToken cookie,
so stale browser cookies cannot shadow the injected token.
— JWT_CLOCK_TOLERANCE env var (seconds) passed to jwt.verify as clockTolerance;
set to 7200 in .env to absorb clock skew between local and production auth server.
— Startup token refresh: on launch with saved tokens, main.ts calls
POST account.rokojori.com/api/auth/refresh before opening the main window;
shows login window if refresh fails.
— Quit-on-login fix: createMainWindow() is now async; login-success handler
awaits it before closing the login window, preventing window-all-closed → quit.
— Credential persistence: email and password saved to userData on successful login;
remember-me checkbox controls whether they are saved; clear button deletes them.
</task-content>
</task-item>

View File

@ -0,0 +1,242 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Friday, 18 July 2026 — Roject</title>
<link rel="stylesheet" href="../../../../_assets_/styles.css">
<link rel="stylesheet" href="../../../../_assets_/nav.css">
</head>
<body>
<div class="page">
<header>
<p class="date">Friday, 18 July 2026</p>
<h1>Session History</h1>
<p class="subtitle">CodeMirror syntax highlighting system, smart file-tree open, rokojori-auth login fix. Electron local dev fixes: stale cookie auth, clock tolerance, quit-on-login, credential persistence. Nav z-index fix.</p>
</header>
<section>
<h2>What we built</h2>
<div class="card">
<h3>File tree: single-click smart file open</h3>
<p>
Clicking a file in the file tree now opens it intelligently rather than always
targeting any available panel:
</p>
<ul style="line-height:1.9;margin-top:0.75rem">
<li>If the file is already open in any panel, that panel's tab is focused — no
duplicate open, no reload.</li>
<li>Otherwise, the next available editor panel of the correct type that is not
pinned and not dirty is used; a new panel is created in the active section
if none qualifies.</li>
<li>Pinned editors (<code>_pinned</code> property on the panel element) are
excluded from the available pool entirely.</li>
</ul>
</div>
<div class="card">
<h3>CodeMirror vendor syntax modes</h3>
<p>
Four minified CodeMirror 5 mode files added to <code>source/vendor/</code> and
loaded in <code>editor.html</code>:
</p>
<ul style="line-height:1.9;margin-top:0.75rem">
<li><code>cm-mode-clike.min.js</code> — C/C++/Java; used for GLSL and GDShader</li>
<li><code>cm-mode-python.min.js</code> — Python; used for GDScript (<code>.gd</code>)</li>
<li><code>cm-mode-shell.min.js</code> — shell scripts (<code>.sh</code>)</li>
<li><code>cm-mode-yaml.min.js</code> — YAML and YML</li>
</ul>
<p style="margin-top:0.75rem">
Extension mappings in <code>code-panel</code>'s <code>_resolveMode</code>:
<code>.yaml</code>/<code>.yml</code><code>yaml</code>,
<code>.sh</code><code>shell</code>,
<code>.gd</code><code>python</code>,
<code>.glsl</code>/<code>.gdshader</code>/<code>.gdshaderinc</code><code>clike</code>,
<code>.cs</code><code>rokojori-cs</code>.
</p>
</div>
<div class="card">
<h3>CodeMirror lexer mode system — BrowserLexer + CodeMirrorLexerMode</h3>
<p>
A custom, self-contained browser lexer and CodeMirror mode wrapper built in
<code>source/components/code-panel/</code>:
</p>
<ul style="line-height:1.9;margin-top:0.75rem">
<li><code>BrowserLexer.ts</code> — zero external imports. Inlines
<code>makeSticky()</code> (adds <code>/y</code> flag to regexes).
<code>BrowserMatcher</code> uses sticky regex + <code>lastIndex</code> for
positional matching. <code>BrowserLexer</code> holds named mode lists of
matchers. Exports a <code>cLikeLexer()</code> factory with matchers for all
C-like token types (comments, strings, numbers, operators, keywords,
identifiers, etc.).</li>
<li><code>CodeMirrorLexerMode.ts</code> — wraps any <code>BrowserLexer</code>
into a CodeMirror 5 mode object. Supports multi-line block definitions (start
regex → end regex → CSS class; state preserved across lines). Supports named
keyword sets: sets of words that override the base CSS class for a given token
type (e.g. mapping C# keywords from <code>CWORD</code><code>keyword</code>).
Keyword sets are mutable at runtime — add/remove/update without recreating
the mode. <code>refresh(cm)</code> forces CodeMirror to re-tokenize by
re-setting the mode option.</li>
<li><code>CSharpMode.ts</code> — creates <code>csharpMode</code> using
<code>cLikeLexer()</code> with a multi-line <code>/* ... */</code> comment
block and a keyword set of ~70 C# keywords. Registered in CodeMirror as
<code>'rokojori-cs'</code>.</li>
</ul>
<p style="margin-top:0.75rem">
The browser-only design (no <code>library-ts</code> dependency) avoids the
<code>moduleResolution: "bundler"</code> / <code>ts-node</code> conflict:
library-ts compiles without <code>.js</code> extensions (works for
<code>ts-node</code>), while browser ES modules require explicit extensions.
Keeping the lexer self-contained in <code>code-panel/</code> eliminates the
tension entirely.
</p>
</div>
<div class="card">
<h3>rokojori-auth: remove broken refresh-session redirect</h3>
<p>
The page-level middleware in <code>rokojori-auth/source/server/index.ts</code>
was redirecting expired <code>accessToken</code> requests to
<code>/api/auth/refresh-session?redirect=...</code> — a route that no longer
exists. This blocked login entirely (redirect loop on first visit after token
expiry). Fixed by replacing the entire error branch with <code>next()</code>:
the middleware now passes through on any token error. Transparent refresh for
Roject API calls is handled server-side by Roject's own
<code>jwtMiddleware</code>.
</p>
</div>
</section>
<section>
<h2>Key decisions</h2>
<div class="card">
<p>
<strong>Self-contained BrowserLexer instead of reusing library-ts CLikeLexer.</strong>
Importing from library-ts pulled in extensionless relative imports that break the
browser ES module loader (<code>NS_ERROR_CORRUPTED_CONTENT</code>). Adding
<code>.js</code> extensions to library-ts imports broke <code>ts-node</code>
(CommonJS cannot remap <code>.js</code><code>.ts</code>). The cleanest fix
was a purpose-built, dependency-free browser lexer duplicating only what the
code editor needs.
</p>
</div>
<div class="card">
<p>
<strong>Single-click open, not double-click.</strong> The board task said
double-click, but single-click is more natural for an IDE file tree (matches
VS Code, JetBrains). The smart-targeting logic (focus existing, skip pinned)
makes single-click safe — it never disrupts an intentionally pinned panel.
</p>
</div>
</section>
<section>
<h2>Session 2 — Electron local dev fixes</h2>
<div class="card">
<h3>Mobile: nav z-index fix</h3>
<p>
<code>.pld-nav</code> in <code>project-list-default.css</code> has
<code>position: fixed</code> but no <code>z-index</code>. Stacking contexts
created by <code>position: relative</code> project rows on mobile buried the nav
underneath them. Fixed with <code>z-index: 10</code>. Overlays remain above at
<code>z-index: 200</code>.
</p>
</div>
<div class="card">
<h3>Bearer-before-cookie in <code>extractToken</code></h3>
<p>
The root cause of Electron auth failures: <code>extractToken()</code> in
<code>source/auth-connector/source/server/auth.ts</code> was checking the
<code>accessToken</code> cookie before the <code>Authorization</code> header.
Electron's Chromium session had a stale <code>accessToken</code> cookie that took
priority over the fresh Bearer token injected via
<code>session.defaultSession.webRequest.onBeforeSendHeaders</code>. Fixed by
reversing the check order: Bearer header wins, cookie is the fallback.
</p>
<p style="margin-top:0.75rem">
Additionally, <code>createMainWindow()</code> now calls
<code>session.defaultSession.clearStorageData({ storages: ['cookies'] })</code>
before creating the window, preventing the stale cookie from accumulating across
Electron restarts.
</p>
</div>
<div class="card">
<h3>JWT clock skew — <code>JWT_CLOCK_TOLERANCE</code></h3>
<p>
Fresh tokens issued by the production auth server (1 h TTL) appeared expired
immediately on the Windows dev machine because the local clock was ~65 minutes
ahead of the server. Every <code>jwt.verify</code> call returned
<code>TokenExpiredError</code> seconds after login.
</p>
<p style="margin-top:0.75rem">
Fix: <code>jwt.verify</code> accepts a <code>clockTolerance</code> option. An env
var <code>JWT_CLOCK_TOLERANCE</code> (integer, seconds; default 0) is now read and
passed as <code>clockTolerance</code> when non-zero. Set to <code>7200</code> in
<code>.env</code> for local development. The underlying audit task (time must never
depend on the user's clock) is on the board.
</p>
</div>
<div class="card">
<h3>Electron startup token refresh + quit-on-login fix</h3>
<p>
Two issues fixed in <code>electron/main.ts</code>:
</p>
<ul style="line-height:1.9;margin-top:0.75rem">
<li><strong>Startup refresh:</strong> on launch with saved tokens, the app now
calls <code>POST account.rokojori.com/api/auth/refresh</code> before opening
the main window. Fresh tokens are saved; if refresh fails the login window is
shown instead. This prevents using expired access tokens on startup.</li>
<li><strong>Quit-on-login race:</strong> <code>createMainWindow()</code> was made
<code>async</code> (to await the cookie clear), but <code>loginWindow?.close()</code>
was called before awaiting it. Zero open windows → <code>window-all-closed</code>
<code>app.quit()</code>. Fixed by:
<code>createMainWindow().then(() =&gt; loginWindow?.close())</code>.</li>
</ul>
</div>
<div class="card">
<h3>Electron login: credential persistence + remember-me</h3>
<p>
The login window (<code>electron/login.html</code>) was extended:
</p>
<ul style="line-height:1.9;margin-top:0.75rem">
<li><strong>Remember me</strong> checkbox (checked by default): when checked,
email and password are saved to <code>userData/last-email.txt</code> and
<code>userData/last-password.txt</code> on successful login; when unchecked, any
saved files are deleted.</li>
<li><strong>Clear saved</strong> button: calls
<code>auth:clear-credentials</code> IPC, wipes the fields, and disables itself.
Hidden when no credentials are saved.</li>
<li>On load, both fields are pre-filled from saved values; focus goes to the Sign
in button if both are filled, the password field if only email is saved, or the
email field if nothing is saved.</li>
</ul>
<p style="margin-top:0.75rem">
IPC surface added to <code>electron/preload.ts</code>:
<code>lastEmail()</code>, <code>lastPassword()</code>, <code>clearCredentials()</code>.
</p>
</div>
</section>
<footer>
Roject &mdash; session history
</footer>
</div>
<script>var NAV_ROOT = '../../../../';</script>
<script src="../../../../_assets_/nav-data.js"></script>
<script src="../../../../_assets_/nav.js"></script>
</body>
</html>

View File

@ -19,6 +19,11 @@
<section>
<h2>2026 — July</h2>
<div class="card">
<h3><a href="2026/07-July/18-Friday/index.html">Friday, 18 July 2026</a></h3>
<p>CodeMirror syntax highlighting: vendor modes (clike, python, shell, yaml) + custom C# mode built on BrowserLexer + CodeMirrorLexerMode with dynamic keyword sets. File tree single-click smart open (focus existing, skip pinned). rokojori-auth login fix: removed broken refresh-session redirect. Electron local dev fixes: Bearer-before-cookie token extraction, JWT_CLOCK_TOLERANCE clock skew escape hatch, startup token refresh, quit-on-login race fix, credential persistence with remember-me. Mobile nav z-index fix.</p>
</div>
<div class="card">
<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, 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>

View File

@ -48,17 +48,35 @@
token rotation, roles, and account deletion.
</p>
<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>
<strong>Token refresh:</strong>
rokojori-auth's page-level middleware (before <code>express.static</code>)
verifies the <code>accessToken</code> cookie but calls <code>next()</code> on
any error — no redirect on expiry. Transparent refresh for API calls is handled
by Roject's <code>jwtMiddleware</code>: 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, decodes the new JWT into <code>req.user</code>,
and continues transparently. If refresh fails, the request falls through to
<code>requireAuth</code> which returns 401. <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.
in production to bypass nginx. The <code>editor-shell</code> checks
<code>GET /api/auth/me</code> on startup and redirects to <code>/</code> on 401.
</p>
<p style="margin-top:0.75rem">
<strong>Token extraction order (<code>extractToken</code> in <code>auth.ts</code>):</strong>
Bearer header is checked before the <code>accessToken</code> cookie. This means an
explicit <code>Authorization: Bearer ...</code> header always wins — critical for
Electron (which injects tokens via <code>onBeforeSendHeaders</code>), API clients,
and any context where a stale browser cookie might otherwise shadow a fresh token.
</p>
<p style="margin-top:0.75rem">
<strong>Clock skew — <code>JWT_CLOCK_TOLERANCE</code>:</strong>
<code>jwt.verify</code> accepts a <code>clockTolerance</code> option (seconds).
The env var <code>JWT_CLOCK_TOLERANCE</code> (default 0 / unset) is read as an
integer and passed as <code>clockTolerance</code> when non-zero. Set to
<code>7200</code> in <code>.env</code> for local development to absorb clock skew
between the Windows dev machine and the production auth server. Never set this in
production — if you need it there, fix the clock instead.
</p>
</div>
@ -155,9 +173,49 @@
A <code>FileEditorRegistry</code> routes files to the correct panel by extension:
HTML → <code>html-editor-panel</code> (iframe, contenteditable, MutationObserver,
undo/redo, Ctrl+S save); all other text formats → <code>code-panel</code>
(CodeMirror 5, syntax highlighting, dark theme). An optional project-level
<code>workspace/editor/file-editors.json</code> overrides the defaults.
(CodeMirror 5, syntax highlighting, dark theme). Godot file types
(<code>.gd</code>, <code>.gdshader</code>, <code>.gdshaderinc</code>,
<code>.tscn</code>, <code>.tres</code>, <code>.res</code>) are pre-registered.
An optional project-level <code>workspace/editor/file-editors.json</code>
overrides the defaults.
</p>
<p style="margin-top:0.75rem">
Clicking a file in the file tree opens it with smart panel targeting: if the file
is already open in any panel, that panel's tab is focused. Otherwise, the next
available unpinned non-dirty editor of the correct type is used; a new panel is
created in the active section if none qualifies. Pinned panels are never overwritten.
</p>
</div>
<div class="card">
<h3>CodeMirror syntax highlighting</h3>
<p>
Vendor modes bundled: <code>clike</code> (C/C++/Java/GLSL/GDShader),
<code>python</code> (GDScript), <code>shell</code>, <code>yaml</code>.
Extension → mode mappings in <code>_resolveMode</code>:
<code>.yaml</code>/<code>.yml</code><code>yaml</code>,
<code>.sh</code><code>shell</code>,
<code>.gd</code><code>python</code>,
<code>.glsl</code>/<code>.gdshader</code>/<code>.gdshaderinc</code><code>clike</code>,
<code>.cs</code><code>rokojori-cs</code>.
</p>
<p style="margin-top:0.75rem">
<strong>Custom C# mode</strong> is built on a browser-only lexer stack in
<code>source/components/code-panel/</code> (no <code>library-ts</code> dependency —
avoids the <code>ts-node</code> / browser-extension import conflict):
</p>
<ul style="line-height:1.9;margin-top:0.75rem">
<li><code>BrowserLexer.ts</code> — zero imports. <code>BrowserMatcher</code> uses
sticky regexes (<code>/y</code> flag + <code>lastIndex</code>) for positional
matching. <code>cLikeLexer()</code> factory returns a full C-like token set.</li>
<li><code>CodeMirrorLexerMode.ts</code> — wraps a <code>BrowserLexer</code> into a
CodeMirror 5 mode. Supports multi-line blocks (start/end regex + CSS class, state
persisted across lines) and named keyword sets that override the base CSS class for
matching token types at runtime. <code>refresh(cm)</code> forces re-tokenization.</li>
<li><code>CSharpMode.ts</code><code>csharpMode</code> registered as
<code>'rokojori-cs'</code>; ~70 C# keywords mapped from <code>CWORD</code>
<code>keyword</code>.</li>
</ul>
</div>
<div class="card">