proposal draft

BLAST Frontend Enablement and Migration: Duetto to Vercel

Antonio Cortés Updated 2026-03-11
blast frontend vercel migration authentication clerk cloudfront architecture

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

  1. Objective: Adopting the BLAST Architecture
  2. Where We Are Today
  3. The Gaps Between Current State and BLAST
  4. Bridging the Gaps
  5. Key Decisions
  6. Implementation Roadmap
  7. Cost & Risk
  8. Open Questions & Next Steps

Appendices


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/exchangeauth-bridge ClerkTokenExchanger → KMS-signed Duetto JWT → cached in Vercel KV
5 Dual auth at monolith Request with Clerk JWT → DualAuthFilterClerkJwtValidator → 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:

  1. 17 domain microservices already extracted — onboarding-domain (formerly hotel-domain), intelligence-domain, market-domain, group-domain are the services BLAST targets first
  2. common-boot-auth provides JWT + KMS signing — foundation for token exchange
  3. Tenant Management module (domain-modules/ pattern)auth-bridge module follows this established pattern
  4. admin-panel (React 18 + Vite) — existing precedent for Vite at Duetto
  5. ops-tf + terraform_modules — infrastructure patterns ready for CloudFront/VPC modules
  6. duetto-gitops (FluxCD) — GitOps patterns can extend to Vercel deployments
  7. 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:

  1. Single domain (app.duettoresearch.com) — no CORS issues, shared cookies
  2. Path-based routing allows incremental migration
  3. CloudFront handles global CDN + caching for existing assets
  4. Vercel Edge handles Next.js-specific optimizations (ISR, streaming, edge middleware)
  5. 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/exchange endpoint, 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. Each apps/* 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-frontend repo 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

  1. 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.

  2. 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, @auth directives, and DataLoader — extraction requires re-architecture.

  3. HTTP caching. BLAST puts CloudFront in front of everything. REST GET responses are cacheable with standard Cache-Control headers — zero custom infrastructure. GraphQL POST requests are not cacheable without persisted queries and custom cache keys, which is significant additional complexity.

  4. Observability. GET /api/hotels/123 has distinct latency, error rate, and traffic metrics in any standard APM tool. GraphQL's single /graphql endpoint requires custom resolver-level instrumentation to achieve the same visibility.

  5. 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.

  6. 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 /hotels is simpler than mutation CreateHotel(input: CreateHotelInput!) { createHotel(input: $input) { id name } } — less code, less tooling, same result.

Why Not Kill GraphQL Immediately

  1. 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.

  2. 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.

  3. 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-client package generates typed hooks from the OpenAPI spec (e.g., via openapi-typescript + openapi-fetch or 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:

  1. Domain service publishes OpenAPI spec
  2. BFF adds REST client for the domain service
  3. BFF routes calls to domain service instead of monolith GQL
  4. Verify parity (shadow-read if needed)
  5. Remove GQL call from BFF
  6. 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 PermissionSet configurations 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_PRICES without EDIT_AUTOPILOT at 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:

  1. Admin authenticates via Clerk ("Duetto Internal" org)
  2. Admin selects target user + hotel in admin panel
  3. Auth-bridge validates caller has AdminPermission.LOGIN_AS + MANAGE_ALL_COMPANIES
  4. Returns target user's PermissionsResult (not the admin's)
  5. All subsequent API calls carry both identities for audit trail
  6. 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-frontend is 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-frontend routes migrated to Next.js on Vercel
  • [ ] Confirm all users authenticating via Clerk-native (Phase 2 auth)
  • [ ] Remove DualAuthFilter from SecurityConfiguration.java
  • [ ] Remove domain-modules/auth-bridge-api/ and domain-modules/auth-bridge/
  • [ ] Remove DuettoSessionAuthenticationFilter from frontend-core
  • [ ] Remove MongoDB userSession collection dependency (or archive)
  • [ ] Remove CSRF token mechanism from frontend and backend
  • [ ] Remove Clerk JS SDK from legacy duetto-frontend (repo archived)
  • [ ] Update common-boot-auth to 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 for auth-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

  1. Kong vs AWS API Gateway — Need POC to compare latency through VPC connection
  2. User Provisioning — Auto-create Duetto users from Clerk, or require manual linking?
  3. ~~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.
  4. ~~GraphQL vs REST for BLAST~~ — Resolved. REST-forward for new services, GraphQL scoped to legacy and transitional BFF use. See Section 4.5.
  5. Backbone.js page inventory — Need complete mapping of hash routes in the monolith
  6. Node 16 → 20 upgrade — Should this happen before or alongside BLAST migration?
  7. Shared design system — Does the new design system use MUI or a different component library? Consider alignment with Vite-based MFEs.
  8. ~~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.
  1. Secure Vercel Enterprise contract — Confirm Secure Compute and VPC peering setup for staging
  2. Prototype auth-bridge module — Build and test the Clerk↔Duetto JWT exchange in domain-modules/
  3. CloudFront POC — Deploy CloudFront distribution in staging with path-based routing
  4. TourOperator + Onboarding scoping — Define exact routes and APIs these products need on Vercel
  5. Clerk POC — Set up Clerk with pass-through auth against Duetto staging
  6. Vite MFE POC — Evaluate Vite for BLAST microfrontends alongside Next.js Multi-Zones (see Section 7)
  7. Team alignment — Present this research to engineering leads; get buy-in on phasing
  8. 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-frontend React 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.

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 PermissionsResult includes companyPermissions (CompanyPermission feature flags) and hotelPermissions (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 DuettoSessionAuthenticationFilter stays in frontend-core — unchanged
  • The existing SessionTokenManager stays in api — unchanged
  • The existing CompanyPermission, UserPermission, PermissionSet stay in data — 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 AuthBridgeServiceImpl delegates 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

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 architectureduetto-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.gradle auto-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 chain
  • frontend-core/src/main/java/com/duetto/frontend/spring/filter/DuettoSessionAuthenticationFilter.java — Primary auth filter
  • api/src/main/java/com/duetto/api/session/SessionTokenManager.java — Session management
  • api/src/main/java/com/duetto/auth/ — JWT, KMS, Bearer tokens
  • data/src/main/java/com/duetto/model/user/UserPermission.java — 50+ permissions enum
  • data/src/main/java/com/duetto/model/company/Company.java — Tenant entity

duetto-frontend (Frontend)

  • src/server/server-prod.js — Production Express server
  • src/containers/App/App.tsx — Main app shell
  • src/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 monorepo
  • terraform_modules/ — 56+ reusable modules
  • common-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