Appearance
Core services & API layer
Core (backend/src/core/)
Provider clients + platform primitives with zero product knowledge. Modules and the spine read through them.
| Service | What it provides |
|---|---|
services/anthropic.js | The Claude client, and the ONLY file allowed to touch the SDK. createMessage() / streamMessage() are the guarded doors — they call ensureBudget() on entry, and createMessage writes the ledger row in a finally (so a failed or part-way call is still recorded, because the vendor still bills it). generateContent() (default claude-haiku-4-5; accepts a thinking option passthrough — adaptive-thinking models like Sonnet 5 think by default and the spend comes out of max_tokens, so output-budget callers pass thinking: {type:'disabled'}; extracts the FIRST text block wherever it sits, never assumes content[0]; a response cut at max_tokens THROWS unless the caller passes allowTruncation: true, and since 2026-09-02 books ONE status='error' ledger row that still carries the billed usage — the truncation check used to run after the ok-row write, so every truncated call booked twice) and runWithTools() (non-streaming tool-use loop, default claude-sonnet-4-6, optional extended thinking) both ride createMessage with deferTracking: true — they sum usage across iterations and write one richer row. A call with NO {workspaceId, operation} throws in dev and is booked to the global bucket flagged unattributed in prod |
spend/ | The budget guard every paid call passes through — see below |
services/supabase.js | The service-role Supabase client (bypasses RLS; never exposed to frontend) + selectAllPaginated() (pages past PostgREST's 1000-row cap) |
settings/ | The settings engine — see below |
services/dataforseo.js | DataForSEO client: expandSeeds, validateKeywords, serpOverview, inferDomainLocale (workspace-less locale: ccTLD → <html lang> region subtag → language's biggest market — details on the lead-audit page); AI surfaces: llmScrapeChatGpt + llmScrapeGemini (real-UI scrapers, not week-cached), serpAiOverview + serpAiMode (Google AI blocks, week-cached), llmMentionsQuestions + llmMentionsTopCited (LLM Mentions DB, report-priced — callers gate on stored-snapshot age), aiKeywordVolume, backlinksBulkRanks (2026-08-22 — one flat call, up to 1000 targets → domain rank map; the market scorecard's authority column); global 30-concurrent cap, in-memory weekly cache, usage logged to dataforseo_usage. Keyword metrics (validateKeywords) cache PER KEYWORD on a MONTHLY bucket, and a call sends only the keywords it does not already know — see below |
services/perplexity.js | Perplexity research wrapper (model sonar): enrichTopic() with tracked cost (prefers Perplexity's reported cost) |
services/pdf.js | Canonical HTML→PDF renderer (puppeteer, A4). Used by the lead-audit PDF and the monthly performance report (operator-run only since 2026-07-27 — no cron, no route); pdf-assets.js embeds assets |
services/url-guard.js | SSRF guard assertPublicHttpUrl for agent-facing outbound fetches — DNS-aware; blocks loopback/RFC-1918/link-local (cloud metadata)/CGNAT/reserved, on the requested AND post-redirect URL. Also normalizeWebsiteUrl(input) — canonicalizes a typed site address (acme.com, ACME.com/, www.acme.com/x?y) to one https origin, dropping the path and rejecting IP literals, credentials and non-http schemes; null when it isn't a public hostname |
services/http-fetch.js | Shared hardened fetcher: per-host throttle + 429 backoff/retry + single UA (fetchText/fetchUrl) |
services/support-identity.js | THE ONE EXCLUSION (2026-09-02): who Luniq's support account is, as pure config — getSupportEmails(), isSupportEmail(email), withoutSupport(rows, readEmail?). Support holds a workspace_members row on every workspace so it can reach one, so it must be left out of every PRODUCT surface that names people: the team lists, the pickers, the assignee filter, the agent's team, and the notification fan-out. It lives in core (not beside the access rail in workspace/agency/masters.js, which re-exports it as withoutMasters) because BOTH rings need the same answer and deps point inward only — core/notifications must not mail support and cannot import the spine. Reads the same PLATFORM_MASTER_EMAILS env var as the rail, so the two can never disagree. Not applied to the operator surfaces (/api/internal/engagement/*) or countAdmins, deliberately |
services/workspace-active.js | The single operate-gate isWorkspaceActive() (account active AND workspace live) — read by scheduler + billable-route middleware. Its liveWorkspaceCounts counts SEAT-CONSUMING live workspaces: the agency site (accounts.agency_workspace_id, included in the plan, 2026-08-29) is excluded, same rule as the per-agency count in the spine |
services/logo-processor.js | Normalises an uploaded agency logo into a FIXED box per slot (LOGO_SLOTS: wordmark 1200×300, mark 512×512 — sized for the biggest consumer (PDF cover, public header at 3× DPR), not the 24px sidebar). Separate from image-processor.js on purpose — opposite requirements (hard-edged type, alpha that must survive, a 24px display slot). Trim is per slot: the wordmark IS trimmed (it sits inline, so consistent optical size beats whatever whitespace was exported), the square mark is NOT (a designed asset — the spacing inside the square is part of it, used as uploaded; a non-square file is CROPPED to square via fit: 'cover', never padded, since added pixels are a background we invented). Then fit: 'inside'; never upscales (a faked-up logo hides the blur instead of reporting it) and returns lowRes when the result is below the slot's minCrispHeight — a hint, not a rejection. Returns aspect so the chassis can pick a render height that suits the shape. Aspect is policed only for the wordmark (the sidebar owns that container); the square slot has NO aspect gate — we own that canvas, and a non-square upload is padded (never cropped) into it. lowRes measures the SOURCE artwork, not the padded canvas. WebP q90 + alphaQuality: 100. Also analyzeLogoTone() — mean Rec.709 luminance + coloured-pixel share over OPAQUE pixels only, at 48px → {tone, mono}, the two facts the dark-mode rule consumes. Fails soft to mid (renders untouched): analysis is an optimisation, never a gate |
services/account-pricing.js | Displayed cost only (priceForSeats(); €0 during early bird — the public GET /pricing endpoint and its sign-up seat picker were removed 2026-08-11). Real prices live on the Stripe Price once core/stripe/ is flipped on |
notifications/ | NotificationService.create() inserts a workspace-scoped notifications row (the bell always fires) + emails opted-in members via Resend. Recipient chain, in order: the personal cross-workspace mute (profiles.email_notifications_enabled — the master switch on Account → Email, outranks everything) → the per-workspace stored pref (notification_preferences, managed from the same Account → Email page per workspace since 2026-08-23) → the type default. Muting email never hides the bell. types.js = the NOTIFICATION_TYPES registry (each type carries its module_id, an email gate, and optionally always). email: false is a HARD type-level gate checked before recipients are resolved, and such a type is never offered as a switch (the routes only expose emailed, non-always types). always: true marks transactional mail: it skips BOTH the per-workspace prefs and the master mute (Leon, 2026-08-23: an invite must always arrive), so nothing a user sets can stop it. The registry since the 2026-08-16 simplification (Leon) is THREE types: member_invited (email, always), signals_weekly_overview (email — the ONE recurring client mail: the Monday board-at-a-glance, composed by modules/agent/weekly-overview.js with a caller-built HTML block via create({ emailHtml }), action link → /signals), and the in-app-only agent_skill_complete + ai_visibility_change. Retired that day: agent_draft_ready (per-draft email), site_refresh_complete/_error (weekly scan mail to every member), agent_weekly_digest (the in-app retrospective — replaced, not renamed), and the orphaned agent_article_published (its auto-publish producer went 2026-08-12); earlier monthly_report (2026-07-27). A type nothing can fire is a preferences toggle for an event that never happens; old rows stay in the bell and render through the frontend's humanize() fallback. admin-alert.js = throttled 5xx operator email; new-agency-alert.js = the operator heads-up when an agency finishes its setup (there is no approval step since 2026-08-27, so the "you're approved" mail is gone with it) |
misc services/ | image-processor.js (workspace image dimensions), html-text.js (entities, HTML→text; extractPageTitle was removed 2026-08-16 with GSC discovery + the title-repair sweep, its only callers), sitemap.js (the live site's URL inventory: readSitemap — robots.txt Sitemap: directives first, conventional paths only as fallback, nested indexes followed to depth 3, capped at 50 documents — plus fetchSitemapUrls (URL set only, back-compat), dominantOriginOf, isListingPage. readSitemap returns { urls, complete, documentsRead, sources, failed }; complete is load-bearing — every caller that concludes a URL is ABSENT must stay silent when it is false) |
services/job-runs.js | The run ledger, LIVE since runs-and-actions PR B (2026-09-02): startJobRun() opens a 'running' row at fire time (null finished_at) and finishJobRun() closes it — so "is something running right now" is answerable and the Activity tab's Routine section reads listRunningJobRuns() (6h presentation age-guard). sweepStaleJobRuns() at scheduler boot closes strays as 'interrupted' — before this, a deploy mid-run left NO row and read as "never fired". startJobRun is fail-open against a database that predates the job_runs_running_status migration (status CHECK refuses 'running'): it returns null and makeCronGuard falls back to the legacy path — recordJobRun(), one fire-and-forget insert per completion, kept for exactly that fallback (2026-08-15; the GSC sync half-failed on 08-08/09/10 and the database showed it as a decline). Never awaited on the write path, never throws: a cron cannot fail because its bookkeeping did. latestJobRuns(workspaceId, jobIds) is the read — since 2026-08-23 one newest-row query PER job (the shared N×5 window let daily jobs crowd a weekly one out), duration_ms included; the board's run line and get_automation_status read it. Deliberately NOT batched — ~100 rows a day fleet-wide does not justify a queue, and core/ may not import modules/observability/micro-batcher.js under the ring rule |
services/dataforseo.js → market resolution | TWO resolvers, two questions, deliberately not merged: resolveDataForSeoLocale — "which index validates keyword volume for content in language X?" (the language's biggest market); resolveBusinessMarkets (2026-08-23) — "where does this business compete?" → CANDIDATE (location, language) pairs from service areas × published languages (declared: true) plus each language's default market (declared: false), most-primary first. The market module's reconcileMarkets keeps only the DECLARED ones (a market is what the workspace is set up for); marketLabel(locationCode) is the one place a market is named. tldLocationCode(domain) (2026-08-23) answers the neighbouring question — which country a domain's ccTLD claims, null for generic TLDs — so a candidate rival that plants its flag in another country is not offered as a local competitor. serpOverview also returns organicDomains (top-10 hostnames + position, parsed from the same paid response) — how the market module finds rivals before it ranks for anything: search the demand, read page one |
services/business-identity.js | What a company IS, and whether it is a rival. readBusinessContext(domain) — fetch a homepage and flatten it to title/H1/meta/body-excerpt (the reader lead-audit used on prospects, moved here when market needed the same thing for rival domains; lead-audit re-exports it as buildSiteContext). judgeRivalry(ourBusiness, theirs) — one Haiku call classifying competitor / platform / publisher / directory / marketplace / other / unknown. Exists because ranking for the same searches makes two sites neighbours, not competitors: webflow.com, forbes.com and g2.com all rank for "website optimization" and none of them is a company an agency's client would hire instead. Best-effort both halves — an unreachable homepage or a failed judgment is unknown, never a false verdict |
services/keyword-relevance.js | The ONE judge of "is this keyword THIS business's demand?" — filterRelevantKeywords(businessContext, rows, { label, maxKeywords, localBusiness }), one Haiku call per 40 keywords (small batches keep it strict), best-effort: any failure returns the input unchanged. Two callers build the CONTEXT differently and share the JUDGMENT: lead-audit from a prospect's homepage (relevance.js#buildSiteContext), market from the workspace profile (market/identity.js#businessContext). No mechanical filter can separate "imprimerie ciney" from a B2B agency's field — only reading what the business is |
services/keyword-field.js | Pure payload math over Labs responses, shared by lead-audit's one-shot gap and the market module's weekly sync (2026-08-22): pickComparableRivals (drop-self, ≥2 intersections, size-ratio cap ×50 on full_domain_metrics), brandTokensOf/isBrandedKeyword, rankedKeywordRow, volumeTrendOf (12-mo demand slope), and answeredByPath — the already-answered guard, moved here from gsc's detectors (gsc re-exports it as alreadyAnsweredBy; calibrations traveled with it). Zero product/DB knowledge — the seam that keeps modules from importing each other |
utils/ | job-context.js (runs-and-actions PR E: runWithJobContext/currentJobId via AsyncLocalStorage — the runner wraps every skill handler, the observability tracker stamps the ambient job id on each api_events row, and cost-per-action becomes a plain sum), language.js, countries.js (the worldwide country catalog: ISO ↔ DataForSEO location code + market languages, ~85 major markets, every code verified against the vendor's location list; Russia deliberately absent — the vendor has no supported location for it), onboarding-logger.js, cron-stagger.js, cron-guard.js (the run ledger + error alarm; the setup/automation composition consumers read is modules/setup-state.js, wiring ring: records every guarded run — ran / threw — hooked here because it is the one seam all module schedulers pass through; since 2026-08-16 it holds NO gate — readiness and the pause switch are decided once, at fire time, by the scheduler chokepoint via workspace/readiness.js#automationGate), with-timeout.js, and the three pure agent-tool conventions (2026-08-23 coverage wave, docs/reference/agent-tools.md → Conventions): window.js (WINDOW_PARAMS/windowOpts — the ONE range | from+to reporting window every tool shares; pixel and GSC re-export it), url-forms.js (urlForms(url) — slash-toggled / http→https forms for lookups against stored strings; identity joins still use the spine's normalizeUrl), tool-envelope.js (listEnvelope {total, offset, returned, truncated}, emptyResult, readError, guardRead — the one list / empty / error shape) |
services/dataforseo.js is the vendor containment boundary (branding contract, 2026-08-05). DataForSEO is Orbit's search-data vendor and the product never says so. Everything that can reach a person — agent tool descriptions (which are the MCP tool descriptions verbatim), errors that ride a tool result into chat or a job's error field, the lead-audit PDF, the app, the user docs — says Orbit. surfaceName(path) in the client maps each endpoint family to its Orbit-facing name (/v3/serp/ → "Orbit search results", /v3/backlinks/ → "Orbit link data", /v3/on_page/ → "Orbit site crawler", /v3/ai_optimization/ → "Orbit AI-surface scan", everything else → "Orbit keyword data"); every throw out of callDataForSeo is wrapped in one, with the raw error kept on cause and the raw path kept in the log line + the cost ledger. The vendor name deliberately survives in exactly five places: this client (+ its env vars, dataforseo_usage, and the ledger's provider: 'dataforseo' — the internal cost dashboard must reconcile against a real invoice), countries.js's location-code column, the DPA's subprocessor table, these dev docs, and ONE note in frontend/src/docs/what-orbit-can-do.md ("Where the numbers come from"). Adding a sixth is a bug — do not put a vendor name or a raw endpoint path in anything a user or an MCP client can read.
cron-stagger.js deserves a note: staggerCron(expr, workspaceId) shifts a fixed m h * * * schedule by a stable per-workspace offset (0–179 min, a 3-hour window) rather than replacing its time. Shifting is load-bearing — every cron a workspace schedules moves by the SAME offset, so two jobs a module deliberately spaced apart stay spaced apart. The earlier version derived the minute from the workspace hash alone, which collapsed every fixed cron in the same hour onto one firing time and silently broke the agent module's 05:00 signals sweep → 05:30 research ordering. staggerOffsetMinutes() exposes the offset; the invariant is asserted by scripts/test-autopilot-safety.mjs. Expressions with ranges/lists/steps are deliberate operator choices and pass through untouched.
The spend spine (core/spend/)
One guard, one ledger, four doors. Vendor money leaves the building through exactly four files — services/anthropic.js, services/dataforseo.js, services/perplexity.js, and workspace/site-context/embeddings.js — and each calls ensureBudget() on entry and track() on exit. Putting the check inside the client rather than at an entry point is what makes coverage total: chat, crons, routes, MCP and public endpoints are all covered by construction, because the cap is not their job. Before this, the check lived at agent dispatch boundaries only, so MCP-triggered tools reached the search-data vendor with no cap at all, and the four raw getClient().messages.* loops (chat, analysts, daily-run, docs-chat — ~90% of LLM spend) bypassed the wrappers that were supposed to guard them.
ensureBudget({ workspaceId, estUsd, reserveUsd, operation, scope, window })throwsSpendCapErrorwhen the budget is gone. FAIL CLOSED: an unreadable ledger refuses the call rather than silently disabling the ceiling.- Two ceilings ship: per workspace per workspace-local day (
DEFAULT_DAILY_CAP_USD= 15, overridable per workspace viaagent.dailyCostCapUsd), and one global bucket for workspace-less spend (GLOBAL_DAILY_CAP_USD= 10) — setup prefill and the public lead-audit endpoint spend real money with no workspace, and "no workspace" must never mean "no ceiling". scope/windoware parameters from day one even though only('workspace','day')and('global','day')exist. The agency/month pair (the deferred credit pool) then lands as an implementation of an existing signature instead of a redesign of every call site.api_eventsis the only ledger. The rows the clients write are the rows the guard sums, so the cap can never disagree with the dashboard or the invoice. There is no second table.- The guard runs on every paid call, so spend + cap are cached per key for 10s and each call's own estimate is added locally in between (
accrue) — a burst inside one window still climbs toward the cap instead of all seeing the same stale floor.estUsdonly answers "is there room for one more"; it never bills. modules/agent/safety/cost-cap.jsis now a thin pre-flight over this module (checking before a run is built is an optimization; the enforcement is in the clients and cannot be skipped).
Two mechanisms keep it closed as the code grows: scripts/check-spend-guard.mjs (in verify) fails the build if a vendor URL or the Anthropic SDK appears outside its guarded client, if getClient().messages is called anywhere else, if a model literal has no pricing.js row, or if a file that defers tracking never calls track(). And scheduler/spend-alarm.js (daily, 08:00 UTC) mails the admin when yesterday's fleet spend is unlike any day in the trailing fortnight, when too much spend is unattributed, or when the ledger has gone silent. The gate catches a missing guard; only the alarm catches money burnt correctly, through the guards — a retry storm, a loop, a config slip. The alarm's baseline is the trailing max, not the median, because the research schedule is deliberately bimodal (two full days a week at ~$25 fleet-wide, three light days at ~$6) and a median baseline would page on every full-research day and be muted within a week.
Keyword metrics are bought once a month, not once a run. validateKeywords caches each keyword's {volume, kd, cpc, intent} individually, keyed location::language::YYYY-MM::keyword, and sends the vendor only the keywords it does not already hold — when every keyword is known it makes zero calls instead of three. Both halves were measured from the cost ledger (2026-08-14): this was ~55% of all search-data spend, because the batch-keyed cache only hit when two callers asked with a byte-identical list, and each analyst lens builds its own overlapping-but-different list. These endpoints are priced per CALL, so the saving comes from not asking, not from asking for less. The monthly bucket matches the real refresh rate — volume is published monthly at the vendor, difficulty and intent move slower still — where the old weekly bucket paid four times a month for data that changed once. SERP state and the AI-surface scrapes keep the weekly bucket; those genuinely move. A leg that FAILS is never cached (the caller still gets the partial row) — caching it would serve the hole for the rest of the month. scripts/test-keyword-cache.mjs locks all of this down with a stubbed vendor.
The settings engine (core/settings/)
settingsManager resolves workspace_settings rows as schema defaults ← stored overrides, with deep-merge for json fields (opt-out via mergeStrategy: 'replace'), rejects unknown keys, and runs one-time legacy migrations for the 1a-blog-publisher row (retired keys self-strip on read — most recently buyerLanguage, 2026-08-24: the writer read it but nothing had written it since the pre-April onboarding was removed, so only workspaces born before 2026-04-02 carried one and no surface could fill it; before that avgLeadValue, 2026-08-20: value is per conversion goal now, avgLeadValueCurrency stays as the workspace display currency).
Namespaces model: four logical namespaces (profile, publishing, agent, gsc) are projections over ONE physical row whose storageModuleId is the frozen '1a-blog-publisher'. A namespace is a named subset of schema keys, not a separate row — getNs(ws, 'profile') and get(ws, '1a-blog-publisher') read identical bytes. Invariant (enforced by test-namespaces.mjs + boot-time assertNamespaceCover): the namespaces form a total, disjoint cover of the schema. validateNamespaceUpdates rejects foreign-but-valid keys at the namespace boundary.
API layer (backend/src/api/)
Thin transport only — new endpoints go in a module's routes/, never here.
Settings writes (api/routes/modules/settings.js): the PATCH is the ONE legitimate writer of the frozen '1a-blog-publisher' row, so the invariants that protect it live there — the onboarding lock (the row is read-only while a setup pipeline is running; since 2026-09-02 only while that run is actually ALIVE per modules/onboarding/liveness.js — a 'running' flag orphaned by a deploy killing the detached pipeline no longer holds settings hostage, and the same assessment lets /status report the death on the next poll and /setup accept a retry immediately instead of after the 45-minute deadline) with its self-write allowance, and the CMS lock (assertCmsTypeChangeAllowed, spine): once a workspace has published articles its cmsType can no longer change — nor be cleared to null, the "No CMS yet" choice a CMS-optional workspace can make (2026-08-27; the route asserts whenever cmsType is in the body, null included) — because every collection's cms_collection + fields map describes the OLD platform and nothing re-derives them — the switch would publish blank items to the new CMS while reporting success and strand the live ones behind ids that no longer resolve. 409 cms_locked_by_published_content. Errors thrown with status/code are passed through rather than flattened to 400, so the UI can state the rule. Server-side pipelines write the row through settingsManager directly, and since 2026-09-02 that includes the prefill route: POST /api/onboarding/:ws/prefill persists the detected publish languages fill-only (persistDetectedLanguages) — never over a saved value — so the detection survives a wizard reload instead of living only in the client until /setup.
Auth ladder (middleware/auth.js): requireAuth (Supabase JWT → req.user + observability context) → requireWorkspaceMember (a workspace_members row is the only within-workspace gate — no roles) → requireActiveWorkspace (billable actions via isWorkspaceActive; archived workspaces keep read access). Plus requireInternalUser (confirmed @luniq.io, predicate isOperatorUser in workspace/agency/viewer.js) and requireWorkspaceCreator — a thin wrapper over THE workspace-creation policy, workspaceCreateGate in workspace/agency/create-gate.js: operators, or the admin of an active agency with a free seat (tightened 2026-08-10 from any-staff). Its 403 carries a machine reason (no_agency | suspended | not_admin | payment_required | over_capacity | no_seats — payment_required is billingAccess in core/services/account-billing.js, the one plan predicate isWorkspaceActive reads too; live only with BILLING_ENABLED), and GET /api/agency/me returns the same verdict as createWorkspace so the UI renders the states the server enforces.
The platform master ([email protected], workspace/agency/masters.js) never touches this ladder: it rides materialized membership — a real workspace_members row on every workspace, fanned out at create — so requireWorkspaceMember and everything behind it stay unchanged; operator status carries the rest.
Agency gates (same file, AGENCY_MODEL_PLAN §5) — two axes kept apart, all reading the one resolver in workspace/agency/viewer.js:
requireAgencyMember/requireAgencyAdmin— "what are you to YOUR agency" (the/api/agencysurface). Setreq.viewer+req.agency.requireWorkspaceAgencyStaff/requireWorkspaceAgencyAdmin— "are you staff/admin of the agency that OWNSreq.workspaceId". An ownership check on a workspace action, NOT a within-workspace role: a guest fails by definition (no agency), a staffer of a different agency fails too, operators pass. Used afterrequireWorkspaceMember. Staff gates minting/revoking a public share link — publishing outward on the client's behalf is the agency's act, so a workspace guest (a member) must not do it. Admin gates the destructive workspace actions (archive, reactivate, delete, re-run onboarding).
Public surfaces inside /api: /api/legal (the two document reads) and /api/share/:token (branded shared pages) simply omit requireAuth. That is the right pattern when the only caller is our own SPA; a surface embedded on customer sites (/pixel, /lead-audit) must instead mount pre-CORS in createApp() with its own permissive CORS. See Shares.
Module mounting (routes/modules/index.js → modules/registry.js): /api/modules/settings and /api/modules/enablement are fixed; then mountModuleRoutes mounts every registered module at /api/modules/<id> (its routeAlias + legacy routeAliases). Adding a module touches only its dir + registry.js.
Legacy/thin routes under api/routes/: workspaces, collections, agency/* (index · members · invites · workspaces · branding · billing (Checkout + portal sessions — the only payment surfaces; PATCH / also carries the pending-wizard's name/note edits); mounted at /api/agency — the deprecated /api/accounts alias was removed 2026-08-10 with its last caller, the join page), legal (the contract surface — its two document reads are mounted PUBLIC, since terms must be readable before anyone has an account; see Legal), onboarding (also the shared CMS-connection surface — types, credential fields, test, discovery — plus the two Drupal-only translation routes; see Routes), documents, notifications, invite (incl. pre-auth /resolve/:token — the one branded resolver for both invite kinds — and /signup: invite-token signups are created email-pre-confirmed — the token proved mailbox ownership), support, agent, plus public pixel (host-rewritten from pixel.orbit.luniq.io, mounted pre-CORS) and lead-audit (own SSRF/rate guards, pre-CORS). The MCP vanity host follows the pixel pattern: mcp.orbit.luniq.io/* rewrites to /api/modules/mcp/* (rewriteMcpHost), except /.well-known/* — the OAuth discovery metadata (RFC 8414/9728) stays at the origin root, mounted as public read-only JSON before the restrictive CORS.
Internal dashboard routes (api/routes/internal/, all internal-gated except client-errors): /internal/observability (api_events — GET /errors?since=&level= where level is error (default) / warning / all, plus counts for both; every captured line is stored status='error' including console.warn, so without that split a week where 306 of 494 rows were warnings read as 494 errors and the dozen real failures were invisible inside it; each event also carries a kind — bot/client/provider/agent/api/system, the taxonomy in modules/observability/error-kind.js, stamped into metadata.kind at write time by logError and re-derived at read time for older rows — so the dashboard badges rows by who the error is about, groups repeats of the same message, and hides scanner noise by default), /internal/client-errors (browser error sink), /internal/system (global cron kill-switch + operator Run-now), /internal/app-events (auth-only like client-errors: the engagement ingest — the browser reporter frontend/src/lib/engagement-reporter.ts batches pageviews / data-track clicks / visible+active-only heartbeats / one session_start+perf per session into app_events via modules/observability/app-tracker.js; validation clamps seconds 0–60, whitelists types, truncates path/target; DISABLE_APP_EVENTS=true answers 410 and the reporter self-disables), /internal/engagement (internal-gated reads for the dashboard's Engagement page: summary/timeseries/workspaces/users off the nightly app_engagement_daily rollup (scheduler/engagement-rollup.js, 04:40 UTC, kill-switch-respecting, 3-day recompute window + 90-day raw purge), paths/clicks off raw app_events, GET /sessions/:id — one session's event stream joined with client errors sharing the metadata.sessionId the error reporter stamps — and GET /users/:id (engagement-user.js, shared plumbing in engagement-lib.js): the per-user drill behind /telemetry/engagement/users/:id — identity, hour-by-hour heat, time per page, clicked controls, and the session list that opens the sessions drill; all raw-backed via the app_user_* RPCs (backend/db/engagement-user-drill-2026-08-12.sql), so it is live including today but reaches back at most the 90-day raw retention; design: plans/engagement-tracking-2026-08-10.md), /internal/accounts (accounts overview + agency approval, and the agent level ladder — GET/POST /workspaces/:id/agent reads/writes { level: full/light/off, schedule: {mon..sun: full/light/off} }; off maps to the agent.paused bit, the one off-state since the per-workspace module switches were removed 2026-08-14; surfaced in the workspace popup's "Agent level" section).
App wiring (api/index.js): createApp() mounts host-rewrite → pixel → lead-audit → /stripe/webhook (raw-body, signature-verified via core/stripe/; 404 while BILLING_ENABLED is off — the ONLY writer of the subscription mirror in workspace/agency/billing.js; a subscription event whose discount arrives as a bare id triggers one expanded re-read so the coupon summary mirrors too) → CORS allowlist → JSON → request-context → /api. core/stripe/ holds the lazily-constructed Stripe client and webhook verification and NOTHING product-shaped — seat math lives in the spine ("no Stripe client exists yet" no longer holds, but no charge happens until the flag flips). The error handler dual-channels: logError into api_events for all 4xx/5xx, throttled alertAdmin email for 5xx, and never leaks internal 5xx text to clients.
trust proxy: 1 — what req.ip is, and is not. It makes req.ip the right-most untrusted X-Forwarded-For entry, which is unspoofable and therefore correct for rate limiters and any security decision. It is not the visitor: Railway runs more than one hop in front of the container, so req.ip is a Railway edge address. Anything that needs the actual person (geo, and nothing else so far) must read the left-most public hop via getVisitorIp() in modules/orbit-pixel/services/geo.js. Conflating the two geolocated the entire fleet to our own infrastructure for 25 days — see Statistics § Geo. Do not "fix" this by raising the hop count: hop-counting is the fragility, and getVisitorIp reads left-to-right precisely so it never has to know.
One exception, answered but not filed: body-parser input failures (request.aborted, entity.parse.failed, encoding.unsupported, entity.verify.failed) and CORS-denied origins (cors.origin.denied, answered 403). A body that never parsed is a scanner POSTing junk at / or a connection that hung up mid-upload; a rejected Origin header is always a bot spoofing one, since every frontend we ship is on the allowlist. Nothing actionable, and no code of ours "failed". entity.too.large is deliberately excluded from that list: it names a limit we chose, so a real user hitting it is worth seeing.