BLAST Frontend Enablement and Migration: Duetto to Vercel
BLAST Frontend Enablement and Migration: Duetto to Vercel
Author: Antonio Cortés Date: 2026-02-25 Status: DRAFT Audience: Engineering Leadership, Frontend Team, Platform Team Scope: End-to-end analysis for adopting the BLAST architecture at Duetto
Related Proposals: - Polaris Architecture — unified architecture direction that BLAST implements for the frontend layer
Table of Contents
Main Body
- Objective: Adopting the BLAST Architecture
- Where We Are Today
- The Gaps Between Current State and BLAST
- Bridging the Gaps
- Key Decisions
- Implementation Roadmap
- Cost & Risk
- Open Questions & Next Steps
Appendices
- Appendix A: Auth-Bridge Module Design
- Appendix B: Clerk Integration Details
- Appendix C: CDN Comparison
- Appendix D: MFE Framework Evaluation
- Appendix E: Required Changes (Detailed)
- Appendix F: Reference Documents & Key Files
- Appendix G: Glossary of Terms
1. Objective: Adopting the BLAST Architecture
Duetto's frontend platform carries years of accumulated technical debt that now actively limits the organization's ability to ship. The duetto-frontend repository is a React 17 monolithic single-page application that bundles all 11 products — Gamechanger, Scoreboard, Blockbuster, CommandCenter, OpenSpace, Advance, Enterprise, TourOperator, Onboarding, Unicorn, and Settings — into a single Webpack build. It is served by Express.js 4.21 running on Node 16.14.2, deployed as Docker containers on AWS EKS. React 17, Node 16, and Material-UI v4 are all end-of-life. There is no CDN in front of the application; browser requests hit Express pods directly via an internal ALB. Code splitting exists only at the vendor level — there is no route-based or product-based splitting, so users download code for all 11 products regardless of which one they use. Authentication is not handled by the frontend at all: it is fully delegated to the Java monolith via session cookies and CSRF tokens, meaning the frontend cannot be deployed or operated independently of the backend. The monolith itself (duetto) runs a custom session-based auth system with SHA-512 hashed tokens stored in MongoDB, SSO through Auth0 and SAML, and a 4-layer RBAC system deeply embedded across its 19 Gradle modules. There is no clean API boundary for identity. Meanwhile, legacy Backbone.js pages are still served directly from the Java monolith using hash-based routing. In short: the frontend platform, its runtime, its auth layer, its delivery infrastructure, and its deployment model are all either end-of-life or architecturally unsustainable. This prevents modern development practices — independent deployments, edge-first delivery, modern auth — and slows velocity across every product team.
BLAST is Duetto's answer. "Build Like A Startup," introduced by Robert Matsuoka (2026-02-10, DRAFT), defines a three-layer architecture that cleanly separates frontend delivery from backend services through a secure connection layer:
┌─────────────────────────────────────────────┐
│ Vercel Edge Network │
│ Next.js Apps (1-N) │ Edge Functions │ Neon │
├─────────────────────────────────────────────┤
│ Secure Connection Layer │
│ Vercel Private Networking │ VPC Peering │
├─────────────────────────────────────────────┤
│ AWS VPC (Duetto Core) │
│ API Gateway (Kong/AWS) │ Domain Services │
└─────────────────────────────────────────────┘
The strategy is not a big-bang rewrite. New frontends will be built on Vercel — TourOperator and Onboarding first — as independent Vite SPAs authenticated through Clerk. CloudFront becomes the unified entry point for all traffic, routing requests to Vercel for new BLAST apps (/mfe/*), to EKS for the legacy React SPA (/ui/*), and to the Java monolith for Backbone.js pages (/*). At the backend boundary, a lightweight auth-bridge Gradle module validates Clerk JWTs alongside legacy session cookies, enabling dual-auth coexistence for the duration of the migration. This follows the Strangler Fig pattern: route by route, product by product, the legacy surface shrinks until it can be removed entirely. The migration is expected to take 1-2 years, during which both systems run in production simultaneously.
Success means: new frontends are live on Vercel behind CloudFront, Clerk handles authentication for BLAST apps while legacy sessions continue for the existing SPA, dual auth coexists cleanly at the monolith boundary through the auth-bridge module, and each migrated product can be delivered and operated independently without coordinating releases with the monolith.
The full target architecture — spanning Vercel, the existing duetto-frontend on EKS, legacy Backbone.js pages, the duetto backend with the auth-bridge module, domain microservices, and external auth providers — is shown below:
┌──────────────┐
│ Browser │
└──────┬───────┘
│
┌──────▼───────┐
│ Route53 │
│ app.duetto │
│ research.com │
└──────┬───────┘
│
┌────────────▼────────────┐
│ CloudFront CDN │
│ (Unified Entry Point) │
│ │
│ Path-based routing: │
│ /mfe/* → Vercel │
│ /ui/* → EKS Frontend │
│ /api/* → EKS Backend │
│ /* → EKS Legacy │
└──┬──────────┬──────────┬─┘
│ │ │
┌──────────────────▼──┐ │ ┌────▼──────────────────┐
│ │ │ │ │
│ VERCEL PLATFORM │ │ │ AWS VPC │
│ │ │ │ │
│ ┌─────────────────┐ │ │ │ ┌───────────────────┐ │
│ │ Vercel Edge │ │ │ │ │ EKS Cluster │ │
│ │ Network │ │ │ │ │ │ │
│ │ │ │ │ │ │ ┌───────────────┐ │ │
│ │ Edge Middleware│ │ │ │ │ │duetto-frontend│ │ │
│ │ (Clerk auth │ │ │ │ │ │ Express.js │ │ │
│ │ validation) │ │ │ │ │ │ React 17 SPA │ │ │
│ └────────┬───────┘ │ │ │ │ │ (/ui/*) │ │ │
│ │ │ │ │ │ └───────────────┘ │ │
│ ┌────────▼───────┐ │ │ │ │ │ │
│ │ BLAST Apps │ │ │ │ │ ┌───────────────┐ │ │
│ │ │ │ │ │ │ │duetto (Java) │ │ │
│ │ ┌────────────┐ │ │ │ │ │ │ Jetty 12 │ │ │
│ │ │TourOperator│ │ │ │ │ │ │ Spring 6 │ │ │
│ │ │ Vite SPA │ │ │ │ │ │ │ │ │ │
│ │ └────────────┘ │ │ │ │ │ │ ┌───────────┐ │ │ │
│ │ ┌────────────┐ │ │ │ │ │ │ │auth-bridge│ │ │ │
│ │ │ Onboarding │ │ │ │ │ │ │ │module │ │ │ │
│ │ │ Vite SPA │ │ │ │ │ │ │ │ │ │ │ │
│ │ └────────────┘ │ │ │ │ │ │ │DualAuth │ │ │ │
│ │ ┌────────────┐ │ │ │ │ │ │ │Filter │ │ │ │
│ │ │Intelligence│ │ │ │ │ │ │ │ │ │ │ │
│ │ │ Next.js │ │ │ │ │ │ │ │ClerkJwt │ │ │ │
│ │ └────────────┘ │ │ │ │ │ │ │Validator │ │ │ │
│ │ ┌────────────┐ │ │ │ │ │ │ │ │ │ │ │
│ │ │ Market │ │ │ │ │ │ │ │Token │ │ │ │
│ │ │ Next.js │ │ │ │ │ │ │ │Exchanger │ │ │ │
│ │ └────────────┘ │ │ │ │ │ │ └───────────┘ │ │ │
│ └────────────────┘ │ │ │ │ │ │ │ │
│ │ │ │ │ │ ┌───────────┐ │ │ │
│ ┌────────────────┐ │ │ │ │ │ │Backbone.js│ │ │ │
│ │ Shared Pkgs │ │ │ │ │ │ │ Legacy │ │ │ │
│ │ (monorepo) │ │ │ │ │ │ │ Pages │ │ │ │
│ │ ┌──────┐ │ │ │ │ │ │ │ (hash │ │ │ │
│ │ │ auth │ Clerk │ │ │ │ │ │ │ routes) │ │ │ │
│ │ │ ui │ React │ │ │ │ │ │ └───────────┘ │ │ │
│ │ │ api │ SDK │ │ │ │ │ │ │ │ │
│ │ └──────┘ │ │ │ │ │ │ ┌───────────┐ │ │ │
│ └────────────────┘ │ │ │ │ │ │GraphQL │ │ │ │
│ │ │ │ │ │ │API + REST │ │ │ │
│ ┌────────────────┐ │ │ │ │ │ │(/api/*) │ │ │ │
│ │ Vercel KV │ │ │ │ │ │ └───────────┘ │ │ │
│ │ (JWT cache │ │ │ │ │ └───────────────┘ │ │
│ │ 12 min TTL) │ │ │ │ │ │ │
│ └────────────────┘ │ │ │ │ ┌───────────────┐ │ │
│ │ │ │ │ │Domain Services│ │ │
│ ┌────────────────┐ │ │ │ │ │(Microservices)│ │ │
│ │ Neon Postgres │ │ │ │ │ │ │ │ │
│ │ (dev/preview │ │ │ │ │ │ onboarding- │ │ │
│ │ branches) │ │ │ │ │ │ domain │ │ │
│ └────────────────┘ │ │ │ │ │ intelligence-│ │ │
│ │ │ │ │ │ domain │ │ │
└────────┬───────────┘ │ │ │ │ market-domain│ │ │
│ │ │ │ │ customer- │ │ │
│ VPC Peering │ │ │ │ domain │ │ │
│ (Vercel Secure │ │ │ │ pricing- │ │ │
│ Compute) │ │ │ │ domain │ │ │
└───────────────────┘ │ │ │ group-domain │ │ │
│ │ └───────────────┘ │ │
│ └───────────────────┘ │
│ │
│ ┌───────────────────┐ │
│ │ Data Stores │ │
│ │ │ │
│ │ ┌──────┐┌──────┐ │ │
│ │ │Mongo ││Redis │ │ │
│ │ │ DB ││ │ │ │
│ │ └──────┘└──────┘ │ │
│ │ ┌──────┐┌──────┐ │ │
│ │ │RDS ││AWS │ │ │
│ │ │Postgr││KMS │ │ │
│ │ │es ││(JWT) │ │ │
│ │ └──────┘└──────┘ │ │
│ └───────────────────┘ │
│ │
└───────────────────────┘
┌──────────────────────┐
│ External Services │
│ │
│ ┌───────┐ ┌────────┐ │
│ │ Clerk │ │ Auth0 │ │
│ │ Cloud │ │(legacy)│ │
│ └───────┘ └────────┘ │
└──────────────────────┘
Key Architecture Flows:
| # | Flow | Path |
|---|---|---|
| 1 | New BLAST MFE request | Browser → CloudFront (/mfe/*) → Vercel Edge (Clerk auth) → Vite/Next.js app → VPC peering → duetto backend (/api/*) |
| 2 | Legacy React 17 request | Browser → CloudFront (/ui/*) → EKS → duetto-frontend (Express) → proxy → duetto backend |
| 3 | Legacy Backbone.js page | Browser → CloudFront (/* default) → EKS → duetto (Java) → Backbone.js HTML |
| 4 | Clerk token exchange | Vercel Edge middleware → VPC peering → duetto /api/auth/exchange → auth-bridge ClerkTokenExchanger → KMS-signed Duetto JWT → cached in Vercel KV |
| 5 | Dual auth at monolith | Request with Clerk JWT → DualAuthFilter → ClerkJwtValidator → SecurityContext; OR request with session cookie → DuettoSessionAuthFilter → SecurityContext |
| 6 | API call from MFE | Vite/Next.js app → VPC peering → duetto GraphQL/REST (/api/*) → domain services (onboarding, intelligence, etc.) |
The research behind this document evaluates every layer of this architecture. The key findings are:
| Area | Current State | Recommended Target | Confidence |
|---|---|---|---|
| Frontend Platform | EKS (Express.js containers) | Vercel Edge Network | High |
| Frontend Framework | React 17 + Webpack 5 | Vite SPAs (TourOperator, Onboarding) + Next.js (dashboards) on Vercel | High |
| Authentication | Custom session cookies + MongoDB | Clerk (phased migration, 1-2 yr dual auth) | High |
| IAM Modularization | Auth embedded across monolith modules | Light auth-bridge Gradle module (not full hexagonal) |
High |
| CDN | None (served directly from EKS) | CloudFront as unified entry + Vercel Edge | High |
| Frontend Architecture | Monolithic SPA (11 products) | Mixed MFEs: Vite SPAs + Next.js per product needs | High |
| Legacy Migration | Backbone.js in Java monolith | Strangler Fig via Multi-Zones | High |
| API Gateway | Direct ALB | Phase 1: ALB + middleware, Phase 2: Kong | Medium |
2. Where We Are Today
2.1 The Frontend: duetto-frontend
| Attribute | Value |
|---|---|
| Framework | React 17.0.2 (EOL) |
| Build | Webpack 5.93 |
| Server | Express.js 4.21 on Node 16.14.2 (EOL) |
| Deployment | Docker containers on AWS EKS |
| CDN | None — served directly from Express pods |
| Products | 11 bundled (Gamechanger, Scoreboard, Blockbuster, CommandCenter, OpenSpace, Advance, Enterprise, TourOperator, Onboarding, Unicorn, Settings) |
| Auth | Delegated to Java monolith (session cookies + CSRF) |
| Code Splitting | Vendor-level only (no route-based) |
Key Technical Debt: - React 17, Node 16, Material-UI v4 — all end-of-life - No CDN for static assets - No code splitting by route/product - Tight coupling to Java monolith for auth - All 11 products in a single webpack bundle (97 direct dependencies)
2.2 The Backend: duetto Monolith
| Attribute | Value |
|---|---|
| Language | Java 17 |
| Framework | Spring 6.2.10 + Spring Security 6.5.3 |
| Database | MongoDB (custom ORM via MongoTranslator) |
| Auth | Custom session-based (SHA-512 hashed tokens in MongoDB) |
| JWT | AWS KMS-based signing (RS256) |
| SSO | Auth0, SAML, OAuth2/OIDC |
| Modules | 19 Gradle subprojects |
Authentication Flow:
HTTP Request → DuettoSessionAuthenticationFilter
→ Extract session ID (cookie: sid / header: X-Duetto-ApiKey / query: ?apiKey=)
→ SHA-512 hash → Load UserSession from MongoDB
→ Validate (expiration, active user, session type)
→ Create UsernamePasswordAuthenticationToken (ROLE_DUETTO)
→ Set SecurityContextHolder
RBAC (4-layer system):
1. CompanyPermission — tenant-level feature flags (controlled by admin, gated by DuettoEdition). Planned migration to a dedicated feature-flag product, but timeline is uncertain — auth-bridge and Clerk must support these as-is.
2. UserPermission — user access rights (COMPANY or HOTEL scoped). Hotel permissions also planned for feature-flag migration, but the current model must be fully supported during the transition.
3. PermissionSet — shareable permission bundles scoped to specific Hotels
4. GraphQL @auth directive — field-level authorization per schema field
2.3 Ecosystem & Constraints
| Category | Count | Key Repos |
|---|---|---|
| Domain Microservices | ~17 | onboarding-domain (formerly hotel-domain), customer-domain, pricing-domain, intelligence-domain, group-domain, market-domain |
| Shared Libraries | ~20 | common-boot-auth (JWT+KMS), duetto-shared-javascript, duetto-components |
| Infrastructure | ~18 | ops-tf (Terraform monorepo), terraform_modules (56+ modules), duetto-gitops (FluxCD) |
| AI/LLM Platform | ~7 | duetto-mcp (82 MCP tools), duetto-code-intelligence (RAG search) |
| ML/Data Science | ~16 | ml_pricing_engine, duetto-training, duetto-inference |
Infrastructure is 100% Terraform — no CDK or CloudFormation. All infra changes must follow Terraform patterns via ops-tf and terraform_modules.
The duetto-frontend migration will take 1-2 years, meaning legacy and new systems must coexist.
3. The Gaps Between Current State and BLAST
Adopting BLAST requires closing gaps in four areas: routing and CDN, authentication, frontend platform, and legacy migration. The table below maps each BLAST requirement to the current state and identifies the work needed.
| BLAST Requires | Current State | Gap |
|---|---|---|
| Next.js / Vite on Vercel | React 17 + Express on EKS | New MFEs (TourOperator, Onboarding first) |
| Clerk auth | Custom MongoDB sessions | Auth migration required |
| Neon Postgres (FE data) | N/A (no FE-specific DB) | New infrastructure |
| VPC connection (Vercel↔AWS) | No Vercel infrastructure | Vercel Secure Compute + VPC peering (confirmed) |
| BFF or direct API calls | Express proxy to monolith | New API layer (BFF for Next.js; direct for Vite SPAs) |
| Edge middleware for auth | CSRF token in localStorage | New auth flow |
| Domain service API layer | GraphQL monolith | REST-forward: CRUD MFEs call domain service REST directly; dashboard MFEs use BFF (Next.js API routes) that calls monolith GraphQL during transition, shifting to domain service REST as services mature. See Section 4.5. |
| CloudFront unified entry | Direct ALB routing | New CDN layer |
What Already Exists That BLAST Can Leverage:
- 17 domain microservices already extracted — onboarding-domain (formerly hotel-domain), intelligence-domain, market-domain, group-domain are the services BLAST targets first
- common-boot-auth provides JWT + KMS signing — foundation for token exchange
- Tenant Management module (
domain-modules/pattern) —auth-bridgemodule follows this established pattern - admin-panel (React 18 + Vite) — existing precedent for Vite at Duetto
- ops-tf + terraform_modules — infrastructure patterns ready for CloudFront/VPC modules
- duetto-gitops (FluxCD) — GitOps patterns can extend to Vercel deployments
- Planned feature-flag product migration for Hotel and Company permissions — will eventually simplify Clerk RBAC mapping, but the auth-bridge must handle the current permission model until that migration completes
4. Bridging the Gaps
This section describes how to close each gap — what to build, what to adopt, and why.
4.1 Routing & CDN: CloudFront as Unified Entry Point
The problem. Duetto has no CDN today. All assets are served directly from Express.js pods on EKS. BLAST requires a unified entry point that routes to both legacy (EKS) and new (Vercel) origins under a single domain.
The solution. CloudFront as the unified entry point with Vercel as one of its origins:
Route53 (app.duettoresearch.com)
→ CloudFront CDN (unified entry)
→ /ui/* → Duetto EKS (existing frontend)
→ /mfe/* → Vercel Edge (new BLAST frontends)
→ /api/* → Duetto EKS (Java backend)
→ Default → Duetto EKS (legacy routes)
Why this is the right approach:
- Single domain (
app.duettoresearch.com) — no CORS issues, shared cookies - Path-based routing allows incremental migration
- CloudFront handles global CDN + caching for existing assets
- Vercel Edge handles Next.js-specific optimizations (ISR, streaming, edge middleware)
- Blue/green deployments via CloudFront weighted routing (90/10 canary)
The Terraform for CloudFront is already designed in the ops-tf patterns (ACM certificate, Route53 alias, cache behaviors per path, origin groups for failover).
See Appendix C for a detailed Vercel CDN vs CloudFront feature comparison.
4.2 Authentication: Clerk + Auth-Bridge Dual-Auth Module
The problem. Auth is deeply embedded in the monolith — custom session cookies (sid), SHA-512 hashing, MongoDB storage (userSession collection), and CSRF tokens baked into every request. BLAST requires Clerk JWTs. Both auth mechanisms must coexist in production for the entire 1-2 year migration period.
The solution. A new auth-bridge light module under domain-modules/ in the monolith. It follows the Tenant Management module's structural pattern (Gradle module boundaries, -api/-impl split, Spring Boot auto-config) but skips hexagonal ceremony (no JMolecules, ArchUnit, domain events) because the code is transitional — deleted when the frontend migration completes.
The right investment level sits between a throwaway thin bridge and the full hexagonal treatment:
| Approach | Effort | Comfortable Lifespan |
|---|---|---|
Thin bridge (package in frontend, no module) |
~1.5 weeks | Painful after 6 months |
| Light module (Gradle module, interfaces, auto-config) | ~2.5 weeks | Comfortable for 1-2 years ✅ |
| Full hexagonal (Tenant Management module style with JMolecules, ArchUnit, domain events) | ~4-5 weeks | Overkill — deleted in 1-2 years |
How dual auth works. During the 1-2 year transition, every request to the monolith hits the DualAuthFilter, which detects the auth method and routes accordingly:
Request arrives at SecurityConfiguration filter chain
│
├─ Has Authorization: Bearer <clerk-jwt> header?
│ └─ YES → DualAuthFilter → ClerkJwtValidator → TokenExchanger → DuettoJWT → SecurityContext
│
├─ Has sid cookie OR X-Duetto-ApiKey header?
│ └─ YES → DuettoSessionAuthenticationFilter (existing, unchanged) → SecurityContext
│
└─ Neither?
└─ 401 Unauthorized
Both paths end with a valid SecurityContext containing a UsernamePasswordAuthenticationToken with ROLE_DUETTO. Everything downstream (GraphQL @auth, REST controllers, permission checks) works identically regardless of auth method.
Clerk migration: two phases.
- Phase 1 — Pass-through: Users log in with their existing Duetto credentials via the Clerk UI. Clerk validates the credentials through the monolith's existing auth endpoint and auto-provisions the user in Clerk's directory. No user-facing change; the switch is invisible.
- Phase 2 — Clerk-native: Users log in directly with Clerk (social login, passwordless, or migrated credentials). The Clerk JWT is exchanged for a Duetto JWT via the auth-bridge's
/api/auth/exchangeendpoint, with the result cached for 12 minutes in Vercel KV to avoid per-request round-trips.
The permissions challenge. Duetto has a 4-layer RBAC system: CompanyPermission (company-level flags), UserPermission (50+ granular permissions per user-hotel pair), PermissionSet (named bundles of permissions assigned to roles), and GraphQL @auth directives that enforce access at the resolver level. Hotel-level and Company-level permissions are planned to migrate to a feature-flag product, but the timeline is uncertain. The auth-bridge must expose the full current permission model from day one — the BLAST frontends cannot wait for a permission system redesign.
Existing auth code stays untouched. The existing DuettoSessionAuthenticationFilter, SessionTokenManager, and MongoDB sessions are not refactored. The auth-bridge contains only NEW code — dual-auth routing, Clerk integration, user/org mapping, and token exchange.
See Appendix A for the full module structure, code interfaces, and DO/SKIP breakdown. See Appendix B for Clerk entity mapping, RBAC translation, pricing, and pros/cons.
4.3 Frontend Platform: Vite SPAs + Next.js MFEs on Vercel
The problem. The current duetto-frontend bundles 11 products into a single React 17 SPA with one webpack build. Adding or updating a single product requires rebuilding and redeploying everything.
The solution. Break the monolith into independently deployable MFEs on Vercel. Use Vite for interactive back-office SPAs (TourOperator and Onboarding — the first candidates), and Next.js for data-heavy dashboards that benefit from SSR/ISR (Intelligence, Market). Each MFE is a separate Vercel project with independent CI/CD.
Per-product framework recommendations:
| Product | Recommended Framework | Rationale |
|---|---|---|
| TourOperator | Vite SPA | Interactive back-office tool, no SEO needs, closest to current React SPA architecture |
| Onboarding | Vite SPA | Wizard-based workflow, client-side heavy, benefits from Vite's simplicity |
| Intelligence dashboard | Next.js | Benefits from SSR for initial data load, ISR for caching, BFF for API aggregation |
| Market performance | Next.js | Data-heavy dashboards benefit from server-side rendering and ISR caching |
| Future public-facing pages | Next.js | SEO, performance, edge rendering |
Proposed Zone/Route Structure:
Zone 1 (Legacy): /ui/* → Existing React 17 on EKS
Zone 2 (Vite MFE): /mfe/tour-operator → Vite SPA on Vercel (first candidate)
Zone 3 (Vite MFE): /mfe/onboarding → Vite SPA on Vercel (first candidate)
Zone 4 (Next.js): /mfe/intelligence → Next.js on Vercel
Zone 5 (Next.js): /mfe/market → Next.js on Vercel
Host: CloudFront → Routes to appropriate zone/origin
Why a monorepo (not polyrepo)? Each MFE deploys independently to its own Vercel project — the monorepo is a source code organization choice, not a deployment coupling choice. The benefits over separate repositories are:
- Shared code without publishing. The
packages/folder (design system, Clerk auth utilities, API client, types) is consumed by all MFEs at build time. In a polyrepo you'd need to publish these as npm packages, manage versioning, and deal with diamond dependency issues. In a monorepo it's just an import. - Atomic cross-cutting changes. When the Clerk auth wrapper changes or the API client adds a new endpoint, you update it once and every app gets it in the same PR — no "update the shared lib, publish, then update 5 repos" dance.
- Turborepo + Vercel integration. Turborepo (recommended in Section 5) is built by Vercel. It provides smart caching: if only
apps/tour-operator/changed, only that app rebuilds and redeploys. Eachapps/*folder maps to a separate Vercel project with independent CI/CD, so you get monorepo convenience with polyrepo deployment independence. - Consistent tooling. ESLint, TypeScript, Prettier, and test configuration are defined once at the root and inherited by all apps — no config drift across repos.
- Operational overhead. With 145 repositories already in the Duetto org, adding 5-10 more MFE repos increases maintenance burden. A single
blast-frontendrepo is easier to manage, onboard to, and govern.
Monorepo Structure:
blast-frontend/
apps/
tour-operator/ # Vite + React SPA (first candidate)
onboarding/ # Vite + React SPA (first candidate)
intelligence/ # Next.js (SSR/ISR)
market/ # Next.js (SSR/ISR)
packages/
ui/ # Shared design system
auth/ # Shared Clerk auth utilities (React SDK)
api-client/ # Shared API client (fetch wrappers, React Query hooks)
config/ # Shared configuration
duetto-types/ # Typed clients from domain service OpenAPI specs and BFF contracts
See Appendix D for the full framework comparison (Module Federation, Next.js Multi-Zones, Vite SPAs), shared state strategy, and team autonomy benefits.
4.4 Legacy Backbone.js: Strangler Fig Migration
The problem. Legacy Backbone.js pages live in the Java monolith (not in duetto-frontend), served via hash-based routes (e.g., /scoreboard/${id}#customreports). The duetto-frontend Express server proxies all non-/ui/ routes to the monolith, which serves these Backbone.js pages. These pages cannot be migrated in a single effort — the monolith coupling is too deep.
The solution. Use the Strangler Fig pattern via CloudFront path-based routing. New pages are built on Vercel from the start; legacy pages are migrated route-by-route as priorities allow.
Strangler Fig progression:
Current: app.duettoresearch.com/some-page
→ ALB → EKS → Java monolith → Backbone.js page
Phase 1: app.duettoresearch.com/some-page
→ CloudFront → ALB → EKS → Java monolith (unchanged)
app.duettoresearch.com/mfe/new-page
→ CloudFront → Vercel → Next.js (new)
Phase 2: app.duettoresearch.com/mfe/migrated-page
→ CloudFront → Vercel → Next.js (migrated from Backbone)
app.duettoresearch.com/old-page
→ CloudFront → ALB → EKS → Java monolith (shrinking)
Phase 3: app.duettoresearch.com/*
→ Vercel → Next.js (all routes migrated)
→ Vercel rewrites /api/* → AWS backend (still on EKS)
Backbone.js migration priority:
Note: This section covers only the Backbone.js pages served from the Java monolith — not the React products in
duetto-frontend, which are covered in Section 7 and the phased roadmap. A complete inventory of Backbone.js hash routes in the monolith is an open question (see Section 14).
| Backbone.js Route | Complexity | Business Value | Priority | Notes |
|---|---|---|---|---|
Scoreboard hash routes (e.g., /scoreboard/${id}#customreports) |
High | High | Phase 2 | Many custom reports, deep monolith coupling |
| Legacy admin pages | Medium | Medium | Phase 3 | Administrative pages served directly by the monolith |
| OpenSpace iframe pages | High | Medium | Phase 3 | Embedded legacy UI, complex integration |
| Remaining hash routes | Very High | Low | Last | Deep monolith coupling; requires full inventory before prioritization |
During migration, auth works across both old and new pages via the same-domain cookie sharing and dual auth described in Section 4.2.
4.5 API Strategy: Pragmatic Hybrid (REST-Forward, GraphQL Scoped)
The problem. The monolith exposes a single GraphQL endpoint with schema definitions, @auth directives, and resolver logic all co-located in the same codebase. This works today because one frontend talks to one backend. BLAST breaks that assumption — multiple independently deployed MFEs need independent API contracts they can evolve without coordinating through a shared schema.
GraphQL without federation is an inherently centralizing technology. Every consumer shares one schema, one endpoint, one deploy pipeline. As Duetto decomposes into product-aligned teams with independent MFEs and domain services, the API layer becomes the last bottleneck — even after the frontend and backend are decoupled, every team still queues behind the same schema file in the monolith.
Federation would solve the ownership problem but requires 3-6 months of platform investment (router, subgraphs, composition pipeline, schema registry) before any product team benefits. Investing in federation infrastructure competes directly with product delivery.
The solution. New services start with REST, the monolith GraphQL stays for consumers that still need it, and GraphQL shrinks naturally as domain services replace monolith resolvers. This aligns with the Strangler Fig philosophy already adopted in Polaris — don't rewrite, redirect.
Alignment with Polaris Architecture
This API strategy is a direct application of Polaris principles to the API layer:
| Polaris Principle | API Strategy Application |
|---|---|
| Strangler Fig over Big Bang | GraphQL is not rewritten or removed — it shrinks as consumers migrate to REST domain services, resolver by resolver |
| Domain Service Extraction | Each extracted domain service owns its REST API (OpenAPI). No dependency on monolith schema. Clean extraction boundary. |
| Independent Deployability | CRUD MFEs call their domain service directly. No shared schema means no deploy ordering constraints between teams. |
| Incremental Migration | Dashboard MFEs start by calling monolith GraphQL through a BFF, then shift to domain service REST as services mature — no big cutover |
| Parallel Run / Shadow Read | When migrating a BFF call from monolith GraphQL to domain service REST, shadow-read comparison validates parity before switching |
Polaris defines the destination (autonomous domain services with clean boundaries). This API strategy defines how the API contracts evolve to get there without requiring all services to be extracted before BLAST MFEs can ship.
Why REST-Forward
-
Independent ownership. Each domain service owns its REST API (OpenAPI spec). No shared schema, no coordination, no monolith PRs to add a field. Ship when ready.
-
Decomposition-aligned. The Polaris strategy is extracting domain services from the monolith. REST services are self-contained — they own their endpoints, auth middleware, and documentation. Extraction is clean. GraphQL resolvers are entangled with the schema,
@authdirectives, and DataLoader — extraction requires re-architecture. -
HTTP caching. BLAST puts CloudFront in front of everything. REST
GETresponses are cacheable with standardCache-Controlheaders — zero custom infrastructure. GraphQLPOSTrequests are not cacheable without persisted queries and custom cache keys, which is significant additional complexity. -
Observability.
GET /api/hotels/123has distinct latency, error rate, and traffic metrics in any standard APM tool. GraphQL's single/graphqlendpoint requires custom resolver-level instrumentation to achieve the same visibility. -
Hiring and onboarding. Every backend developer knows REST. GraphQL requires specific expertise (schema design, DataLoader, persisted queries, depth limiting). A REST-forward strategy widens the talent pool and shortens onboarding time for new engineers.
-
CRUD is the majority case. ~60% of Duetto's products (TourOperator, Onboarding, Settings, Enterprise, Admin) are form-heavy back-office tools. For these, GraphQL adds ceremony without benefit.
POST /hotelsis simpler thanmutation CreateHotel(input: CreateHotelInput!) { createHotel(input: $input) { id name } }— less code, less tooling, same result.
Why Not Kill GraphQL Immediately
-
The legacy React 17 frontend depends on it. Rewriting its data layer is out of scope and would delay BLAST for no user-facing benefit.
-
Dashboard MFEs (Intelligence, Market) genuinely benefit from flexible queries. These products display complex nested data across multiple domains. During the transition period, their BFFs will call monolith GraphQL server-side — this is pragmatic, not permanent.
-
Migration cost. The existing GraphQL layer represents years of resolver logic, auth integration, and type definitions. Replacing it wholesale would be a rewrite — the exact anti-pattern Strangler Fig avoids.
GraphQL is not being rejected. It is being scoped to where it earns its complexity and prevented from spreading to new consumers.
The Backend for Frontend (BFF) Pattern
A BFF is a thin server-side layer that sits between a specific frontend application and the backend services it consumes. Unlike a general-purpose API gateway that serves all clients, each BFF is tailored to one frontend's exact data needs.
Without BFF: With BFF:
┌──────────┐ ┌──────────┐
│ Frontend │ │ Frontend │
└────┬─────┘ └────┬─────┘
│ 4 separate calls, │ 1 call, shaped
│ client assembles │ for this view
▼ ▼
┌──────────┐ ┌─────────┐
│Service A │ │ BFF │ ← owned by the
├──────────┤ └──┬──┬───┘ frontend team
│Service B │ │ │
├──────────┤ ▼ ▼
│Service C │ Backend services
├──────────┤ (REST, GraphQL,
│Service D │ whatever)
└──────────┘
Why BFFs matter for BLAST:
- The frontend team owns the BFF. They control the API contract their UI depends on. No cross-team coordination to change a response shape.
- The BFF absorbs backend complexity. If the backend is GraphQL today and REST tomorrow, only the BFF changes — the frontend never knows.
- Each BFF serves one product. Intelligence's BFF aggregates data from multiple domain services into exactly what the Intelligence dashboard needs. It doesn't serve TourOperator, Market, or any other product.
- No BFF needed for simple cases. When a CRUD MFE (Onboarding, TourOperator) has a 1:1 relationship with a single domain service, calling the REST API directly is simpler and correct. BFF is a complexity budget — spend it only where aggregation is required.
In the BLAST monorepo, BFFs are not separate services. For Next.js apps, the BFF is the built-in API routes (app/api/ directory) — same deployment, same Vercel project, no extra infrastructure. For the rare Vite SPA that needs aggregation, a lightweight Vercel Function can serve the same purpose.
API Pattern by Product Type
| Product Type | Examples | API Pattern | Rationale |
|---|---|---|---|
| CRUD MFEs | Onboarding, TourOperator, Settings | MFE → domain service REST API (direct) | No aggregation needed. Domain service owns its API. Simple typed client from OpenAPI spec. |
| Dashboard MFEs | Intelligence, Market | MFE → BFF → multiple backends | Aggregates data from multiple domain services. BFF shapes response for the view. Calls monolith GraphQL during transition, shifts to REST as domain services mature. |
| Legacy frontend | React 17 monolith UI | Monolith GraphQL (unchanged) | Don't touch. Let it shrink naturally as products migrate to BLAST MFEs. |
CRUD MFE Pattern (Direct REST)
┌──────────────┐ ┌────────────────────┐
│ Onboarding │ REST │ onboarding-domain │
│ Vite SPA │────────→│ REST API │
│ │ │ (OpenAPI spec) │
└──────────────┘ └────────────────────┘
- Domain service exposes REST endpoints with an OpenAPI spec
api-clientpackage generates typed hooks from the OpenAPI spec (e.g., viaopenapi-typescript+openapi-fetchor similar)- MFE calls the domain service directly through CloudFront/API gateway routing
- Auth: Clerk JWT → auth-bridge validates → domain service trusts the SecurityContext
- No BFF, no GraphQL, no shared schema
Dashboard MFE Pattern (BFF with Transitional GraphQL)
┌──────────────┐ ┌──────────────┐ ┌─────────────────┐
│ Intelligence │ REST │ Intelligence │ GQL │ Monolith │
│ Next.js │────────→│ BFF (Next.js│────────→│ GraphQL │
│ │ │ API routes) │ │ │
└──────────────┘ └──────┬───────┘ └─────────────────┘
│
│ REST (as services mature)
▼
┌─────────────────┐
│ intelligence- │
│ domain REST API │
└─────────────────┘
- BFF is built into Next.js (API routes) — no separate service to deploy
- BFF defines a typed REST contract for the frontend (OpenAPI)
- Backend calls start as monolith GraphQL, shift to domain service REST as services are extracted
- The MFE never sees GraphQL. The BFF absorbs the transition. When the monolith resolver is retired, only the BFF changes — zero frontend impact.
Domain Service API Standards (New Services)
All new and newly-extracted domain services follow:
| Standard | Choice | Rationale |
|---|---|---|
| Protocol | REST (JSON over HTTP) | Simplicity, caching, observability |
| Contract | OpenAPI 3.1 spec | Machine-readable, generates clients and docs |
| Versioning | URL path (/v1/hotels) |
Explicit, simple, no header negotiation |
| Auth | Clerk JWT validated via common-boot-auth |
Consistent with BLAST auth-bridge flow |
| Authorization | Middleware/interceptors (not schema-embedded) | Extractable, testable, no GraphQL coupling |
| Error format | RFC 7807 Problem Details | Standard, machine-parseable error responses |
| Pagination | Cursor-based (?cursor=X&limit=N) |
Stable for real-time data, no offset drift |
GraphQL Reduction Timeline
GraphQL is not removed — it shrinks as consumers migrate away.
| Phase | GraphQL State | What Changes |
|---|---|---|
| Phase 1 (BLAST Foundation) | Fully active | No changes. Legacy frontend uses it. New CRUD MFEs don't connect to it. |
| Phase 2 (First MFE migrations) | Active, no new consumers | Onboarding and TourOperator MFEs use domain service REST. Dashboard MFE BFFs may call monolith GraphQL server-side. |
| Phase 3 (Dashboard migrations) | Shrinking | As Intelligence/Market domain services expose REST APIs, BFFs shift away from monolith GraphQL. |
| Phase 4 (Legacy frontend sunset) | Minimal | Only legacy React 17 pages still use it. As pages migrate to BLAST, resolvers are decommissioned. |
| End state | Decommissioned | No consumers remain. Schema and resolvers removed from monolith. |
No phase requires a "big bang" GraphQL removal. Each resolver dies when its last consumer migrates.
Migration Mechanics for Existing GraphQL Consumers
When a dashboard MFE's BFF is currently calling monolith GraphQL and the corresponding domain service becomes available:
- Domain service publishes OpenAPI spec
- BFF adds REST client for the domain service
- BFF routes calls to domain service instead of monolith GQL
- Verify parity (shadow-read if needed)
- Remove GQL call from BFF
- If no other consumer uses that resolver → deprecate and remove from monolith
This is per-resolver, per-service — not a coordinated migration event.
What This Means for duetto-types/
The duetto-types/ package in the BLAST monorepo (currently planned for GraphQL codegen) becomes:
- For CRUD MFEs: Generated from domain service OpenAPI specs — typed REST clients
- For Dashboard MFEs: Generated from BFF OpenAPI specs — typed REST clients (BFF handles GQL internally)
- No package depends on monolith GraphQL introspection at build time
This eliminates the build-time coupling to the monolith entirely.
4.6 Permissions Strategy: Identity/Authorization Separation with Phased Migration
Scope note. This section addresses permissions only as they relate to BLAST frontend enablement — specifically, what the auth-bridge must handle for BLAST MFEs to ship, and how the permission model evolves alongside the BLAST migration phases. A full permissions migration (consolidating the 4-layer RBAC model, defining the target role hierarchy, migrating CompanyPermission/HotelPermission to the feature-flag product, and redesigning hotel-level authorization) is a separate initiative that will be tracked under the Onboarding domain. This proposal maps the dependencies between BLAST and that initiative, but does not define or own the permissions migration itself.
The problem. Duetto has a 4-layer permission model with ~250 total values spread across CompanyPermission (85 feature flags), HotelPermission (120+ per-hotel toggles), UserPermission (44 user access rights), and AdminPermission (11 internal staff permissions). The BLAST proposal must define how this model maps to Clerk without blocking Phase 1 delivery or creating a second permission system to maintain.
The design principle:
Clerk answers "who are you?" — the backend answers "what can you do?"
This separation holds for Phase 1 and evolves as the permission model simplifies. Clerk handles identity (authentication, SSO, MFA, session management). Authorization stays in the backend until the model is simple enough to migrate — which happens naturally as feature flags move to Datadog/Statsig and hotel-level permissions collapse into roles.
Current Permission Model (Actual Inventory)
| Layer | Count | What It Actually Is | Long-Term Destination |
|---|---|---|---|
CompanyPermission |
85 values (~30 devOnly) | Company-wide feature flags and product gates, controlled by DuettoEdition |
Datadog/Statsig — these are feature flags, not authorization |
HotelPermission |
120+ values | Per-hotel feature toggles, some gated by parent CompanyPermission | Datadog/Statsig — same as above |
UserPermission APP-level |
13 | "Can this user access this product?" (e.g., ONBOARDING_APP_ACCESS, GAMECHANGER_APP_ACCESS) |
Clerk roles — app access per user |
UserPermission COMPANY-level |
18 | Admin capabilities (e.g., MANAGE_USERS, DATA_EXPORT, IMPORT) |
Clerk roles — simplify to ~5-8 admin roles |
UserPermission HOTEL-level |
13 | Revenue management edit operations per hotel per user (e.g., MANAGE_PRICES, EDIT_AUTOPILOT) |
Clerk roles — collapse into ~4-5 hotel roles |
AdminPermission |
11 | Duetto internal staff permissions (LOGIN_AS, MANAGE_ALL_COMPANIES, etc.) |
Clerk "Duetto Internal" org + auth-bridge for impersonation |
PermissionSet |
5 built-in + custom | Named bundles of UserPermissions assigned to users per hotel | Clerk roles — PermissionSets are roles already |
Hotel-Level Permission Simplification
The 13 hotel-level UserPermissions are all revenue management edit operations. They cluster into natural roles:
| Proposed Role | Permissions Bundled | Description |
|---|---|---|
| Revenue Manager | MANAGE_PRICES, MANAGE_FORECAST, EDIT_AUTOPILOT, EDIT_PRICING_STRATEGY, EDIT_RATE_BOUNDS, EDIT_SUBRATES, EDIT_ROOM_TYPE_STRATEGY, EDIT_WPA_RESTRICTIONS, EDIT_STAY_DATE_CLUSTER, MANAGE_GROUP_WASH, UNLOCK_SUBMIT_FORECAST, MANAGE_COMPETITIVE_SET |
Full edit access to pricing and forecasting at a hotel |
| Analyst | (none of the 13 — read-only) | View all data, edit nothing |
| Hotel Admin | MANAGE_HOTEL, MANAGE_COMPETITIVE_SET |
Static hotel configuration |
| Group Manager | MANAGE_GROUP_WASH, MANAGE_FORECAST |
Group business only |
The existing PermissionSet model already bundles these permissions into named roles at provisioning time. Customers assign bundles like "Revenue Manager," not individual toggles like EDIT_STAY_DATE_CLUSTER.
Warning: Data analysis required (Phase 2). Before committing to role-based simplification, query
PermissionSetconfigurations across production customers to confirm the 13 permissions are always assigned in the same clusters. If any customer depends on independent granular toggles (e.g.,MANAGE_PRICESwithoutEDIT_AUTOPILOTat the same hotel), those edge cases must be handled — either as a separate role or as a permission override within a role.
Three Access Tiers
| Tier | Who | Clerk Identity | Authorization (Phase 1) | Authorization (End State) |
|---|---|---|---|---|
| Duetto Admin | Internal staff (support, ops, sales) | "Duetto Internal" Clerk org | Auth-bridge: full access to all companies/hotels. Impersonation via /api/iam/impersonate with audit trail (X-Impersonated-By header). |
Same — impersonation stays in backend |
| Company Admin | Customer admin per company | Company Clerk org, admin role |
Auth-bridge: CompanyPermission flags + admin-level UserPermission |
Clerk admin role + feature flags in Datadog/Statsig |
| Hotel User | Customer user per hotel | Company Clerk org, member role |
Auth-bridge: app access + hotel-scoped permissions + PermissionSet |
Clerk roles: app access + hotel role (revenue_manager, analyst, etc.) + hotel access list |
Clerk Organization Mapping
| Clerk Entity | Maps To | Notes |
|---|---|---|
| "Duetto Internal" org | Duetto staff | Admin access, impersonation capability |
| Company org (1 per Duetto company) | Company entity + all its hotels |
Identity grouping — hotel access resolved by auth-bridge (Phase 1-3) or Clerk membership metadata (end state) |
| User membership | User's role within their company | admin (company admin) or member (hotel user) in Phase 1; granular app + hotel roles in end state |
"Login As" Impersonation
Duetto Admin impersonation stays in the auth-bridge throughout all phases — Clerk doesn't support cross-org impersonation natively, and this is a backend authorization concern:
- Admin authenticates via Clerk ("Duetto Internal" org)
- Admin selects target user + hotel in admin panel
- Auth-bridge validates caller has
AdminPermission.LOGIN_AS+MANAGE_ALL_COMPANIES - Returns target user's
PermissionsResult(not the admin's) - All subsequent API calls carry both identities for audit trail
- Backend enforces target user's permissions, logs admin identity
Phase 1: Foundation (Months 0-6) — Minimal Permissions for BLAST
Onboarding and TourOperator need almost nothing:
| Product | Company Gate | User Gate | Hotel-Level Perms | Notes |
|---|---|---|---|---|
| Onboarding | CompanyPermission.ONBOARDING_APP |
UserPermission.ONBOARDING_APP_ACCESS |
None — delegates to onboarding-domain | 2 checks + 2 route visibility flags |
| TourOperator | New: TOUR_OPERATOR_APP |
New: TOUR_OPERATOR_APP_ACCESS |
None — new product, clean slate | No legacy permission baggage |
Auth-bridge /api/iam/permissions returns (Phase 1 — minimal):
{
"companyApps": ["ONBOARDING_APP", "TOUR_OPERATOR_APP"],
"userApps": ["ONBOARDING_APP_ACCESS", "TOUR_OPERATOR_APP_ACCESS"],
"hotelAccess": ["hotel-123", "hotel-456"],
"routeFlags": {
"showInventoryChanges": true,
"hideCompetitors": false
},
"role": "COMPANY_ADMIN"
}
Cached in Vercel KV alongside JWT exchange (12-minute TTL). One API call on session init. No Clerk authorization setup needed — Clerk handles identity only.
Phase 2: Feature Flags Migrate Out (Months 3-6)
Datadog/Statsig (evaluation already underway) absorbs:
- CompanyPermission (~85 values) → feature-flag product
- HotelPermission (~120+ values) → feature-flag product
- ~30 devOnly/temp flags cleaned up
Auth model shrinks from ~250 values to ~44 UserPermissions + 11 AdminPermissions.
Data analysis runs this phase: Query production PermissionSet configurations to validate hotel-level permission clustering and define role-to-permission mapping.
Phase 3: Dashboard Migrations (Months 6-12) — Hotel Roles in Clerk
Intelligence + Market dashboards migrate to BLAST. These products use hotel-level permissions (MANAGE_PRICES, MANAGE_FORECAST, etc.).
Clerk authorization becomes active: - APP access (13) → Clerk app-access roles - COMPANY-level (18 → ~5-8 simplified) → Clerk admin roles - HOTEL-level (13 → ~4-5 roles based on Phase 2 data analysis) → Clerk hotel roles - PermissionSets → Clerk roles (replaces PermissionSets) - Hotel access → Clerk org membership metadata
Auth-bridge reduces to: JWT exchange (during dual-auth period) + impersonation.
Phase 4: Auth Consolidation (Months 12-18)
- All BLAST MFEs read permissions from Clerk (roles + hotel access)
- Backend services validate Clerk JWT directly via
common-boot-auth - Auth-bridge reduces to: JWT exchange (until legacy frontend retires) + impersonation
- When legacy
duetto-frontendis fully retired, JWT exchange removed - Impersonation stays as a thin backend service
What This Does NOT Require
- No mapping of 250 permissions to Clerk in Phase 1
- No dual-write sync between Clerk and monolith
- No webhook infrastructure to keep permissions in sync
- No waiting for the feature-flag product before BLAST ships
- No solving hotel-level authorization before Onboarding and TourOperator go live
5. Key Decisions
The following matrix summarizes the key architectural decisions, options considered, and recommendations.
| Decision | Option A | Option B | Option C | Recommendation | Rationale |
|---|---|---|---|---|---|
| IAM Modularization | Thin bridge (package) | Light module (Gradle + interfaces) | Full hexagonal (Tenant Management module style) | Light module | 1-2 yr dual auth needs boundaries + tests, but code is deleted after; ~1 week more than thin bridge |
| CDN Strategy | Vercel CDN only | CloudFront unified entry + Vercel | — | CloudFront + Vercel | Single domain, path-based routing, incremental migration |
| Auth Provider | AWS Cognito | Clerk | — | Clerk | Native Vercel integration, built-in multi-tenancy, 10x faster to implement |
| Frontend Architecture | Module Federation | Next.js Multi-Zones / Vite SPAs | — | Mix per product | Vite for interactive SPAs (TourOperator, Onboarding); Next.js for data-heavy dashboards |
| Migration Pattern | Big bang rewrite | Strangler Fig | — | Strangler Fig | Zero big-bang risk, route-by-route, proven |
| API Gateway | AWS API Gateway | Kong (self-hosted) | — | Phase 1: ALB, Phase 2: Kong | Minimize initial cost, add capabilities as needed |
| Monorepo Tool | Nx | Turborepo | — | Turborepo | Better Vercel integration (same company) |
| Legacy Backbone | Embed in Next.js | Separate zone (proxy) | — | Separate zone | Clean separation, no shared JS runtime |
| API Strategy | GraphQL federation | REST facades over GraphQL | Pragmatic hybrid (REST-forward) | Pragmatic hybrid | REST for new services (OpenAPI), BFF absorbs transitional GraphQL for dashboards, GraphQL scoped to legacy — aligned with Polaris Strangler Fig |
| Permissions Strategy | Map all perms to Clerk (Phase 1) | Identity/auth separation (phased) | — | Phased separation | Clerk = identity in Phase 1 (4-6 checks needed). Feature flags migrate to Datadog/Statsig (months 3-6). Hotel perms collapse to ~4 Clerk roles (Phase 3). Full Clerk authorization when model is simple enough. |
| Existing auth code | Refactor into module | Leave in place | — | Leave in place | Works, stable, will be deleted; refactoring adds risk for zero business value |
6. Implementation Roadmap
The migration follows five phases, from foundation infrastructure through full auth consolidation.
Phase 1: Foundation (Weeks 1-6)
Goal: TourOperator and Onboarding live on Vercel with dual auth working
| Week | Deliverable |
|---|---|
| 1-2 | Vercel Enterprise setup, VPC connection (Secure Compute), CloudFront distribution (Terraform) |
| 2-3 | auth-bridge-api + auth-bridge module (~2.5 weeks: light module with interfaces, auto-config, dual auth filter, Clerk validation, token exchange, mapping repos) |
| 3-4 | Cookie domain update, CORS config, DualAuthFilter registered in SecurityConfiguration |
| 4-5 | TourOperator Vite SPA on Vercel, Onboarding Vite SPA on Vercel |
| 5-6 | API integration with Onboarding Domain (formerly Hotel Domain) and relevant domain services |
Deliverables:
- [ ] Vercel Enterprise with Secure Compute + VPC connection
- [ ] CloudFront distribution with path-based routing (Terraform via ops-tf)
- [ ] auth-bridge-api module (4 files: AuthBridgeService interface, DTOs, AuthMethod enum)
- [ ] auth-bridge module (~12 files: service impl, DualAuthFilter, ClerkJwtValidator, ClerkTokenExchanger, mapping repos, auto-config)
- [ ] Integration tests: legacy session path + Clerk JWT path + edge cases
- [ ] TokenExchangeController + IamApiController in frontend (consuming auth-bridge-api)
- [ ] Clerk-Duetto user mapping tables
- [ ] TourOperator Vite SPA deployed to Vercel at /mfe/tour-operator
- [ ] Onboarding Vite SPA deployed to Vercel at /mfe/onboarding
- [ ] Clerk JS SDK added to duetto-frontend for dual auth on legacy pages
- [ ] Backbone.js route inventory — complete audit of all Backbone.js hash routes served from the Java monolith, with complexity and business value assessment (runs in parallel with CloudFront/auth-bridge work; required input for Phase 3 migration scoping)
Phase 2: Production Ready (Weeks 7-14)
Goal: Multiple MFEs, enhanced security, team autonomy
| Week | Deliverable |
|---|---|
| 7-8 | Shared design system package, Turborepo monorepo setup |
| 8-10 | Permission caching, rate limiting, Redis session cache |
| 10-12 | 2-3 more MFEs (market performance, group forecasts) |
| 12-14 | Blue/green deployment, monitoring dashboards, audit logging |
Deliverables: - [ ] Shared UI package (@duetto/blast-ui) - [ ] Shared auth utilities (@duetto/blast-auth) - [ ] Enhanced rate limiting (per-user at edge, per-tenant at gateway) - [ ] Circuit breaker middleware - [ ] Audit log pipeline to DataDog - [ ] Canary deployment (90/10 weighted routing)
Phase 2 → Phase 3 Gate: Kong POC Decision
Before committing Phase 3 engineering budget, the Kong API Gateway POC must be completed and evaluated:
| Gate Criteria | Required Evidence |
|---|---|
| Kong POC deployed in staging | Running on ECS with sample route |
| Latency comparison | Kong vs ALB-only p50/p95/p99 measured |
| Feature validation | Rate limiting, auth forwarding, canary routing confirmed |
| Cost projection | Actual ECS cost vs estimated $500/mo validated |
| Go / No-Go decision | If Kong does not demonstrate clear value over ALB + CloudFront middleware, Phase 3 proceeds with ALB-only and Kong line item is removed from budget |
This POC runs during Phase 2 (Weeks 10-14) and the decision is documented as a DEC record before Phase 3 begins.
Phase 3: Route Migration (Weeks 15-30)
Goal: Migrate high-value routes from legacy to Next.js
| Priority | Routes | Framework |
|---|---|---|
| ✅ Already on Vercel | TourOperator, Onboarding | Vite SPA |
| First | Intelligence dashboard, Market performance | Next.js |
| Second | Gamechanger (rate management), Settings | Vite or Next.js |
| Third | Scoreboard (reporting), Blockbuster (groups) | Vite or Next.js |
| Last | Legacy Backbone.js hash routes, OpenSpace iframe | — |
Phase 4: Scale & Optimization (Months 8+)
- [ ] Kong API Gateway deployment (contingent on Phase 2 POC gate — see above)
- [ ] GraphQL reduction — decommission resolvers replaced by domain service REST APIs (see Section 4.5)
- [ ] Multi-region support evaluation
- [ ] Decommission legacy CloudFront origins
Phase 5: Auth Consolidation (When Frontend Migration Completes, ~1-2 Years)
Goal: Clerk becomes sole auth provider; remove dual auth infrastructure
- [ ] Verify all
duetto-frontendroutes migrated to Next.js on Vercel - [ ] Confirm all users authenticating via Clerk-native (Phase 2 auth)
- [ ] Remove
DualAuthFilterfromSecurityConfiguration.java - [ ] Remove
domain-modules/auth-bridge-api/anddomain-modules/auth-bridge/ - [ ] Remove
DuettoSessionAuthenticationFilterfromfrontend-core - [ ] Remove MongoDB
userSessioncollection dependency (or archive) - [ ] Remove CSRF token mechanism from frontend and backend
- [ ] Remove Clerk JS SDK from legacy
duetto-frontend(repo archived) - [ ] Update
common-boot-authto accept Clerk JWTs natively (or replace) - [ ] Full BLAST architecture realized — Clerk is sole identity provider
7. Cost & Risk
This section combines the projected infrastructure costs and risk assessment.
7.1 New Infrastructure Costs
| Component | Phase 1 | Phase 2 | Phase 3+ | Notes |
|---|---|---|---|---|
| Vercel Enterprise | $2,000/mo | $2,000/mo | $2,000/mo | Includes Private Networking |
| Vercel Secure Compute | $540/mo | $540/mo | $540/mo | $6,500/year |
| Clerk (auth) | $155/mo | $250/mo | $350/mo | Growing user count |
| Vercel KV (Redis) | $150/mo | $300/mo | $300/mo | JWT + cache storage |
| Data Transfer (VPC) | $100/mo | $300/mo | $300/mo | VPC peering |
| CloudFront | $50/mo | $100/mo | $200/mo | CDN distribution |
| Kong Gateway (ECS) | — | $500/mo | $500/mo | Phase 2 only |
| Total New | ~$2,995/mo | ~$3,990/mo | ~$4,190/mo |
7.2 Savings
| Component | Current Cost | Savings After Migration |
|---|---|---|
| EKS pods (10 prod replicas) | ~$500-800/mo | Gradual reduction as routes migrate |
| Express server maintenance | Engineering time | Eliminated for migrated routes |
| Auth maintenance overhead | Engineering time | Reduced by Clerk managed service |
7.3 Total Estimated Investment
| Phase | Duration | Infrastructure | Engineering |
|---|---|---|---|
| Phase 1 | 6 weeks | ~$18k | 2-3 engineers full-time |
| Phase 2 | 8 weeks | ~$34k | 2-3 engineers full-time |
| Phase 3 | 16 weeks | ~$70k | 3-4 engineers (includes route migration) |
| Year 1 Total | ~30 weeks | ~$122k | ~$250-350k engineering |
7.4 Risk Assessment
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Clerk vendor lock-in | Low | Medium | JWTs are standard; migration away possible but costly |
| Dual auth complexity | High | Medium | Time-box transition period; automated user migration |
| VPC connection latency | Low | Medium | Vercel Secure Compute optimized for this; monitor p99 |
| Multi-zone hard navigation UX | Medium | Low | Acceptable for B2B back-office; optimize critical paths |
| Backbone.js migration effort | High | High | Prioritize new features in Vite/Next.js; migrate route-by-route |
| Team learning curve | High | Medium | Training on Vite, Next.js, Clerk, Vercel; hire/upskill |
| Permissions model complexity | Medium | Medium | Phase 1 needs only 4-6 permission checks (Onboarding + TourOperator). Full RBAC mapping deferred to Phase 3 when dashboard products migrate. Feature-flag migration to Datadog/Statsig (months 3-6) removes ~205 values from auth model. Hotel-level permissions collapse to ~4-5 Clerk roles (data analysis in Phase 2 to confirm). See Section 4.6. |
| "Login As" admin impersonation | Medium | Low | Phase 3 custom implementation; workaround via direct monolith access |
| MongoDB session load during dual auth | Medium | Medium | Redis cache layer in Phase 2 |
8. Open Questions & Next Steps
Assumptions (Confirmed)
- VPC connection between Vercel and AWS is confirmed — Vercel Secure Compute with VPC peering will be used
- Tenant Management module is merged —
domain-modules/pattern available forauth-bridge - TourOperator and Onboarding are the first products to migrate to Vercel
- Hotel Domain is being renamed to Onboarding Domain
- Hotel and Company permissions are planned to migrate to a dedicated feature-flag product (timeline uncertain — auth-bridge must support current model)
Open Questions
- Kong vs AWS API Gateway — Need POC to compare latency through VPC connection
- User Provisioning — Auto-create Duetto users from Clerk, or require manual linking?
- ~~Hotel Access Mapping~~ — Resolved. 1 Clerk org per company (identity grouping). Hotel-level access resolved by auth-bridge in Phase 1-3, then by Clerk hotel roles in Phase 3+. Data analysis in Phase 2 to validate hotel permission-to-role clustering. See Section 4.6.
- ~~GraphQL vs REST for BLAST~~ — Resolved. REST-forward for new services, GraphQL scoped to legacy and transitional BFF use. See Section 4.5.
- Backbone.js page inventory — Need complete mapping of hash routes in the monolith
- Node 16 → 20 upgrade — Should this happen before or alongside BLAST migration?
- Shared design system — Does the new design system use MUI or a different component library? Consider alignment with Vite-based MFEs.
- ~~Feature-flag product selection~~ — In progress. Datadog and Statsig are under evaluation. Expected to cover all CompanyPermission and HotelPermission flags within 6 months. See Section 4.6.
Recommended Next Steps
- Secure Vercel Enterprise contract — Confirm Secure Compute and VPC peering setup for staging
- Prototype
auth-bridgemodule — Build and test the Clerk↔Duetto JWT exchange indomain-modules/ - CloudFront POC — Deploy CloudFront distribution in staging with path-based routing
- TourOperator + Onboarding scoping — Define exact routes and APIs these products need on Vercel
- Clerk POC — Set up Clerk with pass-through auth against Duetto staging
- Vite MFE POC — Evaluate Vite for BLAST microfrontends alongside Next.js Multi-Zones (see Section 7)
- Team alignment — Present this research to engineering leads; get buy-in on phasing
- Database strategy — Evaluate database needs for BLAST frontend-specific data (user mappings, UI state)
Appendix A: Auth-Bridge Module Design
Implementation details for Section 4.2.
A.1 The Tenant Management Module: Why the domain-modules/ Pattern is Justified for IAM
The Tenant Management module (PR #8878) establishes the domain-modules/ pattern in the duetto monolith. The key architectural decisions relevant to the auth-bridge module are:
| Tenant Management Module Decision | What It Means for auth-bridge |
|---|---|
domain-modules/ with -api / -impl split |
auth-bridge-api (interface + DTOs) consumed by frontend; auth-bridge (implementation) wired via auto-config. Compile-time boundary enforcement. |
| Spring Boot Auto-Configuration | Module is self-contained and auto-discovered — no @Import needed in the app module |
| Library, not Executable | auth-bridge is a Gradle dependency, NOT a separate service. Same JVM, same Spring context, same Jetty process as the monolith |
settings.gradle auto-include |
Any module under domain-modules/ is auto-registered — adding auth-bridge requires zero build config changes |
| Contract-first API | AuthBridgeService interface in -api defines the contract; frontend controllers never see implementation details |
Why the pattern is justified for IAM despite the code being transitional (1-2 years):
The Tenant Management module's customer-domain uses the full hexagonal ceremony (JMolecules, ArchUnit, domain events, individual use-case classes) because it's a permanent domain. The auth-bridge takes the structural benefits (Gradle module boundaries, API separation, auto-config) while skipping the ceremony — because the code is deleted when the frontend migration completes. The Gradle module boundary is the single highest-value element: it prevents accidental coupling between auth-bridge internals and the rest of the monolith, and it makes deletion clean (rm -rf domain-modules/auth-bridge*).
See Section 4.1 for the full DO vs SKIP breakdown and the recommended auth-bridge structure.
A.2 Modularization Strategy: Calibrated for a 1-2 Year Frontend Migration
Key Constraint: The
duetto-frontendReact 17 SPA will remain on EKS for 1-2 years during gradual route-by-route migration to Next.js on Vercel. This means dual auth (legacy sessions + Clerk JWTs) must coexist in production for that entire period.
Why This Changes the Approach
The Tenant Management module's customer-domain is a permanent domain — tenants will always exist in Duetto. The IAM/auth bridge for Clerk is transitional — once the frontend migration completes and all users are on Clerk-native auth, the bridge code is deleted. But "transitional" at 1-2 years is long enough that a throwaway thin bridge becomes painful, while full hexagonal ceremony (JMolecules, ArchUnit, domain events, individual use-case classes) is over-engineering for code with a known expiration date.
The right investment level is a Gradle module with clean interfaces but without hexagonal ceremony.
| Approach | Effort | Comfortable Lifespan |
|---|---|---|
Thin bridge (package in frontend, no module) |
~1.5 weeks | Painful after 6 months |
| Light module (Gradle module, interfaces, auto-config) | ~2.5 weeks | Comfortable for 1-2 years ✅ |
| Full hexagonal (Tenant Management module style with JMolecules, ArchUnit, domain events) | ~4-5 weeks | Overkill — deleted in 1-2 years |
What the Gradle Module Boundary Buys You
Creating auth-bridge as a Gradle module (not just a package) gives compile-time enforcement for free:
- The auth-bridge can't accidentally import frontend controllers
- The frontend module can only see the interfaces exposed via auth-bridge-api
- Adding it under domain-modules/ auto-registers it via settings.gradle (Tenant Management module pattern)
- Spring Boot auto-configuration wires it without @Import in the app module
This is the single highest-value thing the Tenant Management module proved — and it costs almost nothing extra over a plain package.
Recommended Structure: auth-bridge
domain-modules/
customer-domain-api/ ← Tenant Management module (existing)
customer-domain/ ← Tenant Management module (existing)
auth-bridge-api/ ← NEW: ~4 files (what frontend sees)
auth-bridge/ ← NEW: ~12 files (implementation)
auth-bridge-api (the contract — consumed by frontend):
// The entire API module — 4 files
public interface AuthBridgeService {
AuthResult validateRequest(HttpServletRequest request); // Legacy OR Clerk
AuthResult exchangeClerkToken(String clerkJwt); // Clerk → Duetto JWT
AuthResult validateDuettoCredentials(String email, String password); // Pass-through
PermissionsResult getPermissions(String userId, String companyId);
PermissionsResult getPermissions(String userId, String companyId, String hotelId); // Hotel-scoped
}
public record AuthResult(String duettoJwt, UserContext user, AuthMethod method) {}
public record PermissionsResult(
Set<String> companyPermissions, // CompanyPermission flags (feature-level)
Set<String> userPermissions, // UserPermission (user access rights)
Map<String, Set<String>> hotelPermissions // Hotel-scoped permissions (hotelId → permissions)
) {}
public enum AuthMethod { LEGACY_SESSION, CLERK_JWT }
Note on permissions: The
PermissionsResultincludescompanyPermissions(CompanyPermission feature flags) andhotelPermissions(hotel-scoped UserPermission) because the planned migration to a feature-flag product has an uncertain timeline. The auth-bridge must expose the full current permission model — it cannot assume the feature-flag product will be ready before BLAST MFEs go live.
auth-bridge (implementation):
auth-bridge/src/main/java/com/duetto/authbridge/
AuthBridgeServiceImpl.java ← Routes to correct auth path
ClerkJwtValidator.java ← Clerk JWKS validation
ClerkTokenExchanger.java ← Clerk JWT → Duetto JWT (KMS signing)
UserMappingRepository.java ← blast_user_mappings CRUD
OrgMappingRepository.java ← blast_org_mappings CRUD
DualAuthFilter.java ← Spring Security filter (detects auth method)
AuthBridgeAutoConfiguration.java ← Auto-config (follows Tenant Management module pattern)
auth-bridge/src/test/java/
DualAuthFilterTest.java ← Critical: both auth paths work
ClerkTokenExchangerTest.java ← Token exchange correctness
AuthBridgeServiceIntegrationTest.java ← Full round-trip test
Then in frontend, REST controllers consume the auth-bridge-api interface:
// frontend/src/main/java/com/duetto/frontend/blast/TokenExchangeController.java
@RestController
@RequestMapping("/api/auth")
public class TokenExchangeController {
private final AuthBridgeService authBridgeService; // From auth-bridge-api
@PostMapping("/exchange")
public ResponseEntity<AuthResult> exchange(@RequestHeader("Authorization") String clerkJwt) {
return ResponseEntity.ok(authBridgeService.exchangeClerkToken(clerkJwt));
}
@PostMapping("/duetto-validate")
public ResponseEntity<AuthResult> validate(@RequestBody CredentialsRequest request) {
return ResponseEntity.ok(
authBridgeService.validateDuettoCredentials(request.email(), request.password()));
}
}
What to DO vs SKIP (Tenant Management Module Elements)
| Tenant Management Module Pattern Element | Do It? | Rationale |
|---|---|---|
Gradle module under domain-modules/ |
Yes | Compile-time boundary enforcement, auto-included by settings.gradle |
-api / -impl split |
Yes | Frontend depends only on interface — cheap and high value |
| Spring Boot auto-configuration | Yes | Zero wiring effort in app module, consistent with Tenant Management module |
| Clean interfaces (ports) | Yes | Makes Clerk adapter swappable; makes testing easy |
| Integration tests for dual auth | Yes | Dual auth is the riskiest part — test both paths thoroughly |
| JMolecules stereotypes | Skip | Ceremony for code with a 1-2 year lifespan |
| ArchUnit boundary tests | Skip | The Gradle module boundary already enforces this |
| Domain events | Skip | Structured logging is sufficient (log.info("token_exchanged", ...)) |
| Individual use-case classes | Skip | One service class is fine for 4 operations |
| Value objects (CompanyId-style) | Skip | Strings are fine for identity mappings |
| Aggregate root pattern | Skip | No complex domain invariants — it's a lookup table + token signing |
Why NOT Modularize the Existing Auth Code
The existing auth (DuettoSessionAuthenticationFilter, SessionTokenManager, MongoDB sessions, CSRF) should not be extracted into the new module. It works, it's stable, it will be deleted when Clerk takes over. The auth-bridge module only contains the new code:
- The dual-auth routing logic (how to tell if a request is Clerk vs legacy)
- The Clerk integration (JWKS validation, token exchange)
- The user/org mapping persistence
- The pass-through credential validation endpoint
The existing DuettoSessionAuthenticationFilter stays exactly where it is in frontend-core. The auth-bridge module's DualAuthFilter sits alongside it in the Spring Security filter chain.
A.3 Current IAM Architecture (Existing — Not Modified)
The authentication in duetto is deeply embedded across multiple modules. These are not refactored — the auth-bridge module works alongside them:
app/config/SecurityConfiguration.java ← Add DualAuthFilter to chain (1 line)
frontend-core/filter/DuettoSessionAuthFilter.java ← UNCHANGED — still handles legacy sessions
api/session/SessionTokenManager.java ← UNCHANGED — still manages MongoDB sessions
api/auth/ ← UNCHANGED — JWT/KMS signing reused by auth-bridge
data/model/user/UserPermission.java ← UNCHANGED — auth-bridge reads these
data/model/user/PermissionSet.java ← UNCHANGED — auth-bridge reads these
data/model/user/UserContext.java ← UNCHANGED — auth-bridge returns these
A.4 Required IAM API Endpoints
The BLAST architecture document and microfrontend document define 7 IAM endpoints that the auth-bridge module exposes:
| # | Endpoint | Method | Purpose | Served By |
|---|---|---|---|---|
| 1 | /api/iam/validate-session |
POST | Validate session (legacy or Clerk), return user context + permissions | AuthBridgeService.validateRequest() |
| 2 | /api/iam/login |
POST | Authenticate user from MFE | AuthBridgeService.validateDuettoCredentials() |
| 3 | /api/iam/refresh-session |
POST | Extend session without re-auth | Delegates to existing SessionTokenManager |
| 4 | /api/iam/logout |
POST | Invalidate session (both auth methods) | Clerk SDK + existing session cleanup |
| 5 | /api/iam/permissions |
GET | Fetch user permissions (company + hotel scoped) | AuthBridgeService.getPermissions() |
| 6 | /api/iam/csrf-token |
GET | Obtain fresh CSRF token (legacy frontend only) | Existing CSRF mechanism |
| 7 | /api/auth/exchange |
POST | Exchange Clerk JWT for Duetto JWT | AuthBridgeService.exchangeClerkToken() |
A.5 Dual Auth: How Both Frontends Coexist
During the 1-2 year transition, every request to the monolith hits the DualAuthFilter, which detects the auth method and routes accordingly:
Request arrives at SecurityConfiguration filter chain
│
├─ Has Authorization: Bearer <clerk-jwt> header?
│ └─ YES → DualAuthFilter → ClerkJwtValidator → TokenExchanger → DuettoJWT → SecurityContext
│
├─ Has sid cookie OR X-Duetto-ApiKey header?
│ └─ YES → DuettoSessionAuthenticationFilter (existing, unchanged) → SecurityContext
│
└─ Neither?
└─ 401 Unauthorized
Both paths end with a valid SecurityContext containing a UsernamePasswordAuthenticationToken with ROLE_DUETTO. Everything downstream (GraphQL @auth, REST controllers, permission checks) works identically regardless of auth method.
| Concern | Legacy Frontend (EKS) | BLAST Frontend (Vercel) |
|---|---|---|
| Auth mechanism | Session cookie (sid) + CSRF token |
Clerk JWT in Authorization header |
| Filter | DuettoSessionAuthenticationFilter (existing) |
DualAuthFilter (new, in auth-bridge) |
| Session storage | MongoDB (userSession collection) |
Clerk cloud + Vercel KV cache |
| Permission loading | GraphQL @auth directive |
/api/iam/permissions endpoint |
| Property context | propertyId cookie + URL param |
URL param + Clerk org metadata |
| Outcome | SecurityContext with ROLE_DUETTO |
SecurityContext with ROLE_DUETTO ← same |
A.6 Impact on Monolith Decoupling: Auth as a Shared Dependency
This section addresses how the decision to leave existing auth code unmodularized (Section A.2) interacts with the broader monolith extraction effort described in the Shadow Vampire Pipeline and Polaris Architecture proposals.
The Tension
The auth-bridge module is designed to solve BLAST's problem: dual-auth coexistence for 1-2 years. It deliberately leaves the existing auth code (DuettoSessionAuthenticationFilter, SessionTokenManager, CompanyPermission, UserPermission, PermissionSet, GraphQL @auth) scattered across frontend-core, api, data, and app/config.
This is pragmatic for BLAST, but creates a constraint for monolith decoupling. Every domain service extracted from the monolith — onboarding-domain, pricing-domain, group-domain, etc. — needs to answer: "how do I authenticate requests and check permissions?"
Today, extracted services use common-boot-auth for JWT validation. But the authorization model (who can do what, at which hotel, with which company permissions) remains locked inside the monolith's data module. There is no clean API contract for permissions.
Three Consequences of Leaving Auth Unmodularized
1. Extracted services have no clean permission contract
When a domain service handles a request, it needs to know the user's permissions — not just their identity. Currently, extracted services either:
| Approach | Trade-off |
|---|---|
| Call back to the monolith for permission checks | Runtime coupling — service can't operate if monolith is down |
| Receive permissions in JWT claims | Stale data risk (permissions change between token issuance and use); token bloat with 50+ permissions × N hotels |
| Duplicate the permission model | Divergence risk — two sources of truth for who can do what |
The auth-bridge's PermissionsResult (company permissions, user permissions, hotel-scoped permissions) already defines the right contract. But only BLAST frontends use it — domain services don't.
2. "Deleted when Clerk takes over" is an oversimplification
The proposal assumes legacy auth is removed when the frontend migration completes. In practice:
| Component | Covered by Clerk? | When removed? |
|---|---|---|
| Session authentication (cookies, MongoDB) | Yes — replaced by Clerk JWTs | When last legacy frontend route migrates |
| CSRF tokens | Yes — not needed with JWT auth | Same as above |
| Backend-to-backend auth (service mesh) | No — Clerk is user-facing only | Never (needs separate solution) |
| 4-layer RBAC model | No — Clerk handles identity, not business authorization | Never (business rules, not auth infrastructure) |
| Backbone.js pages | Eventually — but deprioritized ("Last" in roadmap) | Uncertain (could be years) |
GraphQL @auth directive enforcement |
No — resolver-level authorization is business logic | Never |
Clerk replaces the authentication mechanism (how users prove their identity), but not the authorization model (what users are allowed to do). The 4-layer RBAC system (CompanyPermission + UserPermission + PermissionSet + @auth) is a business rules problem that outlives any auth provider migration.
3. Missed opportunity for a unified identity contract
The AuthBridgeService interface already defines exactly what a proper identity API would look like:
public interface AuthBridgeService {
AuthResult validateRequest(HttpServletRequest request);
AuthResult exchangeClerkToken(String clerkJwt);
PermissionsResult getPermissions(String userId, String companyId);
PermissionsResult getPermissions(String userId, String companyId, String hotelId);
}
This contract could serve all consumers — BLAST frontends, legacy frontend, AND extracted domain services — without refactoring the existing auth code. The implementation would delegate to the same scattered auth classes that exist today; only the entry point would be unified.
Recommendation: Extend, Don't Rewrite
The auth-bridge module does not need full hexagonal modularization. But it should be designed as the unified identity facade from day one, not just a BLAST adapter:
| Current Proposal (BLAST-only) | Recommended Extension |
|---|---|
AuthBridgeService handles only Clerk traffic |
AuthBridgeService handles both Clerk AND legacy traffic — thin delegation to existing code for legacy path |
| Legacy auth bypasses the bridge entirely | Legacy auth continues to work via existing filters, but PermissionsResult is available to any consumer |
| Extracted services call monolith directly for permissions | Extracted services depend on auth-bridge-api — same PermissionsResult contract regardless of auth method |
| Interface deleted with auth-bridge in 1-2 years | Interface survives the auth-bridge — when Clerk takes over, swap the implementation, downstream consumers unchanged |
Additional effort: ~1 week on top of the 2.5-week estimate (total ~3.5 weeks). The work is wrapping existing permission-loading code behind the AuthBridgeService interface — not refactoring it.
What this buys:
- Domain services extracted via Shadow Vampire have a clean dependency (auth-bridge-api) instead of reaching into the monolith's data module
- The permission model is exposed as a contract (PermissionsResult) that can evolve independently of the auth mechanism
- When Clerk replaces legacy sessions, only the AuthBridgeServiceImpl changes — no downstream impact
- When the permission model eventually migrates to a feature-flag product, the contract stays the same — only the implementation is swapped again
What This Does NOT Change
- The existing
DuettoSessionAuthenticationFilterstays infrontend-core— unchanged - The existing
SessionTokenManagerstays inapi— unchanged - The existing
CompanyPermission,UserPermission,PermissionSetstay indata— unchanged - The DO/SKIP list from A.2 remains valid — no JMolecules, no ArchUnit, no domain events
- The auth-bridge Gradle module structure is identical — the only change is the scope of what
AuthBridgeServiceImpldelegates to
Appendix B: Clerk Integration Details
Implementation details for Section 4.2.
B.1 Current Auth vs Clerk
| Dimension | Current (Duetto) | With Clerk |
|---|---|---|
| Session storage | MongoDB (userSession collection) | Clerk cloud (JWT-based) |
| Token format | SHA-512 hashed session IDs | JWT (signed by Clerk) |
| CSRF | Custom token in localStorage | Not needed (JWT in Authorization header) |
| MFA | Not available | Built-in |
| SSO | Auth0 + SAML (complex setup) | SAML/OIDC out of the box |
| Multi-tenancy | Company entity + manual setup | Organizations feature (native) |
| RBAC | 4-layer custom system | Custom roles + permissions (up to 10 roles) |
| User provisioning | Manual admin creation | Self-service + org invites |
| Social login | Not available | Google/Microsoft SSO ready |
B.2 Two-Phase Migration Strategy (from BLAST doc)
Phase 1 — Pass-through (Transitional):
User → Duetto credentials in BLAST UI
→ Clerk sends pass-through auth request
→ Clerk validates via Duetto /api/auth/duetto-validate
→ Duetto returns user info (companyId, hotels, roles)
→ Clerk auto-provisions user (creates/updates)
→ Clerk session + JWT returned
→ User logged in with Clerk session
Phase 2 — Clerk-native (Steady State):
User → Clerk login directly
→ Clerk JWT issued
→ API request with Clerk JWT in Authorization header
→ Vercel Edge middleware validates Clerk session
→ Exchange Clerk JWT for Duetto JWT (cached 12 min in Vercel KV)
→ Call Domain API with Duetto JWT
→ Response returned
B.3 Clerk Organization Mapping to Duetto
| Clerk Entity | Maps To | Duetto Entity |
|---|---|---|
| User (clerk_user_id) | blast_user_mappings |
User (duetto_user_id) |
| Organization (clerk_org_id) | blast_org_mappings |
Company (company_id) + hotel_ids |
| Membership (admin/viewer) | Role mapping | UserRole (ADMIN/READ_ONLY) |
Custom permissions (org:pricing:update) |
Permission mapping | UserPermission enum values |
B.4 Mapping Duetto's 4-Layer RBAC to Clerk
| Duetto Layer | Phase 1 | End State | Notes |
|---|---|---|---|
| CompanyPermission (85 feature flags) | Auth-bridge serves the ~4 flags BLAST Phase 1 needs | Datadog/Statsig — removed from auth entirely | Feature-flag migration expected within 6 months |
| HotelPermission (120+ per-hotel toggles) | Not needed by Onboarding/TourOperator | Datadog/Statsig — removed from auth entirely | Same timeline as CompanyPermission |
| UserPermission APP-level (13) | Auth-bridge checks ONBOARDING_APP_ACCESS, TOUR_OPERATOR_APP_ACCESS |
Clerk app-access roles | Straightforward 1:1 mapping |
| UserPermission COMPANY-level (18) | Auth-bridge serves as needed | Clerk admin roles (~5-8 simplified) | Rationalize during Phase 3 |
| UserPermission HOTEL-level (13) | Not needed by Phase 1 products | Clerk hotel roles (~4-5: Revenue Manager, Analyst, Hotel Admin, Group Manager) | Data analysis in Phase 2 to confirm clustering; separate Permissions Migration initiative defines final roles |
| PermissionSet (role bundles) | Auth-bridge returns role name | Clerk roles (replaces PermissionSets) | Natural mapping — PermissionSets are already roles |
| AdminPermission (11 Duetto staff) | Auth-bridge handles impersonation + admin access | Clerk "Duetto Internal" org + backend impersonation service | Impersonation stays in backend permanently |
| GraphQL @auth | Backend enforcement (unchanged) | Backend enforcement (unchanged) | Clerk handles identity, services handle authorization |
B.5 Clerk Pricing for Duetto
Estimated for ~500 active users across ~50 hotel properties:
| Component | Monthly Cost |
|---|---|
| Pro plan (base) | $25 |
| B2B add-on (unlimited org members) | $85-100 |
| Enterprise SSO connections (3 hotel chains with SAML) | $45-225 |
| Total | ~$155-350/month |
B.6 Pros & Cons
Pros: - Dramatically faster implementation (days vs weeks) - Native Vercel/Next.js integration (Edge middleware, hooks, server components) - Built-in multi-tenancy (Organizations) eliminates custom development - Modern SSO/MFA/social login out of the box - Pre-built UI components (SignIn, SignUp, OrganizationSwitcher) - Very reasonable pricing for B2B scale
Cons: - Vendor lock-in (Clerk-specific APIs, though JWT tokens are standard) - Must maintain dual auth during transition period (1-2 years) - Mapping 50+ UserPermissions to Clerk's permission model requires design work - CompanyPermission and hotel-scoped permissions must be mapped to Clerk from day one — the planned feature-flag product migration has an uncertain timeline, so the auth-bridge cannot defer this work - "Login As" admin impersonation needs custom implementation - Hotel-level permission scoping (not just org-level) requires custom middleware — Clerk Organizations don't natively model hotel-level access within a company
Appendix C: CDN Comparison
Supporting analysis for Section 4.1.
C.1 Detailed Comparison
| Dimension | Vercel CDN | AWS CloudFront |
|---|---|---|
| Points of Presence | 126 PoPs | 600+ PoPs |
| Edge Compute | Next.js Middleware (Edge Runtime) | CloudFront Functions + Lambda@Edge |
| Framework Integration | Deep Next.js (ISR, SSR, RSC) | Framework-agnostic |
| Cold Starts | Near-zero for Edge Middleware | Sub-ms (CF Functions), 50-200ms (Lambda@Edge) |
| Caching | Automatic, framework-aware | Manual configuration |
| Cache Invalidation | Instant on deploy | Up to 15 min propagation |
| Deployment | Git push → instant | CloudFormation → minutes |
| HTTP/3 | Not supported | Supported |
| WebSocket | Not supported (standard) | Supported |
| DDoS | Built-in Firewall | Shield Standard (free) + Shield Advanced (paid) |
| Pricing | Included in plan + usage overages | Pay-per-GB + per-request |
C.2 Critical Context: Duetto Has No CDN Today
The current duetto-frontend serves all assets directly from Express.js on EKS — there is no CloudFront distribution in place. This means any CDN is a net improvement.
C.3 Terraform Implementation
The Terraform for CloudFront is already designed in the microfrontend architecture doc with:
- ACM certificate (must be us-east-1 for CloudFront)
- Route53 A record aliased to CloudFront distribution
- Cache behaviors per path pattern
- Origin groups for failover
- Invalidation via GitHub Actions on Vercel deploys
This fits perfectly into Duetto's existing ops-tf + terraform_modules infrastructure patterns.
Appendix D: MFE Framework Evaluation
Supporting analysis for Section 4.3.
D.1 Four Strategies Evaluated
| Strategy | Description | Vercel Support | SSR/SSG |
|---|---|---|---|
| Module Federation (Webpack 5) | Runtime composition of remote modules | Not compatible with Next.js | N/A |
| Next.js Multi-Zones | Separate Next.js apps under one domain | Native support | Full SSR/SSG/ISR |
| Vite-based SPA MFEs | Separate React SPAs built with Vite, deployed to Vercel | Full support | Client-side only (CSR) |
| Completely Separate Apps | Independent apps on separate domains | Full support | Varies |
D.2 Module Federation — Not Recommended
Why Module Federation doesn't work for Duetto's BLAST migration:
- Next.js uses Turbopack (not webpack) in modern versions — MF is incompatible
- Next.js server components, streaming, and ISR conflict with MF's runtime composition
- Would require ejecting from Next.js's build system
- The existing microfrontend-poc uses Express + http-proxy-middleware — not Next.js-compatible
D.3 Next.js Multi-Zones
How it works:
- Each zone is a standard Next.js application with its own assetPrefix
- One "host" application uses next.config.js rewrites to route paths to other zones
- Each zone is independently deployable on Vercel
- Within a zone: SPA-like soft navigation
- Between zones: hard navigation (full page reload — acceptable for B2B back-office)
Best for: Pages that benefit from SSR/SSG (SEO, first-paint performance), BFF pattern with Next.js API routes, data-heavy dashboards with ISR caching.
D.4 Vite-based SPA MFEs
How it works:
- Each MFE is a standalone React SPA built with Vite
- Deployed to Vercel as static assets (or with Vite SSR if needed)
- CloudFront routes path patterns (/mfe/tour-operator/*, /mfe/onboarding/*) to Vercel
- Each MFE is a separate Vercel project with independent deployment
Best for: Products that are primarily interactive SPAs (like the current duetto-frontend architecture), where SSR/SSG adds complexity without significant benefit. TourOperator and Onboarding are good candidates — they are back-office tools where client-side rendering is acceptable.
Vite advantages over Next.js for MFEs:
- Simpler mental model — no SSR/SSG/ISR modes to choose between, no server components
- Faster build times — Vite's dev server and HMR are significantly faster than Next.js
- Closer to current architecture — duetto-frontend is already a React SPA; Vite MFEs are a natural evolution
- Lighter runtime — no Next.js framework overhead for pure client-side apps
- React 19 compatibility — Vite works with any React version without framework constraints
- admin-panel precedent — The existing admin-panel repo already uses React 18 + Vite, proving the pattern works at Duetto
Vite considerations: - No built-in BFF layer — API calls go directly to backend or through a shared API gateway - No ISR/SWR caching at the framework level — must implement caching in the client (React Query, SWR) - Auth integration via Clerk React SDK (client-side) rather than Clerk Next.js middleware (edge)
D.5 Shared State Across MFEs
| State Type | Solution | Persistence |
|---|---|---|
| Auth context (user, org, role) | Clerk JWT (cookie-based, same domain) | Session |
| Selected hotel/property | URL parameter + cookie | Navigation |
| User preferences | Clerk user metadata + DB | Persistent |
| Application state | Each MFE owns its own | MFE-scoped |
| Business data | Shared API layer (domain services) | Backend |
D.6 Team Autonomy Benefits
Each MFE (whether Vite or Next.js) can have: - Independent CI/CD pipelines (Vercel auto-deploys per project) - Independent release schedules - Different team ownership - Independent dependency versions (within shared constraints) - Preview environments per PR per MFE - Choice of framework — teams pick Vite or Next.js based on product requirements
Appendix E: Required Changes (Detailed)
Detailed change lists supporting Section 6.
E.1 Changes in duetto (Backend)
Foundation: The Tenant Management module (PR #8878) is merged. The
domain-modules/pattern,settings.gradleauto-include, and Spring Boot auto-configuration are available.Modularization approach: Light module — Gradle module with clean interfaces and auto-config, but without full hexagonal ceremony (no JMolecules, no ArchUnit, no domain events). See Section 4.1 for rationale.
| # | Change | Priority | Effort | Phase | Notes |
|---|---|---|---|---|---|
| 1 | Create auth-bridge-api + auth-bridge under domain-modules/ (~16 files) |
Critical | 2.5 weeks | 1 | Light module following Tenant Management module pattern |
| 2 | TokenExchangeController + IamApiController in frontend (consume auth-bridge-api) |
Critical | 3-5 days | 1 | REST controllers only — logic in auth-bridge |
| 3 | DualAuthFilter in auth-bridge + register in SecurityConfiguration |
Critical | 3-5 days | 1 | Routes Clerk vs legacy session requests |
| 4 | Cookie domain → .duettoresearch.com |
Critical | 2-3 days | 1 | Config change in frontend-core |
| 5 | CORS policy for Vercel origins | Critical | 1-2 days | 1 | Config change in app/config |
| 6 | Clerk JWKS validation (ClerkJwtValidator in auth-bridge) |
High | 3-5 days | 1 | Part of auth-bridge module |
| 7 | blast_user_mappings + blast_org_mappings (UserMappingRepository in auth-bridge) |
High | 2-3 days | 1 | Part of auth-bridge module |
| 8 | Integration tests for dual auth (both paths, edge cases) | High | 3-5 days | 1 | Critical — dual auth is riskiest part |
| 9 | Redis session cache (reduce MongoDB read load) | Medium | 1-2 weeks | 2 | Existing module enhancement |
| 10 | Permission caching (server-side @Cacheable) | Medium | 1 week | 3 | Existing module enhancement |
| 11 | "Login As" compatibility for MFEs | Low | 1 week | 3 | Extend auth-bridge for admin impersonation |
E.2 Changes in duetto-frontend (Frontend)
| # | Change | Priority | Effort | Phase |
|---|---|---|---|---|
| 1 | Add Clerk JS SDK for session validation on legacy pages | High | 3-5 days | 1 |
| 2 | Update Express proxy to handle CloudFront forwarded headers | High | 2-3 days | 1 |
| 3 | Add X-Forwarded-Host handling |
High | 1 day | 1 |
| 4 | Navigation links to BLAST routes (/mfe/tour-operator, /mfe/onboarding) |
Medium | 1 week | 2 |
| 5 | Shared design system extraction to npm package | Medium | 2-3 weeks | 2 |
| 6 | Route-based code splitting (React.lazy) | Medium | 1-2 weeks | 2 |
| 7 | React 18 upgrade (prerequisite for gradual migration) | Medium | 2-4 weeks | 2 |
| 8 | Product-by-product migration to Vite SPAs or Next.js MFEs | High | Ongoing | 2-4 |
E.3 New Infrastructure (Terraform via ops-tf)
| # | Resource | Priority | Effort | Phase |
|---|---|---|---|---|
| 1 | CloudFront distribution (unified entry) | Critical | 1-2 weeks | 1 |
| 2 | ACM certificate (us-east-1) | Critical | 1 day | 1 |
| 3 | Route53 DNS update | Critical | 1 day | 1 |
| 4 | VPC connection (Vercel Secure Compute ↔ AWS) | Critical | 1-2 weeks | 1 |
| 5 | Vercel Enterprise configuration | Critical | 1-2 days | 1 |
| 6 | Kong API Gateway on ECS | Medium | 2-3 weeks | 2 |
Appendix F: Reference Documents & Key Files
Sources and key file paths referenced throughout this document.
| Document | Location | Author | Date |
|---|---|---|---|
| BLAST Connectivity Architecture | ~/docs/research/BLAST Connectivity Architecture.docx.md | Robert Matsuoka | 2026-02-10 |
| Microfrontend Architecture Design | ~/docs/research/duetto-microfrontend-architecture-2026-02-24.md | Research | 2026-02-24 |
| Tenant Management Module (Customer Domain) | PR #8878 | Arif Mohammed | 2025-12-01 → 2026-02-20 |
| Tenant Management Module Follow-up (Documentation) | PR #8914 | Copilot | 2026-02-20 |
| Hexagonal Architecture Wiki | Confluence | Duetto | — |
| duetto-frontend Architecture Analysis | ~/dev/duetto-frontend/docs/research/duetto-frontend-architecture-analysis-2026-02-25.md | Research Agent | 2026-02-25 |
| duetto Backend Architecture Analysis | ~/dev/duetto/docs/research/duetto-backend-architecture-analysis-2026-02-25.md | Research Agent | 2026-02-25 |
| ~/dev Directory Inventory | ~/dev/docs/research/dev-directory-inventory-2026-02-25.md | Research Agent | 2026-02-25 |
| BLAST Migration Technology Research | ~/dev/docs/research/blast-architecture-migration-research-2026-02-25.md | Research Agent | 2026-02-25 |
duetto (Backend)
app/src/main/java/com/duetto/app/config/SecurityConfiguration.java— Security filter chainfrontend-core/src/main/java/com/duetto/frontend/spring/filter/DuettoSessionAuthenticationFilter.java— Primary auth filterapi/src/main/java/com/duetto/api/session/SessionTokenManager.java— Session managementapi/src/main/java/com/duetto/auth/— JWT, KMS, Bearer tokensdata/src/main/java/com/duetto/model/user/UserPermission.java— 50+ permissions enumdata/src/main/java/com/duetto/model/company/Company.java— Tenant entity
duetto-frontend (Frontend)
src/server/server-prod.js— Production Express serversrc/containers/App/App.tsx— Main app shellsrc/containers/App/BaseAppRoute.tsx— Product route wrapper (property context, permissions)helm/— Kubernetes deployment configuration.github/workflows/deploy-to-eks.yml— Deployment pipeline
Infrastructure
ops-tf/— Primary Terraform monorepoterraform_modules/— 56+ reusable modulescommon-boot-auth/— JWT + AWS KMS signing library
Appendix G: Glossary of Terms
| Term | Definition |
|---|---|
| ACM | AWS Certificate Manager — provisions and manages SSL/TLS certificates |
| ALB | Application Load Balancer — AWS Layer 7 load balancer that routes HTTP/HTTPS traffic |
| BFF | Backend for Frontend — a server-side component tailored to a specific frontend's data needs |
| BLAST | Build Like A Startup — Duetto's modernization initiative defining a three-layer architecture (Vercel Edge → Secure Connection → AWS VPC) |
| CDN | Content Delivery Network — globally distributed servers that cache and serve content closer to users |
| CI/CD | Continuous Integration / Continuous Deployment — automated build, test, and deployment pipelines |
| CORS | Cross-Origin Resource Sharing — browser security mechanism controlling which domains can make API requests |
| CSRF | Cross-Site Request Forgery — an attack where a malicious site tricks a user's browser into making unwanted requests; prevented with tokens |
| CSR | Client-Side Rendering — the browser downloads JavaScript and renders the page locally |
| DDD | Domain-Driven Design — software design approach that models code around business domains |
| DNS | Domain Name System — translates domain names (e.g., app.duettoresearch.com) to IP addresses |
| EKS | Elastic Kubernetes Service — AWS managed Kubernetes for running containerized applications |
| HMR | Hot Module Replacement — development feature that updates code in the browser without a full page reload |
| IAM | Identity and Access Management — systems that control who can access what in an application |
| ISR | Incremental Static Regeneration — Next.js feature that regenerates static pages on-demand after deployment |
| JWT | JSON Web Token — a compact, signed token for securely transmitting identity and claims between systems |
| JWKS | JSON Web Key Set — a public endpoint that provides the keys needed to verify JWT signatures |
| KMS | AWS Key Management Service — managed service for creating and controlling cryptographic keys |
| MFA | Multi-Factor Authentication — requiring two or more verification methods to sign in |
| MFE | Micro-Frontend — an independently deployable frontend application that owns a specific product or route |
| OIDC | OpenID Connect — identity layer built on top of OAuth 2.0 for user authentication |
| ORM | Object-Relational Mapping — a technique for converting data between a database and application objects |
| PoP | Point of Presence — a physical location in a CDN's network where content is cached and served |
| RBAC | Role-Based Access Control — authorization model where permissions are assigned to roles, and roles to users |
| RSC | React Server Components — React components that render on the server, reducing client-side JavaScript |
| SAML | Security Assertion Markup Language — XML-based protocol for exchanging authentication data between identity providers and service providers |
| SPA | Single Page Application — a web app that loads a single HTML page and dynamically updates content without full page reloads |
| SSG | Static Site Generation — pre-rendering pages to HTML at build time |
| SSO | Single Sign-On — authentication scheme that allows a user to log in once and access multiple applications |
| SSR | Server-Side Rendering — generating HTML on the server for each request, improving initial load time and SEO |
| TTL | Time To Live — the duration a cached value remains valid before it expires |
| VPC | Virtual Private Cloud — an isolated network within AWS where resources communicate securely |