Rojects

Project Documentation

Roject

Developer reference for human and agent contributors. Keep this file up to date as the project evolves.

What it is

Roject is a self-hosted, agent-based IDE for working with files and projects. It provides specialised editors for different file types — a WYSIWYG HTML editor, a CodeMirror-backed code editor, and more to come — all within a multi-panel, tab-based workspace. Projects can be hosted on a Roject server (remote) or opened directly from the local filesystem (local), making it usable both as a lightweight self-hosted CMS and as a full desktop development environment. Designed for individual developers or small teams; no external database dependency.

What exists now

Auth & accounts

User accounts are managed by rokojori-auth at account.rokojori.com — registration, login, JWT issuance, refresh token rotation with a grace window, roles, and account deletion.

Refresh token grace window (rokojori-auth): refreshTokens are soft-deleted — usedAt and replacedBy are stamped on first use rather than the row being deleted. A concurrent second refresh within REFRESH_GRACE_TTL (10 s) resolves to the same replacement pair instead of 401ing. This is the baseline correctness guarantee against races; client-side leader election is an optimisation on top.

TokenUpdater (source/auth/TokenUpdater.ts): Central browser auth state machine. State enum: valid | refreshing | expired | network-error, exposed via EventSlot. Triggers: a 5-minute periodic timer and ActivityAnalyser.onActive (fires on mouse, touch, focus, and tab-visibility restore). Each trigger pings GET /api/auth/me; jwtMiddleware handles proactive cookie rotation server-side when within 15 min of expiry (access token is httpOnly, so expiry is unreadable client-side). Web Locks leader election: the first tab acquires roject-token-updater-leader exclusively and broadcasts state to followers via BroadcastChannel. Leader handoff is automatic when the holder tab closes.

GuardedCall (source/auth/GuardedCall.ts): Singleton wrapper for all API calls. Three tiers — user: no retry, throws on failure; editor: 3 retries at 1 s / 3 s / 8 s, then throws; silent: same delays, never throws, console.warn on failure. Pre-flight: blocks when state is expired; waits up to 6 s when refreshing. Wired into Editor.ts (save = user, open = editor) and editor-shell.ts (layout save/load = silent).

Token extraction order (extractToken in auth.ts): Bearer header is checked before the accessToken cookie. An explicit Authorization: Bearer … header always wins — critical for Electron (which injects tokens via onBeforeSendHeaders) and any context where a stale cookie might shadow a fresh token.

Clock skew — JWT_CLOCK_TOLERANCE: jwt.verify accepts a clockTolerance option (seconds). Set JWT_CLOCK_TOLERANCE=7200 in .env for local dev to absorb skew between the Windows dev machine and the production auth server. Never set this in production — fix the clock instead.

New-session endpoint (POST /api/auth/new-session): requireAuth-guarded; mints a fresh independent token pair via issueTokenPair for the authenticated user. Used by Electron to start a new independent session from a running instance's heartbeat without re-login.

Projects & storage

Projects with member management (viewer / editor / admin roles). Each project gets a real directory on disk at storage/<uuid>/root/ with a default index.html on creation. All data lives as JSON files in build/data/db/ — no external database. Groups exist in the data layer but have been removed from the UI; they may return later via rokojori-auth.

Project home & theme system

The root / serves a new index.html entry point that loads the project-home component. Logged-out users see a full-screen dark hero; logged-in users get the active theme component dynamically imported. The default theme (project-list-default) renders a dark radial-gradient page with a Roject logo nav, per-project coloured badges (hue from UUID via 31-hash, hsl(h, 95%, 45%)), Barlow 900 Italic uppercase project names, hover-revealed delete / member buttons, and a dashed "New Project" card that opens a name dialog on click. Theme preference is persisted server-side in user_settings.json via GET / PUT /api/user/settings. A second theme (Italic Neon) is planned. /edit redirects to the existing /editor.html. Barlow is loaded via @import from styles.rokojori.com (weights 100, 400, 700, 900 including italics) — no Google Fonts dependency.

Ecosystem — tunnel.rokojori.com

A user-based local tunneling service live at tunnel.rokojori.com (source: C:\rokojori\projects\web-projects\tunnel). Exposes local services — LLMs, Stable Diffusion, language servers — to authorised rokojori users over the internet, routed through a relay server via a persistent WebSocket agent connection. Each tunnel is owned by a rokojori account and carries metadata (name, purpose tag, description, access mode, allowed users).

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

The relay uses a three-message streaming protocol over the agent WebSocket: res_start (status + headers), res_data (base64 chunk), res_end — so SSE responses from local LLMs stream through the relay without buffering. Roject integrates via a Browse Tunnels button in rojo-settings-panel and forwards LLM chat through the selected tunnel. The TUNNEL_SERVER_URL env var controls the target in all environments. Phase 2 (multi-user allowed list enforcement, public access mode) is still in progress.

Ecosystem — styles.rokojori.com

A shared asset hosting service for all rokojori projects, live at styles.rokojori.com. Currently serves self-hosted fonts downloaded from Google Fonts on demand via a GET /get-font public endpoint that returns a dynamic CSS file with @font-face rules. Font files are stored as storage/fonts/<family>/<weight>.woff2 and served statically. CORS is restricted to *.rokojori.com origins. Access to the management UI (/list-fonts, /add-fonts) is gated by the shared JWT cookie via requireAccess middleware (role: admin, or role: user + product: styles / premium).

Planned: shared HTML components / Web Components, CSS presets, and binary assets (images, sounds, video) — making styles.rokojori.com the single place to manage any reusable front-end resource across the rokojori ecosystem.

Editor

A full editor page (/editor.html) with a 3-panel resizable layout (Left / Center / Right). Each panel holds one or more sections side by side; each section holds a <tab-container> with drag-and-drop tabs. The Left panel shows the file tree (create, rename, delete). A FileEditorRegistry routes files to the correct panel by extension: .pagepage-editor-panel (structured page editor, see card below); all other text formats → code-panel (CodeMirror 5, syntax highlighting, dark theme). Godot file types (.gd, .gdshader, .gdshaderinc, .tscn, .tres, .res) are pre-registered. An optional project-level workspace/editor/file-editors.json overrides the defaults.

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. Right-clicking a file shows a context menu titled with the filename (truncated to 20 chars with a leading ... if longer). Files with registered alternative editors show an Open > submenu. Directories show As Root Directory to set a sub-root without double-clicking. Open-directory state is preserved across tree refreshes.

Tab container context menu: Add > opens a panel-type submenu. Duplicate clones the active tab. Split > offers ↔ Horizontally (new section side by side) and ↕ Vertically (new tab-container stacked inside the same section). Close Container removes the container and its adjacent resize handle; it is hidden when the container is the last one in its slot. If any tab has unsaved changes, a confirm dialog (Don't Close / Close Without Saving) appears first. Tabs can also be closed by middle-mouse click.

Panel interfacessource/editor/editor-panel.ts defines two interfaces for Web Component panels. EditorPanel (all panels) requires __interfaces__: string[] and addContextMenuEntries(). FileEditorPanel extends EditorPanel (file-editing panels only) adds hasUnsavedChanges(): boolean, replacing the old TabEntry.dirty flag. Each interface has a companion Definition class (EditorPanelDefinition, FileEditorPanelDefinition) with a static readonly type string; use implementsInterface(el, Def) for runtime checks.

EditorConsole (source/editor/EditorConsole.ts) is a standalone singleton — separate from Editor — that holds a capped ring of 500 ConsoleMessage objects (text, type: 'info'|'error'|'hint', timestamp) and dispatches them via onMessage: EventSlot. editor-shell bridges Editor.onFileTypeUnknown to EditorConsole and shows the latest message in a .es-info header element (opacity 0→1, auto-hides after 5 s; portrait: fixed bottom bar). The console-panel tab renders the full message log using custom elements (conp-header, conp-list, conp-entry, conp-time, conp-text) and is available from the tab container Add > menu.

openDocumentIn derives editorTag from panelElement.tagName.toLowerCase() — not from FileEditorRegistry. The caller already chose the target panel; the dispatch must honour that choice so alternative editors (e.g. code-panel opening a .page file) receive and display the document correctly.

Layout persistence — the full tab tree is saved per project per device to .roject/layout-<deviceId>.json inside the project directory. Remote projects write to storage/<id>/root/.roject/; local Electron projects write to <localRoot>/.roject/; remote projects opened via the Electron proxy use the centralized build/data/storage/layouts/ keyed by device + remote project ID. deviceId is a UUID in localStorage — Firefox, Chrome, and Electron each get a distinct ID automatically. The serialized format records panels → sections → tabContainers → tabs, with each tab carrying { id, label, panelType, tag, openFile }. FileEditorPanel was extended with getCurrentFile(): string | null (implemented by code-panel and page-editor-panel) so the serializer can read the open file from each panel element. On editor load, editor-shell restores the saved structure; if no layout is found it falls back to the hard-coded default (file tree left, page editor centre). .roject/ is filtered out of both the remote and local file tree listings server-side. GET / PUT /api/layout in source/server/routes/layout.ts.

Page Editor Panel (page-editor-panel)

A structured authoring editor for .page files — Roject's custom documentation format. A .page file is a full HTML document whose <body> must follow a fixed structure:

<page-header></page-header>

<page-root>
  <page-block>
    <page-area></page-area>
  </page-block>
</page-root>

<page-footer></page-footer>

The <head> may contain links to CSS/JS asset bundles; these will load inside the editor iframe. Pages that do not follow the required body structure fall back to code-panel for plain-text editing.

Validation

Format validation is handled by a single replaceable function (validatePageFormat(doc): boolean) in source/components/page-editor-panel/page-editor-panel.ts. Currently a placeholder that always returns true — swap for real DOM inspection when the format is stable. Required structure when implemented: exactly one <page-header>, one <page-root>, and one <page-footer> as direct children of <body>; no other elements at that level. <page-root> may be empty or contain any number of <page-block> children.

Auto-template for new/empty files

When a .page file is opened and its content is empty (or all whitespace), format validation is bypassed and the standard template is injected automatically. The document is marked dirty so the user must save to persist the initial structure. This is the intended flow for newly created .page files — no "Init" button exists.

JS safety — iframe sandbox

The editor iframe uses sandbox="allow-same-origin", which blocks script execution inside the rendered page. This is intentional: user-authored <script> tags must not run in the editor context. To change sandboxing behaviour, adjust the sandbox attribute on .pep-frame in page-editor-panel.ts.

Editor CSS injection

Block and area layout styles (page-block, page-area, etc.) are injected into the live iframe <head> after load via a <style id="pep-editor-injected"> element. This element is never part of srcdoc and is stripped from the captured HTML before saving, keeping the saved file clean. To update the editor-side layout styles, edit the PEP_EDITOR_STYLES constant in page-editor-panel.ts.

Block registry

Available block templates are defined in a static table (PAGE_BLOCK_REGISTRY) in source/components/page-editor-panel/page-editor-panel.ts. Each entry has a name, optional CSS-based preview markup (a small layout sketch), and an html snippet inserted into <page-root> when the block is added. Blocks without a preview show their name as a text label.

Current standard blocks:

  • Full Width — one <page-area> spanning the full container width.
  • Two Columns — two equal <page-area> elements side by side on landscape; stacked (left above right) on portrait via a CSS media query.

Toolbar modes

Two icon buttons in .pep-toolbar (pushed to the right by a .pep-toolbar-sep spacer) switch between modes:

  • Blocks mode — a horizontal scrollable list of block templates. Each entry shows a small CSS layout preview (or text name) above the block name. Clicking a block appends it to <page-root>.
  • Areas mode — a formatting toolbar that acts on the current selection inside a <page-area>. See rich text below.

Rich text editing in areas

Each <page-area> inside the iframe is contenteditable. Formatting is applied via the Selection / Range API — no execCommand. The shared helper wrapSelection(range, tagName, attributes?) in page-editor-panel.ts uses Range.extractContents() to pull out the selected fragment, wraps it in the target element, and re-inserts via Range.insertNode(). This handles both fully-contained elements (wrapped outside) and boundary intersections (text nodes split automatically by the Range API, wrapped inside the outer element). Semantic tags are preferred: <b>, <i>, <u>; <span style="..."> for colour / font-family.

Known limitation (future work): after wrapping, adjacent identical elements (e.g. two consecutive <b> tags) are not merged. A cleanup pass is not implemented yet.

CodeMirror syntax highlighting

Vendor modes bundled: clike (C/C++/Java/GLSL/GDShader), python (GDScript), shell, yaml. Extension → mode mappings in _resolveMode: .yaml/.ymlyaml, .shshell, .gdpython, .glsl/.gdshader/.gdshaderincclike, .csrokojori-cs.

Custom C# mode is built on a browser-only lexer stack in source/components/code-panel/ (no library-ts dependency — avoids the ts-node / browser-extension import conflict):

  • BrowserLexer.ts — zero imports. BrowserMatcher uses sticky regexes (/y flag + lastIndex) for positional matching. cLikeLexer() factory returns a full C-like token set.
  • CodeMirrorLexerMode.ts — wraps a BrowserLexer 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. refresh(cm) forces re-tokenization.
  • CSharpMode.tscsharpMode registered as 'rokojori-cs'; ~70 C# keywords mapped from CWORDkeyword.

Project access control

source/server/projectAccess.ts is the single point of truth for ownership and membership checks. GET /api/projects filters to projects the requesting user owns or is a member of. Every file route (tree, read, write, rename, delete) calls checkAccess() before touching the filesystem. Delete and member-management routes verify ownership. Members are currently stored by email address (Option B interim); memberMatchesUser() is the one line to change when migrating to user-ID-based lookup via rokojori-auth.

AI chat & utilities

A rojo-chat-panel provides a streaming AI chat interface backed by LangChain + OpenAI-compatible models. A reusable <confirm-dialog> component replaces browser confirm() for destructive actions. An EmailService facade (source/server/email/) provides a sendEmail() entry point backed by a swappable EmailSender interface; the default is SMTPEmailSender (Nodemailer). EmailService.reportEmail is a static field holding the admin notification address; Roject sends emails on server startup and on each verified deploy request.

Electron desktop app

Roject runs as a standalone Windows desktop application. The Express server starts in-process inside Electron's main process. A custom login window calls the auth API directly; tokens are stored in userData/tokens.json and re-used across sessions. All requests to localhost have Authorization: Bearer injected automatically via session.webRequest.onBeforeSendHeaders. Run with npm run electron:dev.

Electron token updater: runs in electron/main.ts. Reads the access token's exp directly from the JWT payload (token is held in the main process, not behind an httpOnly cookie). Uses a server clock offset derived from the Date response header of the first auth-server call (_serverClockOffsetMs) for all expiry comparisons. Refreshes proactively when within 15 min of expiry; checks every 5 min. Network errors on refresh are silently retried next tick; auth failures (expired/revoked refresh token) close the main window and show the login screen. Multiple Electron instances each run their own updater with their own independent session — no single-instance lock.

Heartbeat session sharing: a running instance writes { accessToken, timestamp } to userData/session-heartbeat.json every 10 s. A newly-starting instance reads it on launch; if ≤ 30 s old, it calls POST /api/auth/new-session (requireAuth-guarded) to mint its own independent token pair — skipping the login screen. If the heartbeat is stale or the call fails, it falls through to normal login. Replaces the old plaintext last-password.txt auto-login.

Local filesystem access: when ROJECT_ELECTRON=true, the server mounts /api/local/ routes backed by Node.js fs (no project storage). The project-list-default This PC tab lets the user pick any host directory; the editor opens with a localRoot URL param and all file-tree operations route through /api/local/.

Remote project proxy: /api/remote/** is a catch-all that strips the prefix, prepends /api, and forwards the request to roject.rokojori.com over HTTPS. The Authorization header is already injected by onBeforeSendHeaders, so no extra IPC channel is needed. The project-list-default Online tab fetches from /api/remote/projects and opens the editor with a remoteProject URL param.

Deployment & CI

Live at https://roject.rokojori.com on Server A. nginx handles TLS and reverse-proxies to a Node.js process managed by systemd. Auto-deploy on push to main via a Gitea webhook calling POST /api/deploy (HMAC-SHA256 verified). The deploy script runs detached so it survives the systemctl restart that kills the parent process.

What's next

Current tasks, known bugs, and longer-horizon features are tracked on the Boards.

Technical Implementation

Backend

Node.js + Express, TypeScript compiled on the fly with ts-node. Auth is handled by rokojori-auth; Roject verifies the shared accessToken JWT cookie using jsonwebtoken + cookie-parser. All entity IDs are UUIDs via crypto.randomUUID(). Start the server with npm start.

Node.js Express ts-node jsonwebtoken cookie-parser UUID IDs

Frontend — Editor Singleton & Client/Server Split

The Editor singleton (src/editor/Editor.ts) is the central hub of the frontend — it owns all open document state, the FileEditorRegistry, and the five events that panels and tab containers subscribe to (onDocumentOpened, onDocumentDirty, onDocumentSaved, onFilesChanged, onFileTypeUnknown). For the full event reference and the TypeScript compilation split between client and server, see the Editor Singleton reference.

Frontend

Vanilla HTML, raw CSS (no Tailwind, no framework). Every UI component is a custom element with its own .ts and .css file in source/components/<name>/. CSS uses the element tag as root selector with display: block. TypeScript compiles to build/app/components/ via tsconfig.client.json (module: ESNext, no bundler). HTML pages live in source/pages/ and are copied to build/app/ by scripts/copy-pages.js. Build with npm run build.

Web Components raw CSS module: ESNext no bundler tsc --build

Shared Library

A personal TypeScript library lives as a git submodule at source/library-ts/. It has two parts: browser/ (DOM-capable) and node/ (Node.js only). The browser part is compiled separately via TypeScript project references into build/app/library-ts/browser/. The node part is included by tsconfig.ts-node.json.

Import extension convention: all relative imports in browser/ use explicit .js extensions (e.g. from "../events/EventSlot.js"). Roject's client code is served as unbundled browser-native ESM — no bundler resolves paths at build time, so the browser fetches each module by its literal URL. Extension-less imports 404 because Express static only serves the actual .js files. TypeScript with moduleResolution: "bundler" accepts .js extensions in source even when the source file is .ts.

git submodule source/library-ts/ project references composite: true .js extensions required