Project Documentation
Developer reference for human and agent contributors. Keep this file up to date as the project evolves.
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.
User accounts are managed by rokojori-auth at
account.rokojori.com — registration, login, JWT issuance, refresh
token rotation, roles, and account deletion.
Token refresh:
rokojori-auth's page-level middleware (before express.static)
verifies the accessToken cookie but calls next() on
any error — no redirect on expiry. Transparent refresh for API calls is handled
by Roject's jwtMiddleware: when a TokenExpiredError
hits an /api/ route and a refreshToken cookie is present,
it calls POST AUTH_INTERNAL_HOST/api/auth/refresh server-side, sets
the new cookies on the response, decodes the new JWT into req.user,
and continues transparently. If refresh fails, the request falls through to
requireAuth which returns 401. AUTH_INTERNAL_HOST
defaults to AUTH_HOST; set it to http://localhost:3001
in production to bypass nginx. The editor-shell checks
GET /api/auth/me on startup and redirects to / on 401.
Token extraction order (extractToken in auth.ts):
Bearer header is checked before the accessToken cookie. This means an
explicit Authorization: Bearer ... header always wins — critical for
Electron (which injects tokens via onBeforeSendHeaders), API clients,
and any context where a stale browser cookie might otherwise shadow a fresh token.
Clock skew — JWT_CLOCK_TOLERANCE:
jwt.verify accepts a clockTolerance option (seconds).
The env var JWT_CLOCK_TOLERANCE (default 0 / unset) is read as an
integer and passed as clockTolerance when non-zero. Set to
7200 in .env 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.
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.
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.
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.
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.
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:
.page → page-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 interfaces — source/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.
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.
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.
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.
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.
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.
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:
<page-area> spanning
the full container width.<page-area>
elements side by side on landscape; stacked (left above right) on portrait
via a CSS media query.
Two icon buttons in .pep-toolbar (pushed to the right by a
.pep-toolbar-sep spacer) switch between modes:
<page-root>.<page-area>. See rich text below.
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.
Vendor modes bundled: clike (C/C++/Java/GLSL/GDShader),
python (GDScript), shell, yaml.
Extension → mode mappings in _resolveMode:
.yaml/.yml → yaml,
.sh → shell,
.gd → python,
.glsl/.gdshader/.gdshaderinc → clike,
.cs → rokojori-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.ts — csharpMode registered as
'rokojori-cs'; ~70 C# keywords mapped from CWORD
→ keyword.
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.
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.
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.
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.
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.
Current tasks, known bugs, and longer-horizon features are tracked on the Boards.
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.
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.
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.
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.