Skip to content

Claude/x86 graphics performance issue 9rac6j - #601

Open
ovurrsl wants to merge 135 commits into
pascalorg:mainfrom
ovurrsl:claude/x86-graphics-performance-issue-9rac6j
Open

Claude/x86 graphics performance issue 9rac6j#601
ovurrsl wants to merge 135 commits into
pascalorg:mainfrom
ovurrsl:claude/x86-graphics-performance-issue-9rac6j

Conversation

@ovurrsl

@ovurrsl ovurrsl commented Aug 5, 2026

Copy link
Copy Markdown

What does this PR do?

How to test

Screenshots / screen recording

Checklist

  • I've tested this locally with bun dev
  • My code follows the existing code style (run bun check to verify)
  • I've updated relevant documentation (if applicable)
  • This PR targets the main branch

Note

High Risk
Touches authentication, sessions, MFA, admin APIs, and production deploy paths; misconfiguration of secrets or scene authorization could block deploys or expose data.

Overview
This PR turns ovurrsl/editor into a production fork: work moves on integration, main stays an upstream mirror, and automation keeps the warehouse plugin, console panel, and deploy bundle in sync.

CI/CD and release adds deploy-bundle (standalone build, MySQL smoke tests, force-push to ovurrsl/digitaltwin), mirror-upstream / upstream-check, hourly bump-plugin and pull-panel, manual sync-panel and relock, plus .github/deploy/ (package.json, setup-native.mjs for argon2 symlinks, Hostinger README). Existing workflows (ci, mcp-ci) now target integration instead of main.

Documentation adds OTOMASYON.md, UPSTREAM.md, YAYINLAMA.md, and a fork warning block at the top of AGENTS.md.

Application wires the vendored @panel console: routes under app/(panel)/ (sign-in, MFA, console tabs), public (public)/ guides/changelog, and a large app/api/ surface (auth, users, roles, sites, jobs, audit/logs, admin scene publish/manage). Scene APIs gain authorizeSceneRead / authorizeSceneMutation and owner-scoped listing; /api/health reports MySQL backend and auth status.

Reviewed by Cursor Bugbot for commit ec6f460. Bugbot is set up for automated code reviews on this repo. Configure here.

ovurrsl and others added 30 commits July 26, 2026 04:07
…dows

Registers `@ovurrsl/plugin-warehouse` through `extendPluginDiscovery` rather
than `setPluginDiscovery`, which would replace the whole chain and silently
drop the trees pack registered above it. The panel goes in separately via
`registerEditorHostPanel` — it is host UI, not part of the node manifest.

`allowedDevOrigins` gains the LAN and WAN addresses so the dev server can be
reached from another machine. Note this exposes an unauthenticated editor
with readable source maps to whatever can route to it.

The `dev` script did not run on Windows at all:

    next dev --port ${PORT:-3002}
    error: '${PORT:-3002}' is not a non-negative number

`${PORT:-3002}` is POSIX parameter expansion. Bun's script runner does not
expand it on Windows, so the literal string reached Next's argument parser.
The `dotenv -e ../../.env.local` prefix was also loading a file that does not
exist, and every entry in `.env.example` is optional — so both go, leaving a
script that runs on either platform.

Also carries the scene-clipboard fallback that lets plugin-contributed kinds
be duplicated (submitted upstream as pascalorg#547). `AnyNode` is a
hand-maintained union of built-in kinds, so a plugin kind can never be a
member and `capabilities.duplicable` cannot be honoured for any plugin —
including the first-party trees pack, which declares it and fails the same
way. The fallback tries `AnyNode` first, so built-in behaviour is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…duplicate

Reported as a paste failure:

    Failed to paste copied node "pallet_rack_7vp5f3t58sm5iuch"
    pattern: "^pallet_rack_[\s\S]{0,}$"

`extractIdPrefix` recovered the prefix by splitting at the FIRST underscore.
`generateId` builds ids as `${prefix}_${customId()}` where the suffix is drawn
from `0123456789abcdefghijklmnopqrstuvwxyz` — it never contains an underscore —
so the prefix is everything before the LAST one.

Built-in kinds never noticed, because none of their prefixes contains an
underscore: `wall_abc` splits the same either way. A plugin kind whose prefix
does — `pallet_rack` — cloned as `pallet_abc`, and the kind's own schema then
rejected it. Duplicate and copy/paste were unusable for that kind, and the
error named the plugin rather than the id helper.

The same three-line helper had been copied into three files, so all three had
the bug: `editor/lib/scene-clipboard.ts`, `core/registry/subtree.ts`, and
`core/utils/clone-scene-graph.ts`. Each now uses `lastIndexOf`. Behaviour for
every built-in kind is byte-identical, which is why the existing clipboard
tests pass unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…g GPU disposal

Three defects one debugging session paid for in full:

- saveAsset called crypto.randomUUID unconditionally; it exists only in
  secure contexts, so an editor served over plain http on a LAN threw on
  the first line of every upload. getRandomValues is not gated the same
  way and provides the same uniqueness.
- Two guide-image catch{} blocks swallowed the reason. The message the
  user saw ("Could not add that guide image.") was true and useless; the
  cause now reaches both the console and the toast.
- DragBoundingBox minted geometry per dimension change and disposed the
  previous one in effect cleanup — once per frame during a resize drag,
  while WebGPU could still be executing the command buffer referencing
  the destroyed buffers, which drops the ENTIRE frame ("Vertex buffer
  slot … was not set"). Unit geometry scaled through the mesh transform
  and colour-cached materials: nothing is built or disposed mid-drag.

Also carries the clipboard regression test for multi-underscore id
prefixes, and pins plugin-warehouse at the current catalogue.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds the boom-tip anti-collision sensor and operator step platform,
researched against real telescopic conveyor manufacturers (Feifer,
Dahan) per the user's reference photos.
…s (0927769)

hasSensor / hasPlatform now controllable from the parametrics panel
instead of hardcoded always-on.
Adds warehouse:mezzanine — Mecalux multi-tier structural steel
platform, structural skeleton only (grid/column/beam/floor). No
staircases, gates, railings, or rack-on-mezzanine integration yet.
apps/ifc-converter/next-env.d.ts and bun.lock picked up churn from
rebuilding every @pascal-app/* package's stale dist/ against the
1.0.0-beta.1 release commit — needed to unblock the mezzanine deploy
(next build failed on missing exports from unbuilt package dists).
Staircases (EN ISO 14122-3), railings derived from openings, gates,
stair voids as panel exclusion, plan symbols sharing the 3D
calculators, and rack-on-mezzanine load-class checking + 3D clash.
Adds warehouse:live-racking — Mecalux gravity-flow channel. One node is
one channel column; bay width and roller length derive from the pallet
by catalogue formula. Joins the capacity readout as storage-only depth.
Mezzanine now publishes a real host `slab` per tier, which is the only way
`spatialGridManager` will elect it as a support surface — so racks, pallets
and conveyors can finally be placed on a mezzanine deck. Host level
filtering and pointer-based tier selection come free from the existing
support machinery.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Flow hardware now exists as geometry (brake rollers + drums, centralising
strips, retainers, exit beam, end stop) and `variant` finally changes the
mesh — FIFO and LIFO were byte-identical before. Adds the floor-set pallet
truck level and clad-rack configurations.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds per-level SKU on live racking channels and an unconditional plan
label, so a layout reader can see which lane holds which reference
without selecting it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three deploy-only changes; packages/* and dependency pins untouched:

- next.config.ts: output 'standalone' so the build emits a bundle that
  runs without the monorepo's root node_modules (Hostinger only ships
  the output directory).
- package.json: drop the dotenv -e ../../.env.local build wrapper —
  .env.local is gitignored so the file never exists on the build host,
  and the app builds with no env vars.
- hostinger-server.js: entry file copied to .next/standalone/server.js
  at build time; chdirs into apps/editor so the standalone output finds
  .next/ and public/, and passes PORT through untouched so a Unix
  socket path works as well as a number.

Verified locally: clean install, turbo build --filter=editor, and the
standalone server serving /, /api/health, /_next/static/* and public/
assets over both a TCP port and a Unix socket.

Note: bun resolves the two github git dependencies through the GitHub
API tarball endpoint, so the git+ssh pin installs anonymously as long
as the repo is public; for a private repo set GITHUB_TOKEN in the host
environment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EvTkeoX8srShi7YwB2kZzw
Four mezzanine controls offered a choice and changed nothing: the two gate
types drew identical boxes, turn90 and turn180 cut identical floor voids,
SIGMA silently discarded profile overrides, and hatch2D was populated on
all seven floor types but never read. Each now has a real effect or says
plainly that it is ignored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Hostinger's Node.js app builder only offers fixed build commands
(npm run build), so the root build script now runs the editor-only
turbo build plus the standalone completion steps (public/, .next/static,
and the hostinger-server.js entry copy) that were previously meant for a
custom build command.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EvTkeoX8srShi7YwB2kZzw
Hostinger's fixed install step runs npm install, and npm 7+ auto-installs
peer dependencies: the plugin packages pin @pascal-app/* peers to
'>=0.9.2 <1', which the workspace's 1.0.0-beta.1 prerelease does not
satisfy, so npm shadowed the workspace packages with stale published
copies under apps/editor/node_modules and the build failed on missing
exports. The build host ships bun, so the build script now clears npm's
tree and reinstalls from bun.lock, reproducing the locally verified
resolution before running turbo.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EvTkeoX8srShi7YwB2kZzw
Staircases now carry railings on their open edges, a real landing platform
between flights, and a multi-flight layout driven by the landing type. Adds
the 4000 mm single-straight-flight exception and the 15-step auto-split.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Verified this time against a copy of the standalone directory moved out
of the repo, which is what the host actually deploys — the earlier
in-place verification silently resolved modules from the repo's root
node_modules, masking two failures:

- bun 1.3 defaults workspaces to the isolated linker, and the standalone
  bundle it produces carries node_modules/.bun symlinks that break once
  the output directory is moved (Cannot find module 'next'). Install
  with --linker=hoisted so the bundle contains plain directories.
- the custom entry used the programmatic next() API, whose dependency
  surface (e.g. next/dist/compiled/webpack/webpack-lib) is not part of
  the file-traced bundle. The entry is now a thin CJS shim that dynamic-
  imports the server Next itself generates in the bundle, which handles
  cwd and PORT wiring.

The moved-copy smoke test now passes: /api/health, /, page CSS and
public/ assets all 200.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EvTkeoX8srShi7YwB2kZzw
Catalogue tiles now ship staircases and pallet gates, so a mezzanine placed
from the catalogue can actually be reached. Adds the missing MIXED tile, a
step-count picker and safety-zone creation in the accessory editor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
bun's hoisted linker drops the execute bit when materializing packages
from a warm cache on the build host, so spawning the turbo native binary
failed with EACCES. Re-apply execute permissions to node_modules/.bin
and the turbo platform package before running the build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EvTkeoX8srShi7YwB2kZzw
Stacked mezzanine decks could not be targeted: the host's pointed-surface
election takes the nearest ray crossing, which from a camera above is always
the topmost deck. Placement now honours an explicit active-deck selection.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
claude and others added 29 commits August 1, 2026 19:45
Five provenance columns — sites.created_by, assignments.granted_by,
invitations.invited_by, api_keys.created_by, jobs.queued_by — were NOT NULL
with bare foreign keys, whose default rule is RESTRICT. The moment an account
had created a site or queued a job, DELETE FROM users failed with a constraint
error nothing caught, so the console answered "something went wrong on our
side" to a perfectly reasonable request.

Provenance is history, not ownership: migration 006 lets all five go NULL on
deletion, the way audit_log.actor_user_id always has. The three readers that
inner-joined users on those columns become LEFT JOINs — without that, deleting
a user would have silently removed every site they created from Sites &
Projects, which is worse than the bug being fixed.

If the migration has somehow not run, the delete route now maps MySQL's 1451
to a message that says what is pointing at the account and what to do,
instead of the generic server error.

Verified against the standalone bundle on MariaDB: an admin deleting the
account that created the only site and queued three jobs got 500 before, gets
200 after; the site and jobs remain listed with their actor shown as "—".
An administrator approved an access request, got a green toast, and waited for
a reply that could not come: the account was created and the invitation issued,
but the email never left the building and nothing anywhere said so. send()
swallows delivery failures by design — a mail server having a bad afternoon
must not roll back the account — but "swallow" was being read as "succeed" all
the way up to the UI.

deliverInvite now returns whether the message reached the mail server.
POST /api/requests/:id/approve carries that back as `mailDelivered` (the
account is still created either way) and writes an error-level audit line when
it is false; the approve dialog shows a warning naming the Resend button
instead of "invited". Resend itself answered 200 after sending nothing, which
is the least useful possible behaviour for the one button you press precisely
because the first message did not arrive — it now fails with the reason.

The likely cause of the original silence is also removed: the SMTP transport
was pooled, and an idle pooled socket on shared hosting is dropped by the
provider without the pool noticing, so the first message of a session succeeds
and a later one fails on a socket that looks alive. At a handful of messages a
day a pool buys nothing. Connection, greeting and socket timeouts are set too,
so a hung mail server can no longer hold an HTTP request open indefinitely.

Verified against the standalone bundle both ways: with SMTP pointed at a dead
port, approve returns 201 with mailDelivered:false, the account exists as
Invited, resend answers 500 err.mailFailed and the audit trail carries an error
line; with SMTP reachable, approve returns mailDelivered:true and both the
receipt and the invitation arrive, the latter carrying its /welcome?token link.
…the system audit

A five-dimension audit of the console/server system (83 raw findings, top ten
adversarially verified, none refuted) turned up one real hole and several
things that were quietly wrong.

GET /api/scenes/:id and its SSE stream had no identity check at all. The
surrounding guard proves a request came from this origin, not that it came
from anybody in particular, so a scene id was enough to read the drawing —
while /scene/:id, the page rendering the same data, had redirected strangers
to sign in since this morning. Both now apply the rule the scene *list*
already applied: your own scenes, an admin's anything, and published projects
to any signed-in account, since publishing is an administrator approving a
project for the whole organisation.

The idle timeout could never fire. The console polls /api/auth/session every
30 s while its tab is visible, and that read slid the very window it was
reporting — so a console left open on an unattended screen kept itself signed
in indefinitely. The poll no longer touches; real work still slides the window
through requirePermission, and the idle dialog keeps its own touch endpoint.

Two regressions from this morning's migration 006, which let provenance
columns go NULL: resolveInvitationToken still INNER JOINed the inviter, so
deleting an administrator would have silently invalidated every invitation
they ever sent, and the site-scene worker INNER JOINed the site creator, so a
site whose creator was deleted would never have got its scene. Both are LEFT
JOINs now — the same sweep I did for sites, jobs and API keys, which missed
these two because they live outside the directory I grepped.

Thirteen more error keys had no entry in resolveApiMessage and rendered as
"Something went wrong on our side" — the same class of defect that hid the
superuser and delete refusals twice today. All thirteen now say what happened,
in both languages, and the enumeration is mechanical so the set is complete.

Performance: listUsers computed a correlated COUNT(*) over `scenes` per
account whose result the only caller discarded, and which could not use an
index because the collation split forces CONVERT() on the join column; it is
gone, and the listing is bounded. Migration 007 adds the three missing indexes
on lookups that run on pages everyone opens — sites.scene_id (added by 004
with no index, scanned on every publish, unpublish and console load),
users.created_at and audit_log.created_at.

Verified against the standalone bundle on MariaDB: anonymous read of a scene
and of its event stream both answer 401; a viewer reading another account's
unpublished scene gets 403 on both, the owner still gets 200, an admin still
reads anything, and a published scene stays readable by any signed-in account.
The old install represented racking as a generic item node pointing at
asset://procedural/rack with a bounding box; the current build models it
as a parametric warehouse:pallet-rack node. The converter rebuilds each
procedural item as its plugin node (box folded into bay width, upright
height and depth, everything else on schema defaults), repairs the legacy
writer's null parentIds from the children arrays, and validates the result
with the same apiGraphSchema the API enforces.

An unmapped procedural kind aborts the whole migration with a report —
a silently skipped rack is a rack missing from the customer's floor.
The sqlite side discovers the legacy table instead of assuming it, and
--list prints a per-scene inventory for planning the bulk migration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EvTkeoX8srShi7YwB2kZzw
The late legacy vintage stored equipment as library items whose models
lived in the desktop app's own IndexedDB, so the converter gains their
mappings: asset://rack becomes a pallet rack, the euro-pallet pair become
pallets (loaded ones carry carton cargo), the flat wire mesh conveyor
becomes a roller conveyor, and warehouse ids written under old prefixes
(palletrack_) move to the prefixes the current schemas accept. Handles
the owner chose to keep as items are reported as invisible instead of
silently passing.

The hard lesson is the parse step: setScene stores nodes verbatim, so a
synthesised node missing a defaulted field reaches kind systems in a
shape editor-created nodes never have. A pallet without supportSlabId
crashed the load the moment a slab stood under it — and the editor then
autosaved the empty fallback over the scene. Every converted node now
runs through its owning schema via parseNodeWithDefaults (exported from
the API validation module so there is one source of truth), which
materialises every default and keeps that class of failure impossible.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EvTkeoX8srShi7YwB2kZzw
The dependency was pinned to 9e1b525 (31 July). Every plugin commit since
then — drive-in racking, M7 longspan, M3 shelving, the mezzanine tier
system, the conveyor family joining collective instancing, the panel
refactor, the solo-mode layer-mask fix and the placement-click fix — was
invisible to the app, because `bun install` resolves the pinned commit no
matter what the plugin repo does.

Moved to b16d1c8, the current head. Peer dependency ranges are unchanged
between the two commits, so the lockfile edit is the resolved commit and
nothing else.

Not verified by a local install: this session's GitHub proxy scope
excludes pascalorg/*, so `bun install` cannot resolve @pascal-app/plugin-trees
and aborts before writing. The two lockfile lines were edited by hand to
the form bun itself produced when it cached the new commit
(ovurrsl-plugin-warehouse-b16d1c8). The build must be run before this is
deployed.

Carries a breaking change from 7c25751: warehouse:live-racking,
warehouse:longspan and warehouse:m3-shelving are renamed to live-rack,
longspan-rack and m3-rack. Saved scenes holding the old kinds need
checking before this reaches production.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EvTkeoX8srShi7YwB2kZzw
The deploy template still read 2.10.0 while the live bundle reports 2.12.0 —
the last two releases were versioned in the deployment repository by hand, so
a workflow-built bundle would have shipped a lower version than the one it
replaced.

2.13.0 is this bundle: the warehouse plugin moved from 9e1b525 to b16d1c8.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EvTkeoX8srShi7YwB2kZzw
Today's release took four attempts, and every one of them failed on something
that is invisible from the code: workflow_dispatch returning 404 because the
workflow file was not on the default branch, an empty DEPLOY_TOKEN, and then a
token that could read the deployment repository but not write to it. None of
those are findable by reading the diff, and all three will happen again to
whoever runs the next release.

YAYINLAMA.md records the chain (site ← deployment repo ← this repo ← plugin),
the one-time prerequisites, the update and publish steps, what to verify, and a
table mapping each of those failures to its fix. It also writes down the two
things that lose data: renaming a plugin kind while scenes hold the old name,
and making the deployment repository public while a .env sits in its root.

The lockfile step points at the Relock workflow rather than `bun install`,
because a sandbox cannot compute the sha512 of a GitHub tarball it is not
allowed to fetch — which is the reason that workflow exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EvTkeoX8srShi7YwB2kZzw
b16d1c8 is two days old. Since then the plugin fixed the two things reported as
"warehouse equipment behaves on its own whim": its nodes never entered the
host's shadow-frustum fit, so shadows cut off at an invisible line and the whole
scene's shadow quality shifted whenever a rack was selected; and its materials
ignored the Display menu, so Render and Textures changed host geometry while
racks and pallets stayed as they were. Cargo also moved into the collective
pool, which takes a 3,000-pallet warehouse from 6,000 draw submissions a frame
to 80.

1c73ce2 is the head of the plugin's main at its v0.1.1 tag.

The bundle goes to 2.14.0 rather than a patch: the appearance work is a fix from
the plugin's side, but from the site's side the same scene renders differently
after this, which is a release worth naming.

bun.lock is left to the Relock workflow — this environment cannot compute the
sha512 of a tarball it is not allowed to fetch. The trailing comment in
relock.yml is what fires it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EvTkeoX8srShi7YwB2kZzw
Warehouse equipment follows the editor's Display → Units setting now. It
previously printed every dimension in metres regardless: an Imperial user read
a host wall in feet and the rack beside it in metres, and in the stats tab an
area in ft² sat next to an elevation in m on the same row.

Figures quoted from EN ISO 14122-3, EN 15620 and the Mecalux catalogues stay in
the unit their source publishes — converting a cited 220 mm riser to 0'9" would
invent a precision the document does not have.

Also fixes an inspector crash: the drive-in panel called a hook below its early
return, so selecting a lane after the panel had rendered empty threw "Rendered
more hooks than during the previous render".

Nothing changes for a metric user; every conversion is the identity on that
path.
The plugin pin moved to v0.1.2 (49b2f16) in the previous commit, but the deploy
template was left at 2.14.0 — the bundle would have been published claiming to
be the release it replaces.

v0.1.2 is the Units work: every readout in the plugin's panels now follows the
host's Units setting instead of printing metres regardless, including the case
where one row's area was converted while the elevation beside it was not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EvTkeoX8srShi7YwB2kZzw
`49b2f16` → `fd22b04`. Editör kaynağında değişiklik yok.

Bu dal, üretim bundle'ının gerçekte üretildiği dal: `deploy-bundle.yml`
`push: branches: [main]` diyor ama `main`'in `apps/editor/next.config.ts`'inde
`output: 'standalone'` yok, o yüzden main'den build alındığında
`.next/standalone/apps/editor/server.js` hiç oluşmuyor ve "Assemble the bundle"
adımı düşüyor. Şimdiye kadarki başarılı deploy'ların hepsi bu daldan elle
`workflow_dispatch` ile koşmuş.

Pin bu dala gelmediği sürece eklenti düzeltmeleri canlıya çıkamıyor: eklenti
uygulamanın içine derleniyor, yani bir eklenti sürümü siteye ancak yeniden
derlemeyle ulaşıyor.

Atlanan iki sürüm:

**v0.1.3 — kirli bayrağı tüketimi.** Host'un `FloorElevationSystem`'i kirli
düğüm bayrağını yalnız `def.geometry`/`def.system` bildirMEyen kind'lar için
düşürüyor; bildirenler kendi bayraklarını kendileri tüketmek zorunda. Eklenti
`def.system` bildirip hiçbir yerde `clearDirty` çağırmıyordu. Sahne yüklenirken
her slab ayak izine değen her `floorPlaced` düğümü kirletiyor — depodaki
rafların tamamı — ve bayrak bir daha düşmüyordu. `dirtyNodes.size === 0` on iki
host sisteminin ortak erken çıkışı olduğu için sahne açık kaldığı sürece hepsi
kümenin tamamını her karede geziyordu.

**v0.1.4 — raf başına tekrarlanan hesaplar.** Şekil anahtarı artık düğüm
nesnesine memoize (çağrı başına 12,7 µs → 0,31 µs) ve kademeli mount kapısı
kolektif çizen on üç kind'ın hepsinde, yalnız `warehouse:pallet-rack`'te değil.

Kullanıcının makinesinden gelen taban ölçümü (pin `49b2f16`, yani düzeltmesiz):
41,3 saniyede 22 kare, p50 kare 2098 ms, ana iş parçacığı %98,9 bloke.

peerDependencies bloğunun v0.1.4'te birebir aynı olduğu doğrulandı. bun.lock
elle güncellendi: `bun install` bu ortamda `pascalorg/plugin-trees` tarball'ında
403 alıyor. Aynı SHA üç yerde geçiyor, üçü de değişti.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012VUVkZWKGN5B2oyEnAPjGg
49b2f16 -> fd22b04. Editör kaynağında değişiklik yok.

Pin main'e de kondu (#2) ama main'den bundle üretilemiyor: next.config.ts'inde
output: 'standalone' yok, o yüzden .next/standalone/apps/editor/server.js hiç
oluşmuyor ve "Assemble the bundle" adımı düşüyor. Üretim bundle'ı bu daldan
elle workflow_dispatch ile üretiliyor, o yüzden pinin canlıya ulaşması için
burada olması gerekiyor.
Both MFA routes consulted the lock only on the FAILURE path: a wrong code
called `registerFailure` and the response then said `account_locked`. A
correct code never met the lock at all — it was verified, `clearFailures`
ran, and `mfa_pending` was cleared. So a locked account still signed in, and
the lock counted misses and announced a state it never enforced.

The password step does check first (`auth/signin/route.ts:47`), so the gap was
the second factor alone — precisely the step whose reason to exist is that a
stolen password should not be enough. An attacker holding the password gets a
session in `mfa_pending` and can then guess the code with no effective budget,
which is what the comment above `POST` already promised would not happen.

`readLockState()` existed in `panel/lib/auth/lockout.ts` for exactly this and
was not called anywhere; both routes now call it immediately after the session
check and refuse before spending a code. `ovurrsl/panel`'s copies of both
routes already do this — the guard was dropped while vendoring, not designed
away, so this restores the upstream behaviour rather than inventing a policy.

Recovery codes matter more here than TOTP: they are the credential that
survives losing the authenticator, so an unbounded guessing budget against
them is the weakest point in the whole second factor.

Not covered by a test: this repository has no harness for API routes (every
existing test is a pure-function test — `scene-api-security`, `env-file`,
`graph-schema`, `mail-template`), and these routes need a database and a live
session. A real guard would assert that a locked account is refused even when
the submitted code is correct. Verified here by reading the order of
operations and by `tsc --noEmit` over `apps/editor` (no errors in either file).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012VUVkZWKGN5B2oyEnAPjGg
The sync would have created `scenes-tab.tsx` and `guides-tab.tsx` upstream and
overwritten `console-tabs.ts` to register them, while `ROUTES` (create-new is
`false`) declined to push the endpoints behind them. The standalone console
would have gained two rail entries whose fetches 404.

The endpoints cannot exist upstream, which is why declining them is right and
the tabs are what is wrong:

  /api/guides       imports the editor's own `@/lib/guides-content` and
                    `@/lib/scene-api-security` — absent in the console repo,
                    so the route would not even build there.
  /api/admin/scenes reads a `scenes` table. No migration in the synced set
                    creates one; `004_site_scene.sql` only adds
                    `sites.scene_id`. The console's schema has no scenes.

So all three files join `EDITOR_OWNED`. Verified with `--check` against a
checkout of ovurrsl/panel: 133 planned actions before, 130 after, and the
three named files gone from the list.

The cost, stated plainly: changes to the SHARED tabs' metadata in
`console-tabs.ts` no longer reach upstream either. That is what a file which
must legitimately differ between two deployments costs. Splitting the list
into a base the console owns and an extension the editor adds would remove the
conflict properly; the comment says so, so the next person does not read this
as the intended end state.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012VUVkZWKGN5B2oyEnAPjGg
Verified by applying the sync to a real checkout of ovurrsl/panel and running
that repository's own checks. Its test suite passed with all of these broken —
`tsc --noEmit` is what caught them, so it is the check that matters when this
list changes.

`tab-content.tsx` imports both `guides-tab` and `scenes-tab` and switches on
their names. Holding the tabs back while pushing their only caller turned a
missing feature into a repository that does not compile:

  tab-content.tsx(4,27):  Cannot find module '@/components/console/guides-tab'
  tab-content.tsx(32,10): Type '"scenes"' is not comparable to type
                          '"audit" | "integrations" | ... | "users"'

`api/health/route.ts` is the same shape one layer down — the editor's copy
imports `@/lib/auth/db` and `@/lib/scene-store-server`, neither of which exists
upstream. It is an existing upstream path, so `ROUTES` overwrote it.

Test files now never cross. The two repositories run different runners — this
one is on `bun:test`, the console on vitest — so `mail-template.test.ts` landed
upstream as a file vitest does not collect (its config globs `tests/**`) and
`tsc` cannot resolve (`Cannot find module 'bun:test'`). The console's own suite
lives in `tests/`, outside every mapping here, so nothing in this direction can
reach it either way.

After: 127 planned actions. In the panel checkout, `npm run typecheck` clean and
`npm test` 59/59.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012VUVkZWKGN5B2oyEnAPjGg
The console repository is the home of the console now, so the direction that
matters day to day is inward. `--pull` adds it, inverting the existing tables
rather than declaring its own: a mapping added for one direction is a mapping
the other gets for free, which is the only way two directions stay honest.

Inverting is not a swap. The console's tree nests where the editor's does not —
`src/app/api` and `src/app/globals.css` both live INSIDE `src/app` — so walking
`src/app` naively drags the console's endpoints into `app/(panel)/api/` and its
stylesheet into `app/(panel)/globals.css`, both wrong and both silent. Longest
source path first, and the first pair to claim a file keeps it.

`EDITOR_OWNED` needed no second list: it holds console-side paths, so it is the
origin on pull and the target on push, and it means the same thing either way —
the editor's copy is the authority.

`create` is dropped on pull. Its `false` on `ROUTES` protects the CONSOLE from
receiving editor-only routes; the mirror risk, the editor receiving a console
route it lacks, is not a risk but the point.

## The asymmetry this surfaced

A dry run against the real console reported one file, `console-shell.tsx`,
while the push direction called the same pair in sync. That contradiction is
the signature of a real defect: the forward rewrite is LOSSY. `@panel/x` and a
literal `@/x` both leave as `@/x`, so the inverse cannot tell them apart. The
file held a comment naming `@/lib/escape-layers`; push left it, pull would
rewrite it, and the two directions would have "fixed" each other forever.

Fixed at the source — in the editor that module IS `@panel/lib/escape-layers`,
so the comment is now true as well as stable, and both directions report clean.
The constraint it implies is written down where the rewrite lives: a synced file
under `apps/editor/panel/` must never contain a bare `@/`, in an import or in
prose, and the way that mistake shows up is exactly the contradiction above.

## Verified against the real repository

Not a dry run only. With ovurrsl/panel checked out at its merged `main`:

- both directions report in sync, exit 0 — the round trip is stable
- a change to `src/lib/cn.ts` plus a NEW `src/lib/__pull-probe.ts` importing
  `@/lib/cn`: pull placed both under `apps/editor/panel/lib/`, and the new
  file's import arrived as `@panel/lib/cn`
- probes removed, both repositories clean, both directions in sync again

`biome check .` clean. `bun test` unchanged from base (the one failure is the
pre-existing `graph-schema` plugin-kind case, which fails on the base branch
identically).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012VUVkZWKGN5B2oyEnAPjGg
The console repository is the home of the console, so the automatic direction
is inward. `pull-panel.yml` runs the sync with `--pull` hourly (and on demand,
and on a `panel-updated` repository dispatch the console can fire once it has a
token), commits to the integration branch, and stops there.

Gated on `bun run check-types`, the same command CI runs. Pushing straight to
the branch is what makes this automatic, and automatic without a gate means one
console commit that does not compile here quietly breaks the branch the bundle
is built from. Fails red, pushes nothing.

A scheduled run starts on the default branch, so the branch to update is named
rather than inherited: `vars.INTEGRATION_BRANCH`, falling back to the branch it
is today. Renaming it is a repository-variable edit, not a workflow edit.

## Why the outbound sync is now manual only

Both directions on `push` would give one file two masters: a change made in the
console flows here, the outbound sync fires on that very commit and pushes it
straight back, and the two spend the day answering each other — whichever ran
last looking right. It keeps `workflow_dispatch` because it is still the tool
for seeding the console after a change had to be made here, which the comment
now says.

## And a bug that would have started with the first merge

`gh pr view <branch>` resolves closed and merged pull requests too, so the plain
existence check would have edited the body of the pull request just merged and
never opened a new one — while the force-push to `sync/from-editor` had already
landed. Files would move; nothing would ask anyone to look. It now asks for the
state and only edits when it is OPEN.

Both files parse; `biome check .` clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012VUVkZWKGN5B2oyEnAPjGg
The last manual link. The warehouse plugin is a git dependency pinned to an
exact sha and it compiles into the app, so a plugin release reaches the site
only when this repository moves the pin AND the bundle is rebuilt. Doing that
by hand is what left a set of freeze fixes sitting unreleased while the
plugin's main had moved on and production had not.

`bump-plugin.yml` compares the pin with the plugin's head, moves it, relocks
with a real `bun install` (the lockfile records the resolved tarball's sha512,
which is why `relock.yml` exists at all), type checks, and commits. Needs no
secret: the plugin repository is public, so both reading its head and resolving
the dependency work with nothing configured.

Gated on `bun run check-types` for the reason an exact pin exists — a plugin
release that does not compile against this editor should stop here, visibly,
rather than reach the branch the bundle is built from.

## The handoff, which is not optional

Both this and `pull-panel.yml` end by dispatching `deploy-bundle.yml`
explicitly. A push made with GITHUB_TOKEN starts no workflow runs — `relock.yml`
already says so in its own header — so without the dispatch the pin moves, the
console lands, and the site never rebuilds. The chain would look wired and stop
one step short, which is the same shape as the failure this whole change set is
about.

Verified against the real files rather than by reading: the pin grep returns
`49b2f16…` from `apps/editor/package.json`, `git ls-remote` returns the
plugin's head, and the `sed` rewrites exactly the one occurrence. Both workflow
files parse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012VUVkZWKGN5B2oyEnAPjGg
… mirror upstream into `main`

Four workflows disagreed about which branch was the real one — `ci`, `mcp-ci`
and `deploy-bundle` watched `main`, `relock` watched the working branch,
`sync-panel` watched both. That disagreement is not cosmetic: it is why a pin
bump merged to `main` never deployed (the branch it landed on could not build,
because `next.config.ts` there carries no `output: 'standalone'`), and why a
pull request against the branch all the work happens on gets no CI at all.

They now all name `integration`, and `main` becomes what a fork's `main` should
be: a clean mirror of `pascalorg/editor`, no local commits, ever.

## Why that split, and why `integration` is the default branch

A mirror with nothing of ours on it can only fast-forward, so taking upstream
never conflicts and never needs a decision. Every conflict then has exactly one
place to happen — the pull request from `main` into `integration` — instead of
being spread across whichever branch someone last merged into.

`integration` has to be the DEFAULT branch for this to work at all, and that is
not a preference: GitHub runs scheduled workflows only from the default branch.
Leaving `main` as the default while emptying it of our workflows would have
silently killed every schedule here — the console pull, the plugin bump, the
upstream check — while the files sat on a branch no scheduler reads.

## mirror-upstream.yml

Fast-forwards `main` to `upstream/main` daily, then opens one long-lived pull
request into `integration` that accumulates whatever upstream has added.

It refuses to force. A `main` that cannot fast-forward is a `main` somebody has
committed to, and erasing that quietly is worse than a red job.

## relock.yml

Its self-triggering hack existed because dispatch needs the file on the default
branch and the working branch was not it. It is now, so the hack is gone and it
is dispatched like anything else. The trailing marker comments stay as a record
of which relocks were run that way, not as a mechanism. Routine plugin bumps no
longer come here at all — `bump-plugin.yml` moves the pin and relocks in one job.

All ten workflow files parse; `biome check .` clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012VUVkZWKGN5B2oyEnAPjGg
Depolar arası otomasyon + ikinci faktörde kilidi gerçekten uygula
`pull-panel` decided whether the console had moved by reading a bare
`git status --porcelain`. That is never empty in this job: `tee pull.log`
writes an untracked file and the `actions/checkout` of ovurrsl/panel leaves
an untracked `panel-upstream/` directory, and .gitignore covers neither.

So `changed` was true on every run. A run where the console had not moved
still installed and type-checked, then reached `git commit` with nothing
staged — which exits non-zero. Every hourly run against an unchanged
console would have gone red, and the real signal would have been buried in
the noise.

Scope the check to the three paths the commit actually stages, so the two
agree by construction. Also `set -o pipefail`: the step's default shell is
`bash -e`, not `-eo pipefail`, so `tee` was reporting its own exit status
over a crashed sync.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012VUVkZWKGN5B2oyEnAPjGg
The pull request step was gated on `moved` — did this run push the mirror.
`main` can also be advanced by hand, and when it is, no run ever pushes, so
no pull request is ever opened and the update sits on `main` unseen. That
is not hypothetical: 42 upstream commits accumulated there today with
nothing pointing at them.

Gate on the question that actually matters instead — is the integration
branch behind the mirror — and carry the count into the body. Fetch the
integration branch explicitly rather than relying on checkout's refspec, so
the count has both sides to compare.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012VUVkZWKGN5B2oyEnAPjGg
UPSTREAM.md still described the plugin bump as a manual pin edit and the
console as an outbound sync, both of which stopped being true when
`bump-plugin` and `pull-panel` landed. A topology document that is wrong
about which direction the console flows is worse than none.

Corrects those rows, records the two-branch model and why the integration
branch has to be the default one, and adds the two conflict rules learned
from the beta.4 trial merge that were costing a rediscovery each time.

OTOMASYON.md is the same picture in plain language for whoever operates
this rather than edits it: what runs when, which secret each workflow
needs, what the smoke tests guard, and where to look when a link goes
quiet. Every trigger, secret and gate in it was read out of the workflow
files and checked a second time rather than recalled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012VUVkZWKGN5B2oyEnAPjGg
`AGENTS.md` here is upstream's file, and `CLAUDE.md`, `GEMINI.md` and the
copilot instructions are all symlinks to it. It is accurate about the code
and completely silent about this fork: it does not say that `integration`
is the default branch, that `main` is a pure mirror nothing may be
committed to, or that the plugin pin and the console arrive here on their
own every hour.

An agent opening this repository therefore reads instructions written for
`pascalorg/editor` and acts on them. The expensive version of that mistake
is a commit on `main`: the mirror stops being a mirror, `mirror-upstream`
refuses to force over it — correctly — and every later upstream take needs
a hand-unwind first.

A short block at the top, ahead of upstream's title, with the four facts
that cost something when unknown and pointers to `OTOMASYON.md` and
`UPSTREAM.md` for the rest. It conflicts on every upstream merge because
upstream has no equivalent, so the resolution is recorded in UPSTREAM.md
alongside the other per-file rules: keep the block, take their body.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012VUVkZWKGN5B2oyEnAPjGg

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Want reviews to match your repository better? Bugbot Learning can learn team-specific rules from PR activity. A team admin can enable Learning in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit ec6f460. Configure here.

const session = await getSession()
if (!session || session.mfaPending) {
return new Response('unauthorized', { status: 401 })
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Jobs stream skips admin gate

High Severity

GET /api/jobs/stream only checks for a signed-in session, while GET /api/jobs and the cancel/retry routes require admin_access. Any authenticated non-admin can open the SSE stream and receive the full job queue, bypassing the admin-only gate the polling API enforces.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit ec6f460. Configure here.

/** GET /api/requests — the pending strip above the user table. */
export const GET = handler(async () => {
const guard = await requireSession()
if (!guard.ok) return fail('unauthenticated', 'err.sessionExpired')

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Requests list lacks permission check

High Severity

GET /api/requests only requires a session, so any signed-in account can list pending access requests with full name, email, username, department, and notes. Approve/reject and the users list require stronger permissions (edit_users / admin_access), so this endpoint leaks colleague PII to editors and viewers who cannot reach the Users tab in the UI.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit ec6f460. Configure here.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants