One codebase. Two deployment targets. Keep the external Vercel app running for outside users while shipping the same product internally on Meta's Nest platform — with first-class analytics and keyboard shortcuts on both.
HTML-Docs currently runs on Vercel + Supabase (Auth + Postgres) + Liveblocks + OpenAI. None of these are usable for an internal Meta deployment: external SaaS is blocked by data policy, Supabase email/password isn't an accepted internal auth method, and Vercel isn't a Meta hosting target. We need to deploy this app to employees on the corp network while changing as little of the editor logic as possible — and the external version must keep running for outside users.
The right target is Nest (fbsource/nest/apps/) — Meta's internal Next.js platform. It runs the exact stack we already use (Next.js 16 + React 19 + Node 22), supports WebSockets and SSE through Proxygen/X2P, gives you employee auth at the edge for free, and ships first-class libraries for every infra dependency we have (XDB for Postgres, Manifold for storage, @nest/llm for OpenAI, Chronos for cron). The editor code itself — components/shadow-dom-viewer.tsx, lib/html-parser.ts, lib/interactive-patterns.ts, all the Tiptap / Radix UI — does not change.
@ai-sdk/openai → @nest/llm (thin adapter, same Vercel AI SDK shape).@nest/intern-auth OIDC.@vercel/analytics entirely; build a shared lib/analytics/ module that writes to the local DB on both targets.Because the external version keeps serving outside users while the internal version stands up for Meta employees, we need ONE codebase that builds to both. The pattern is a thin adapter layer + a build-time DEPLOY_TARGET env var (public | meta) that picks which implementation gets bundled. Editor code calls adapter interfaces, never vendor SDKs directly. Next.js tree-shakes the unused branch — the public build ships zero Nest code and the Meta build ships zero Supabase code.
lib/analytics and lib/shortcuts modules) is shared; only the bottom layer swaps per target.@supabase/*, @liveblocks/*, @ai-sdk/openai, @nest/*) live under lib/{db,auth,llm,storage,realtime}/impl/{public,meta}/.@/lib/{db,auth,...} only — never from the vendor SDK directly.lib/{db,auth,...}/index.ts does the dispatch at build time:export const db = process.env.DEPLOY_TARGET === 'meta'
? await import('./impl/meta')
: await import('./impl/public')
import/no-restricted-paths rule blocks direct vendor imports outside impl/.analytics, shortcuts) live alongside — no public/meta split needed.Escape hatch: if a second team starts maintaining the internal build, or the two versions need genuinely different feature sets, switch to extracting the editor core as an internal npm package consumed by two thin app shells. Until then, the single-repo adapter model is the right cost/benefit.
This section is the implementation companion to the plan above. It zooms into request lifecycle, deployment topology, authentication, the AI request flow, the database schema, per-adapter TypeScript contracts, and environment variables — enough detail to build either target without re-deriving the design.
The same HTTP request flows through symmetric layers on both targets. The middle band — Next.js routing, server actions, adapter interfaces — is identical. Only the outer edges differ: what's in front of Next.js (Vercel CDN vs X2P/Proxygen) and what's behind the adapters (Supabase vs XDB, OpenAI vs Plugboard, Liveblocks vs a WS sidecar). That's the entire architectural payoff of the adapter model.
Where things actually run, and what crosses which network boundary.
Both targets land the viewer's identity in the same shape ({ id, name, email }) before any server action runs — but via very different handshakes.
| Step | Public (Supabase) | Meta (OIDC) |
|---|---|---|
| 1. First request | Browser sends cookies if present | Browser sends cookies if present |
| 2. Unauthed? | middleware.ts redirects /settings → /auth/login |
X2P bounces to OIDC IdP, sets short-lived CAT, redirects back |
| 3. Login | email/password (or OAuth) → Supabase issues JWT in cookie | OIDC IdP issues 24h token → X2P stores in CAT |
| 4. Authed request | middleware.ts calls supabase.auth.getUser() → refreshes cookie |
proxy.ts calls @nest/intern-auth → injects unixname/FBID headers |
| 5. Server action reads viewer | createClient().auth.getUser() → { id: uuid } |
getViewer() → { id: fbid, unixname, name, email } |
| 6. Session lifetime | ~1 hour token, refresh-on-touch | 24 hours (OIDC default) |
| 7. Sign out | supabase.auth.signOut() → clears cookie |
X2P drops CAT; user re-handshakes on next visit |
The lib/auth/ adapter normalizes both into a single getViewer(): Promise<Viewer | null> call so server actions don't branch on target.
Most architecturally interesting path because it spans the client (streaming SSE), the LLM adapter (provider selection), and tool calling (which gates writes through user-approval cards).
21 tables across 7 domain groups. Postgres has foreign-key constraints and one trigger (on document_events) that auto-records mutations; XDB drops the FK CONSTRAINTs (app-level invariants instead) and replaces the trigger with an explicit recordEvent() helper called from each mutation.
Each adapter ships a types.ts defining the shape both impls must satisfy. Snippets below show the minimum-viable shape after the first slice has landed; surfaces grow as more action files migrate.
// lib/llm/types.ts — LLM adapter
export type LLMCapability = 'chat' | 'refine' | 'review' | 'vision'
export interface LLMAdapter {
getModel(capability: LLMCapability): LanguageModel
webSearchTool(): Tool | null
}
// lib/db/types.ts — DB adapter (grows per table)
export interface DBAdapter {
documents: {
getById(id: string): Promise<Document | null>
listByOwner(ownerId: string): Promise<Document[]>
getIdByShareCode(code: string): Promise<string | null>
updateHtmlContent(id: string, html: string): Promise<void>
getKnowledge(id: string): Promise<string>
getOwnerId(id: string): Promise<string | null>
updateKnowledge(id: string, knowledge: string): Promise<void>
}
regions: {
listByDocument(documentId: string): Promise<EditableRegion[]>
}
accountKnowledge: {
get(userId: string): Promise<string>
upsert(userId: string, content: string): Promise<void>
}
}
// lib/auth/types.ts — Auth adapter (planned)
export interface Viewer {
id: string // uuid (public) | fbid (meta)
name: string
email: string
unixname?: string // meta only
}
export interface AuthAdapter {
getViewer(): Promise<Viewer | null>
requireViewer(): Promise<Viewer> // throws if unauthed
}
// lib/storage/types.ts — Storage adapter (planned)
export interface StorageAdapter {
uploadDocImage(documentId: string, file: Buffer, mime: string):
Promise<{ url: string; path: string }>
uploadPdfOriginal(documentId: string, file: Buffer):
Promise<{ path: string }>
getPdfDownloadUrl(path: string): Promise<string>
}
// lib/realtime/types.ts — Realtime adapter (stubbed for MVP)
export interface RealtimeHooks {
RoomProvider: React.FC<{ roomId: string; children: React.ReactNode }>
useMyPresence(): [Presence, (next: Partial<Presence>) => void]
useOthers(): readonly OtherUser[]
useBroadcastEvent(): (event: RoomEvent) => void
useEventListener(handler: (event: RoomEvent) => void): void
}
// lib/analytics/types.ts — Shared module (no public/meta split)
export function track(eventName: string, properties?: Record<string, unknown>): void
// Written through lib/db; same call sites, same schema, both targets.
The variable space partitions cleanly. Most public secrets disappear on meta (the corresponding service is internal); meta-specific config is owned by Configerator and the Nest CLI.
| Variable | Public | Meta | Purpose |
|---|---|---|---|
DEPLOY_TARGET | public (default) | meta | Build-time impl dispatch |
NEXT_PUBLIC_SUPABASE_URL | ✓ | — | Supabase project URL |
NEXT_PUBLIC_SUPABASE_ANON_KEY | ✓ | — | Supabase anon key (client-safe) |
SUPABASE_SERVICE_ROLE_KEY | ✓ | — | Supabase admin key (server-only) |
LIVEBLOCKS_SECRET_KEY | ✓ | — | Liveblocks room auth |
OPENAI_API_KEY | ✓ | — | OpenAI API key |
OPENAI_MODEL | optional | — | Model override (public capabilities only) |
POSTGRES_URL / DATABASE_URL | ✓ | — | Postgres connection |
CRON_SECRET | ✓ (Vercel) | ✓ (Chronos) | Bearer for cron auth — same route on both |
ADMIN_USER_EMAILS / ADMIN_USER_IDS | optional | — | Admin allowlist (use unixnames on meta) |
NEXT_PUBLIC_COLLAB_ENABLED | true | false (Phase 4: true) | Stubs realtime hooks when off |
NEXT_PUBLIC_SHARE_BASE_URL | optional | — | Custom share URL on Vercel preview |
| Plugboard config | — | via @nest/llm defaults | Model routing + actor identity |
| XDB connection | — | via @nest/drizzle-xdb | Auto-provisioned per Nest app |
| Manifold bucket | — | via Manifold portal + @nest/ent config | Image + PDF blob storage |
| OIDC config | — | via proxy.ts + Configerator | Employee auth at the edge |
| Concern | Public impl | Meta impl | Adapter shape |
|---|---|---|---|
| Hosting | Vercel | Nest | Build pipeline level |
| Auth | Supabase email/password | @nest/intern-auth OIDC | getViewer() facade |
| Database | Supabase Postgres | XDB MySQL + Drizzle | Drop FK CONSTRAINTs; trigger → recordEvent() |
| File storage | Supabase Storage | Manifold via @nest/ent | Same { url, path } shape |
| LLM | OpenAI (gpt-4o, gpt-5.5) | @nest/llm — Claude Sonnet 4.5 / Llama 4 Maverick | Model-string + actorId |
| Realtime | Liveblocks | Stubbed — NEXT_PUBLIC_COLLAB_ENABLED=false | Future: own ws server |
| Cron | vercel.json | Chronos → curl webhook route | Bearer-checked route, no app change |
| Analytics | Own implementation — single shared lib/analytics/ module on both targets | No split needed | |
| Shortcuts | Central registry + useShortcut() hook — pure editor-core, identical on both | No split needed | |
nest.json — app config, framework: next, runtime: node22proxy.ts — createAuthMiddleware({ useOIDC: true, usePassthroughForOIDC: true, publicRoutes: ['/api/health', '/api/cron/webhook-deliveries'] })app/api/health/route.ts — GET returning 200 OK for FaaS health checksscripts/chronos-cron.sh + Configerator entrylib/{db,auth,llm,storage,realtime}/index.ts — dispatcherslib/{db,auth,llm,storage,realtime}/types.ts — shared interfaceslib/auth/impl/meta/viewer.ts — getViewer() wrapper around @nest/intern-authlib/storage/impl/meta/manifold.ts — @nest/ent wrapperlib/supabase/{client,server,admin,middleware}.ts → lib/db/impl/public/.supabase.from(...) call site behind a typed function in lib/db/types.ts.@/lib/db only.lib/db/impl/meta/schema.ts (Drizzle MySQL, 21 tables, column names preserved).lib/db/impl/meta/*.ts satisfying the shared contract. Keep fetchAllRegions() 1000-row pagination.document_events to recordEvent(). PUBLIC keeps the trigger; META calls explicitly.app/auth/* + app/api/auth/* — keep for PUBLIC, gate behind DEPLOY_TARGET !== 'meta'.lib/auth/personal-access-token.ts + lib/actions/agent-keys.ts — keep for PUBLIC. META uses Service User OAuth tokens (runWithAuth(token)).middleware.ts — branch on DEPLOY_TARGET.Public impl re-exports @ai-sdk/openai as-is. Meta impl wraps @nest/llm/server behind the same interface — same streamText / generateText signatures, same tool definition shape. Callers import from @/lib/llm only.
Model selection: each call site passes a capability tag ('chat', 'refine', 'vision'); each impl maps capability → concrete model id (public: gpt-4o / gpt-5.5; meta: claude-sonnet-4.5 / llama4-maverick). Tool definitions stay identical.
Move existing liveblocks.config.ts exports into lib/realtime/impl/public/. Meta impl provides hooks with identical TypeScript signatures but no-op runtime: RoomProvider renders children, useMyPresence returns default state, useOthers returns [], broadcast/listener hooks are no-ops.
Consumer components need no changes. app/api/liveblocks-auth/route.ts stays gated for PUBLIC. UI affordances read NEXT_PUBLIC_COLLAB_ENABLED and hide when off.
package.json — keep @supabase/*, @liveblocks/*, @ai-sdk/openai, ai. Remove @vercel/analytics (replaced by own analytics). Add @nest/llm, @nest/intern-auth, @nest/drizzle-xdb, @nest/ent, drizzle-orm, drizzle-kit, web-vitals, tinykeys.next.config.mjs — keep server-actions body limit, images.unoptimized: true, native-binding serverExternalPackages.app/layout.tsx — drop <Analytics /> entirely. Mount <WebVitalsTracker /> and <ShortcutProvider /> instead.vercel.json — keep; nest.json + proxy.ts coexist..eslintrc — add import/no-restricted-paths blocking direct vendor imports outside lib/*/impl/.lib/webhooks-ops.ts and app/api/cron/webhook-deliveries/route.ts stay shared. Both targets call them. Bearer check (CRON_SECRET) is already there — Vercel Cron and Chronos both pass the same header.
Rather than treating analytics as a sixth public/meta adapter, we own the analytics layer end-to-end. One shared lib/analytics/ module that writes to the local DB on both targets via the existing lib/db adapter. This collapses what would have been an adapter into editor-core territory: no parity drift, one schema, identical behavior on Vercel and Nest.
getViewer(). On public, anon-IDs via cookie. Same code path either way.app/api/track/route.ts and the page_views table exist. We generalize them.None are blockers for an editor where the interesting questions are "did anyone use Docsmith?" / "which docs got viewed?" / "what's the p95 chat latency?"
One new table on both targets — identical shape in Postgres and MySQL:
analytics_events {
id uuid / bigint pk
user_id uuid / fbid nullable // null = anon viewer on public
session_id text
event_name text // 'docsmith_message_sent', 'pdf_imported', etc.
properties jsonb / json
path text
referrer text nullable
user_agent text
occurred_at timestamptz
}
Index on (event_name, occurred_at) and (user_id, occurred_at).
import { track } from '@/lib/analytics'
track('docsmith_message_sent', { documentId, model, hasAttachments: true })
track('pdf_imported', { pageCount, viaVision: true })
track('document_published', { documentId, visibility })
Single import, no public/meta split needed at the call site. Implementation calls lib/db, which routes to Supabase or XDB.
lib/analytics/track.ts — client beacon + batching (~50 lines, visibilitychange flush)lib/analytics/server.ts — server track() for actions/routeslib/analytics/web-vitals.tsx — wraps Google's web-vitals; CLS/LCP/INP/TTFB beaconlib/db/types.ts + lib/db/impl/{public,meta}/analytics.ts — INSERT contract + trivial implsanalytics_events on both targetsapp/admin/stats/page.tsx — basic public dashboard (admin-gated)analytics_events in XDBapp/api/track/route.ts — generalized to accept {events: [{name, props}]}track() call sites at high-value momentsIf Meta volume outgrows the XDB-write path, tee from lib/db/impl/meta/analytics.ts into Scribe (→ Scuba). Call sites don't change.
A new editor feature that ships to both targets unchanged — exercising the adapter architecture as designed. Central registry + a useShortcut(id, handler, opts?) React hook. A single <ShortcutProvider> at the app root owns the registry and the underlying tinykeys listener (~400 bytes, no deps). Tooltips throughout the UI read from the registry so labels are platform-aware (⌘K on Mac, Ctrl+K elsewhere) without each component knowing the binding.
| Combo | Action | Scope |
|---|---|---|
| ⌘K | Open command palette (uses existing cmdk dep) | global |
| ⌘/ | Toggle Docsmith chat | document |
| ⌘↵ | Submit chat / form | modal |
| ⌘S | Capture version snapshot | document |
| ⌘⇧H | Open version history | document |
| ⌘⇧S | Open share / publish dialog | document |
| ⌘. | Comment on current selection | document |
| ⌘F | Find in document | document |
| ⌘⇧F | Find & replace | document |
| / | Slash menu (insert block at cursor) | region |
| Esc | Close active dialog / menu | modal |
| ? | Show shortcut cheatsheet | global |
lib/shortcuts/registry.ts — register(id, combo, scope, label) + lookup; single in-memory maplib/shortcuts/use-shortcut.ts — React hook (registers on mount, unregisters on unmount)lib/shortcuts/format.ts — platform-aware label formatter (⌘K vs Ctrl+K)components/shortcut-provider.tsx — top-level provider, mounts tinykeys, tracks current scopecomponents/shortcut-cheatsheet.tsx — modal listing all shortcuts grouped by scope (bound to ?)components/command-palette.tsx — Cmd+K palette using existing cmdk depapp/layout.tsx — mount <ShortcutProvider> near rootformatLabel(id) from registry for tooltipstrack('shortcut_used', { id }) in useShortcut's wrapper — feeds the analytics modulelib/{db,auth,llm,storage,realtime}/ dispatchers, move existing Supabase/Liveblocks/OpenAI code under impl/public/, swap all call sites. Add ESLint rule. Prerequisite to everything else.nest init under fbsource/nest/apps/html-docs/. Landing page + read-only doc page rendering on *.nest.x2p.facebook.net with OIDC. META impls return mocks.nest xdb migrate, file-by-file adapter port. Manifold for storage. App becomes fully read/write on Meta build.lib/analytics/, drop @vercel/analytics, generalize app/api/track/route.ts, add analytics_events migration on both targets, instrument ~20 high-value sites, ship /admin/stats on public + Unidash on meta. Lands as soon as lib/db exists on both.@nest/llm impl + Chronos cron. Smoke-test Docsmith, vision, comment AI, beautify. PUBLIC still calls OpenAI unchanged.lib/shortcuts/, wire <ShortcutProvider>, ship v1 set + cheatsheet + command palette, wire to analytics.ws server in Nest container; swap lib/realtime/impl/meta/ stub for real impl. Flip NEXT_PUBLIC_COLLAB_ENABLED=true. PUBLIC still uses Liveblocks unchanged.lib/shortcuts/ only. New tracked events: one track() call. New infra-touching features: update both impl/public/ and impl/meta/, test both.grep -r "@supabase\|@liveblocks\|@ai-sdk/openai" app components empty outside lib/*/impl/.nest dev; internalmeta.com proxy serves home page; authed /documents populates useInternAuth().user.unixname.nest xdb migrate + UI create-doc; verify row in nest xdb shell; recordEvent() produces document_events rows.scontent.xx.fbcdn.net and renders.actorId in @nest/llm logs.llama4-maverick produces editable HTML.curl --cert ... -H "Authorization: Bearer $CRON_SECRET" .../api/cron/webhook-deliveries returns 200; Chronos UI shows minute cadence.analytics_events rows with correct user_id / event_name / properties. PUBLIC /admin/stats shows counts; META Unidash returns rows.web_vital rows for LCP / CLS / INP show up.shortcut_used event lands in analytics_events.DEPLOY_TARGET=public next build ships no @nest/*. nest build ships no @supabase/* or @liveblocks/*.lib/html-parser.ts, components/shadow-dom-viewer.tsx, lib/interactive-patterns.tsvision-convert.ts goes through the adapterlib/webhooks-ops.ts/documents, /documents/[id], /d/[id], all v1 API routeslib/analytics/ — one implementation, writes through lib/db; same event schema and call sites everywherelib/shortcuts/ — pure editor-core; new shortcuts get registered once and ship to both targets