Content engine — the /properties/[slug]/engine page (hive spec)
This is not a new plug-and-play component. It's a per-property page that composes
<VoxelTerrain>/<VoxelStructure>(seedocs/components/voxel-terrain.md, which this doc assumes) alongside two sample-content panels — "engineering" and "flight log." The operator's own framing, verbatim: "the content engine where I let people see the engineering, the minecraft of it, the flight docs."
Update (operator, 2026-08-08): the /properties/* passcode gate this doc originally
described has been removed entirely — drone-hub carries no gating of any kind now (see
CLAUDE.md's "Scope boundary" correction of the same date). Every reference below to
middleware.ts, lib/gate.ts, or a passcode gate describes an architecture that no
longer exists in this repo; kept as historical record of why this page's data flow was
originally designed the way it was, not as a description of current behavior. This page
was always synthetic sample data only, so removing the gate changed nothing about what's
actually shown here — just who can reach it (now: anyone, same as every other page).
Design discussion (RESOLVED architecture — read first):
.pHive/epics/minecraft-content-engine/docs/design-discussion.md, point 5 (the
per-slug resolution mechanism) and point 6 (FlightLogEntry's scope) — this doc explains
those decisions for a maintainer, it does not re-derive them.
Where it lives, and what it's gated by
app/properties/[slug]/engine/page.tsx — nested under the existing
/properties/:path* gate prefix already registered in lib/gate.ts's
GATED_PATH_PREFIXES and middleware.ts's matcher (added in the layer-viewer epic's
layer-viewer-gating-extension story). This page adds zero new gating logic of its
own: no passcode/session code lives in page.tsx, EnginePageClient.tsx, or
VoxelScene.tsx — middleware enforces the gate server-side before the page is ever
reached, the same "gating is applied at the route/middleware layer, not inside
components/pages" discipline every gated route in this repo follows. Unauthenticated
access to /properties/<any-slug>/engine redirects to
/enter-passcode?next=/properties/<any-slug>/engine.
page.tsx is a Server Component, not a client component — unlike its sibling
app/properties/[slug]/page.tsx (which is "use client" + useParams, purely so it can
call next/dynamic({ ssr: false }) directly). This page needs to synchronously check
whether real per-slug files exist on the server filesystem and read them — a Node fs
operation, only legal in a Server Component. Since Next's App Router rejects
ssr: false on a next/dynamic call made directly inside a Server Component,
EnginePageClient.tsx is the "use client" boundary that owns the actual
next/dynamic(() => import("./VoxelScene"), { ssr: false }) call for the heavy WebGL
viewer — page.tsx does the server-side data resolution and hands the resolved
props down.
The per-slug real-vs-fallback resolution mechanism — the least obvious, most important part
Read this section carefully — it's the actual fix a grill review forced into this
epic's design, and it's easy to misdescribe as "just show sample data" if you haven't
read lib/content-engine-resolution.ts directly.
The problem it fixes
The original draft of this page would have shown identical, hardcoded sample content on
every property's /engine page, forever — no path to real per-property data ever
existing, and no way for a visitor to tell "this property genuinely has no records yet"
apart from "this is generic demo content, indistinguishable from every other property's
page." A second, related problem: the sample terrain <VoxelTerrain> renders is the
exact same grid the public, ungated /components/voxel-terrain showcase page already
displays — showing that identical content behind a passcode gate, with no signal that
it's the same public demo data, would misleadingly imply an exclusivity that doesn't
exist.
The fix
lib/content-engine-resolution.ts implements a genuine per-slug lookup with an
explicit, visible fallback, mirroring conventions already established elsewhere in this
codebase for exactly this shape of problem (TourEdge.clip: null → wipe fallback,
LayerDef.disabled: true → no render — see lib/tour-types.ts / lib/layer-types.ts):
export function resolveContentEngineData(baseDir: string, slug: string): ContentEngineResolution {
const real = readContentEngineFiles(baseDir, slug);
const isFallback = real === null;
const content = real ?? readContentEngineFiles(baseDir, SAMPLE_SLUG);
return { isFallback, content };
}
readContentEngineFiles(baseDir, slug) looks for both
<baseDir>/<slug>/engineering.md and <baseDir>/<slug>/flight-log.json on disk; it
returns the parsed content only if both files exist, and null if either is missing.
resolveContentEngineData tries the real slug first; if that comes back null, it falls
back to the generic dataset at <baseDir>/sample-house/ (SAMPLE_SLUG) instead. The
resulting isFallback: boolean is exactly the signal
EnginePageClient.tsx uses to decide whether to render the visible banner — it is
computed from "did a real per-slug folder actually exist," not hardcoded, not a
constant, and not inferred from anything about the slug string itself (the safety guard
below is a separate, narrower check — see "Path-traversal safety" below).
In production, page.tsx calls this with baseDir = path.join(process.cwd(), "public", "content-engine"), so for a real request the actual filesystem layout is:
public/content-engine/
├── sample-house/ # the fallback dataset (minecraft-content-engine-data)
│ ├── engineering.md
│ └── flight-log.json
└── <real-slug>/ # only created once real per-property data exists
├── engineering.md
└── flight-log.json
Today, no property has a real public/content-engine/<slug>/ folder — every request
currently resolves to the sample-house fallback and the banner always renders. That's
expected and correct, not a bug: the mechanism exists so that dropping a real
public/content-engine/2806-prado/{engineering.md,flight-log.json} pair in later
transparently switches that one slug over to real content (banner disappears for that
slug only) with no code change — the whole point of building the resolution mechanism
now, before any real per-property content exists.
baseDir is an explicit parameter on both functions — never hardcoded to
public/content-engine inside lib/content-engine-resolution.ts itself — purely so the
resolution logic is unit-testable against a throwaway fixture directory
(fs.mkdtempSync-created, torn down in afterAll) without touching the real
public/ tree. See "Test coverage" below.
The visible fallback banner
EnginePageClient.tsx renders a role="alert", high-contrast, full-width, top-of-page
banner — "Showing sample data — no real records exist yet for this property." — whenever
isFallback is true. role="alert" matches the same convention app/enter-passcode/
and <LayerViewer> already use for their own user-facing status messages, so assistive
tech announces it immediately rather than it being silently skippable. This is
deliberately not a small footnote: the story's explicit requirement was that the
banner be genuinely visible, because this is the actual fix for the rights/trust concern
above — a gated page silently showing generic public demo content, indistinguishable from
real data, would be misleading even though the stakes are lower than an unreleased asset
leaking publicly.
What is, and isn't, covered by this resolution
The engineering notes and flight log are resolved per-slug as described above. The
terrain (<VoxelTerrain>/<VoxelStructure>, fed from
public/minecraft-samples/2806-prado/heightmap.json) is a separate, deliberately
simpler concern: it is the same sample grid on every slug's /engine page,
regardless of isFallback — there's no real per-property DSM/heightmap data to fall
back from yet (2806 Prado's real photogrammetry pipeline is still blocked on
CLAUDE.md's Phase-0 nadir-pass requirement), so there's no real-vs-fallback distinction
to make for terrain today. page.tsx's own TERRAIN_SAMPLE_SLUG constant carries a
comment flagging this explicitly as a known future concern: once real per-property
WebODM/DSM output exists, terrain will need the identical
resolveContentEngineData-style per-slug resolution pattern applied to it too — tracked,
not built, here.
FlightLogEntry — deliberately minimal scope
export interface FlightLogEntry {
timestampMs: number; // milliseconds since flight start (or epoch — display-only)
lat: number; // latitude, degrees
lon: number; // longitude, degrees
altitudeMeters: number; // altitude above ground/takeoff point, in meters
}
That's the whole type — exactly {timestampMs, lat, lon, altitudeMeters}, enough to
render the flight-log table/panel this page actually displays (time, position, altitude
columns) and nothing more.
This was a real, named grill correction, not an incidental design choice — read this
before touching the type. An earlier draft of this epic designed FlightLogEntry with
fields anticipated for a different, not-yet-planned future epic: gimbal
pitch/yaw/roll, intended to support that future epic's camera-pose computation for a
telemetry-driven video overlay. Grill flagged this as speculative design, inconsistent
with this project's own established practice — GeoAnchoredModel
(lib/geo-model-types.ts) was deliberately kept separate from <Model3D>'s ModelDef
rather than pre-unified for the land-overlay epic's own then-future needs (see
docs/components/land-overlay.md), and this project doesn't design a type today to guess
a future epic's requirements — it designs each type against its own current, real
consuming code, and lets the future epic define whatever type it actually needs once
it has real requirements to design against.
The future telemetry-video-overlay epic (queued next per CLAUDE.md's priority order)
will define its own, richer type for its own camera-pose/gimbal needs — it will not
extend or reuse FlightLogEntry. Do not add gimbal/orientation/camera-pose fields to
FlightLogEntry for that epic's anticipated benefit, even though it might look like a
natural, low-cost extension once that epic starts — that's precisely the speculative
design this type was corrected away from, and doing it "for later" now would silently
reintroduce the exact mistake grill caught. If a future session is tempted to grow this
type, re-read .pHive/epics/minecraft-content-engine/docs/design-discussion.md point 6
and lib/flight-log-types.ts's own header comment first — both say this explicitly.
Path-traversal safety — SAFE_SLUG_PATTERN
export const SAFE_SLUG_PATTERN = /^[a-zA-Z0-9_-]+$/;
slug is attacker/user-influenced — it's a URL path segment (/properties/[slug]/engine)
— and both readContentEngineFiles and (transitively) resolveContentEngineData join it
directly into a filesystem path (path.join(baseDir, slug, "engineering.md")). Without a
guard, a crafted slug like ../../../../etc could walk path.join outside baseDir
entirely. readContentEngineFiles checks SAFE_SLUG_PATTERN.test(slug) first, before
any path is built, and returns null — the same "no real content for this slug" result
an ordinary non-matching property slug produces — rather than throwing or attempting the
join at all.
This is framed deliberately as both a security guard and the semantically correct
behavior, not a bolt-on patch: a slug shaped like a path-traversal attempt can never
correspond to a real property folder name anyway, so treating it identically to "no real
data exists for this slug" (i.e. falling through to the sample-house fallback) is the
right behavior on its own terms, independent of the security motivation. The regression
coverage for this (app/properties/[slug]/engine/page.test.tsx) asserts both properties
directly: SAFE_SLUG_PATTERN.test("../../etc") is false, and
readContentEngineFiles(REAL_CONTENT_ENGINE_DIR, "../../../../etc") returns null
rather than throwing or escaping baseDir.
Test coverage
The resolution logic was extracted out of page.tsx into
lib/content-engine-resolution.ts specifically so it's unit-testable in isolation — a
Server Component doing a synchronous fs read isn't itself renderable/callable from a
Vitest+RTL spec, but the plain functions it delegates to are. This is a pure,
behavior-preserving refactor (page.tsx's actual behavior is unchanged, just relocated),
the same "extract pure/testable logic out of what can't be unit-tested directly"
precedent components/VoxelTerrain/voxel-geometry.ts follows for the exact same reason
(see docs/components/voxel-terrain.md).
app/properties/[slug]/engine/page.test.tsx is the regression guard for the exact gap
grill caught during planning — the story's own framing put it at the same weight as the
model3d epic's video-tour rights-regression guard. Two directions, both exercised against
real filesystem behavior (nothing mocked out from under the function under test):
- Fallback direction, run against the real
public/content-engine/directory (not a fixture), using slug"2806-prado"— a precondition assertion first confirmspublic/content-engine/2806-prado/genuinely does not exist yet, so the test fails loudly instead of silently testing nothing if that ever changes. AssertsisFallback === trueand that the returned content is byte-for-byte the realsample-housefiles read straight off disk (not just "non-null"). - Real-data direction, using an injectable
baseDirpointed at a throwawayfs.mkdtempSyncfixture directory (never touchespublic/), containing both afixture-property/folder with content deliberately different fromsample-house's real content, and its own distinctsample-house/stand-in (so a resolver bug that ignoredslugand always fell through to somesample-housefolder can't accidentally pass by reading the fixture's own fallback content and matching it against itself). AssertsisFallback === falseand that the returned content equals the fixture's real files and is explicitly not equal to the fixture's own sample-house stand-in — this is what makes the test a genuine regression guard: a resolver hardcoded to always return the fallback regardless of slug would still produce non-null content, so a same-content-only comparison could not have caught it. A third case in the same describe block confirms a different, genuinely-absent slug against the same fixture directory still falls back correctly — proving the resolver is per-slug, not just "always real once a baseDir happens to contain some real folder." - Safety-guard coverage:
SAFE_SLUG_PATTERNrejecting path-traversal-shaped slugs,readContentEngineFilesreturningnull(never throwing) for one, and returningnullwhen only one of the two required files exists (partial data is treated the same as no data — a page must never render an engineering panel with no matching flight log, or vice versa).
Usage
Not directly importable — this is a page, not a component. To find it: sign in at
/enter-passcode, then visit /properties/<slug>/engine (e.g.
/properties/2806-prado/engine, which today always shows the fallback banner, since no
real per-slug data exists yet). The page composes:
<VoxelScene grid={grid} />(app/properties/[slug]/engine/VoxelScene.tsx) — a thin wrapper fixing the<VoxelTerrain>/<VoxelStructure>composition (structure centered at grid cell(16, 16)on the 32×32 sample grid) into a singlenext/dynamicboundary, soEnginePageClient.tsxonly needs one dynamic import for the whole scene rather than nesting two.- An "Engineering" panel — the resolved
engineeringMarkdown, rendered as preformatted text (<pre className="whitespace-pre-wrap">). No markdown-rendering library is a dependency of this repo; the content is short, already-readable structured markdown, so plain preformatted text avoids both a new dependency for one display-only panel and thedangerouslySetInnerHTMLrisk of a hand-rolled converter. - A "Flight Log" panel — the resolved
flightLog: FlightLogEntry[], rendered as a plain table (time in seconds, lat/lon to 5 decimal places, altitude in meters), keyed by each row'stimestampMs(monotonic and unique per entry, a stable key).
Phase fit
- This epic: the gated content-engine page, the per-slug real-vs-fallback
resolution mechanism with a visible banner, and
FlightLogEntryscoped to exactly this page's display need. - Deferred, tracked but not built here: per-property real terrain resolution (see "What is, and isn't, covered by this resolution" above) once real WebODM/DSM output exists for a given slug.
- Next (per the operator's 2026-08-07 priority order): the telemetry-driven video
overlay epic defines its own richer telemetry type against its own real requirements —
it does not extend
FlightLogEntry(see the section above).