Skip to content

feat: rewrite v2 - #206

Open
Exeloo wants to merge 2 commits into
mainfrom
rewrite/swap-to-v2
Open

Exeloo wants to merge 2 commits into
mainfrom
rewrite/swap-to-v2

Conversation

@Exeloo

@Exeloo Exeloo commented Oct 4, 2026

Copy link
Copy Markdown
Member

What does this PR do?

Rewrites the NanoForge editor from scratch (rewrite/v2). It keeps what the previous editor did (edit a NanoForge game in the browser, hosted or through nf editor) on a new base: everything visible is a plugin, the game's code is the only source of truth, and every change can be undone. The full list of user-facing changes is in CHANGELOG-v2.md; the plan, phase by phase, is in docs/rewrite-plan.md.

Resolves #174
Resolves #156
Resolves #154
Resolves #128
Resolves #127
Resolves #126
Resolves #125
Resolves #123
Resolves #97

Architecture

  • Monorepo (pnpm + turbo): apps/editor (SvelteKit static SPA + Bun server), packages/* (kernel, sdk, settings, history, layout, ui, code, runtime, protocol, rpc, project, server-core, meta, registry), plugins/* (12 built-in plugins), tooling/* (vite plugin for building plugins, example plugin).
  • Plugins only import @nanoforge-dev/editor-sdk (enforced by ESLint). The SDK and @nanoforge-dev/editor-vite-plugin are the public API for third-party plugins (guide in docs/docs/plugins).
  • Code-first ECS: no more .nanoforge/*.save.json. Entities, components and systems are read from and written to the app's main.ts through a TypeScript code worker (ts-morph stays in the worker, out of the page bundle).
  • Engine bridge (protocol v1): live mode, profiler, network and world inspectors, source-mapped console lines.
  • Packages and plugins (ADR 0003/0005): nanoforge.manifest.json, @scope/name, packages installed read-only in nf_modules, plugins per user or per project. Shared libraries between client and server (ADR 0004).
  • API hand-off: docs/api/ documents the registry and settings-sync contracts the API does not offer yet; contract tests run against stand-ins (API_FEATURES gates the calls).
  • Source layout (ADR 0006), aligned on the engine and the CLI: feature folders with index.ts barrels, kebab-case files named after their main export, kind suffixes (.type.ts, .enum.ts, .exception.ts, .const.ts), tests in test/**/*.spec.ts mirroring src/.

Built-in plugins

code-editor, file-manager, viewport, history-panel, settings-ui, ecs, console, command-palette, inspectors, git, packages, scene.

Repository

  • Node 26, pnpm 12; every workspace has the engine's README, LICENSE, .prettierignore, labels and issue-form entry; published workspaces (apps/editor, packages/sdk, tooling/vite-plugin) have their changelog and release config.
  • New e2e workflow; the old editor's files are removed.

Not in this PR

  • No release: versions are unchanged, nothing is tagged or published.
  • Depends on unpushed branches of other repos: engine feat/core-editor-bridge (and feat/scene for scenes), loader feat/server-editor-ipc, CLI feat/editor-open-project (nf editor, nf create plugin).
  • The registry and settings sync wait for the API (see docs/api/).

How do you test this PR?

pnpm install
pnpm typecheck
pnpm lint
CHROME=/usr/bin/google-chrome-stable pnpm test
pnpm turbo build --filter=@nanoforge-dev/editor...

# E2E, against the production build
cd apps/editor
CHROME=/usr/bin/google-chrome-stable \
NANOFORGE_ENGINE=<engine checkout on feat/core-editor-bridge, built> \
NANOFORGE_CLI=<cli checkout on feat/editor-open-project, built> \
npx playwright test --reporter=line

- Typecheck: 42/42 tasks. Unit and Vitest browser tests: 39/39 tasks. Lint: 29/29. SDK public API snapshot unchanged.
- E2E: 48 passed, 0 failed. The 26 tests that play the game are skipped without NANOFORGE_ENGINE pointing at a built engine. Run them with it to cover play, live mode, the inspectors and scenes.
- Manually: pnpm example breakout (with NANOFORGE_ENGINE=../engine-scene), or nf editor <project> from the CLI branch, then play, edit entities in the Scene screen and in live mode, commit from the Git panel, undo across panels.

@Exeloo
Exeloo requested a review from Tchips46 as a code owner October 4, 2026 23:07
@github-actions github-actions Bot added the documentation Improvements or additions to documentation label Oct 4, 2026

@Tchips46 Tchips46 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security audit

import { type ApiFeature, EnvSchema } from './env.schema';

export const loadEnv = (
source: Record<string, string | undefined> = process.env,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reading env at runtime cause the Editor to run in offline mode in prod (before we were building with env) : either change how we get env, or set the PUBLIC_MODE in run time

pruneTimer.unref?.();

/** Blocks cross-site requests: a local editor must not be driven by random web pages. */
const originAllowed = (request: Request): boolean => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Vulnerable to DNS rebinding : you only check Origin === Host, but never Host A page on evil.com that rebinds its DNS to 127.0.0.1 gets matching headers and the full RPC

Comment thread packages/server-core/src/server/create-editor-server.ts
Comment thread packages/server-core/src/server/create-editor-server.ts
};

const assertWritable = (path: string) => {
if (path === PACKAGES_DIR || path.startsWith(`${PACKAGES_DIR}/`)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also unallow access to .git, writing inside it can cause RCE at clone, and leak tokens

/** Build outputs of running games: `/runtime/<projectId>/<app>/<file>`. */
const handleRuntime = async (request: Request, url: URL): Promise<Response> => {
const [projectId, appSegment, ...rest] = url.pathname.slice('/runtime/'.length).split('/');
if (!projectId || !appSegment || !rest.length || env.mode !== 'OFFLINE')

@Tchips46 Tchips46 Oct 9, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not allowed in ONLINE mode ?

}
if (dir === root || dirname(dir) === dir || relative(root, dir).startsWith('..')) break;
}
if (!packageDir || relative(root, packageDir).startsWith('..')) return [];

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Vulnerable to already pushed symlinks in node_modules


constructor(private readonly _env: EditorEnv) {}

resolve(cookieHeader: string | null | undefined): SessionResolution {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems like the token signature is never checked, just decoded and trusted, someone with just the user id could forge a token

/** Returns an opened project the session may access, else throws NOT_FOUND. */
get(session: Session, id: string): OpenProject {
const project = this._projects.get(id);
const allowed = session.mode === 'OFFLINE' || session.projects.has(id);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Open projects outlive the user who opened them, since session is holded by the browser

@Tchips46 Tchips46 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug reviews Claude found

}
let disposed = false;
editor.projects
.open(ref)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Opens any ?path= with no confirmation, and opening runs nanoforge.config.ts and the project's server plugins: any website can link localhost:3000/load?path=<cloned repo> and get RCE. Needs a trust prompt for paths not in recent.

* Plugins that its packages suggest and that aren't installed are named in a notification, once
* per project.
*/
export const followProjectPlugins = (options: {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Project plugins' client code loads in ONLINE too (only the server entry is gated): anyone who can write .nanoforge/plugins in a shared project runs JS in other users' editor. Don't load them in ONLINE, or make it opt-in.


const CONTENT_TYPES: Record<string, string> = {
'.css': 'text/css; charset=utf-8',
'.html': 'text/html; charset=utf-8',

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.html / .svg from projects are served inline on the editor origin, no CSP, no nosniff: stored XSS in ONLINE. Send Content-Security-Policy: sandbox + nosniff, or serve them as attachments.


/** Clones `url` into `path`, injecting a token for https remotes (GitHub style). */
clone(url: string, path: string, token?: string): Promise<void> {
const remote = token ? withToken(url, token) : url;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Token put in the clone URL: visible in ps and saved in .git/config. Pass it with -c http.extraHeader or GIT_ASKPASS instead.

clone(url: string, path: string, token?: string): Promise<void> {
const remote = token ? withToken(url, token) : url;
return this._mutex.run(path, async () => {
await this._git(this._env.fsRoot, ['clone', '--', remote, path]);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clone and pull have no timeout (network is false): a remote that never answers holds the _opening lock forever.

});

/** Default merge of two commands with the same merge key: first undo, last redo. */
export const mergeCommands = (previous: HistoryCommand, next: HistoryCommand): HistoryCommand => ({

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Merged undo only calls previous.undo(): mergeKey on document edits leaves next applied. No caller passes it yet, but the SDK exposes it.

Comment thread packages/code/src/engine/code-engine.ts Outdated
roots.found.push(root);
}
this.setFiles(files);
} catch {}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This empty catch {} hides failures to resolve engine types, which then show up as false errors in Problems.

const text = await readFile(join(this.root, PACKAGES_FILE), 'utf8').catch(() => undefined);
if (text === undefined) return {};
try {
return JSON.parse(text) as Record<string, unknown>;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A corrupt lock throws a raw SyntaxError instead of a RegistryError.

* - `static`: the bundled Prettier with the closest JSON/YAML config, without plugins.
*/
export class Formatter {
private readonly _projectPrettier = new Map<string, Promise<Prettier | undefined>>();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A missing Prettier is cached forever: installing it afterwards is ignored until restart.

Comment thread apps/editor/package.json
"dependencies": {
"chokidar": "catalog:server",
"prettier": "catalog:server",
"typescript": "catalog:build",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

typescript is in both dependencies and devDependencies.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation

Projects

None yet

2 participants