diff --git a/.github/workflows/integration-test-gha.yaml b/.github/workflows/integration-test-gha.yaml
index 1e2315e21..f7763756f 100644
--- a/.github/workflows/integration-test-gha.yaml
+++ b/.github/workflows/integration-test-gha.yaml
@@ -77,7 +77,7 @@ jobs:
           kubectl wait --for=condition=Ready --timeout=90s pod -l 'app in (cert-manager, webhook)' -n cert-manager
           kubectl apply -k ./deployment/gha
           kubectl wait --for=condition=Ready --timeout=120s pod -l 'app.kubernetes.io/component=controller' -n languagedepot
-          kubectl wait --for=condition=Ready --timeout=120s pod -l 'app in (lexbox, ui, hg, db)' -n languagedepot
+          kubectl wait --for=condition=Ready --timeout=120s pod -l 'app in (lexbox, ui, hg, db, fw-headless)' -n languagedepot
       - name: forward ingress
         run: |
           kubectl port-forward service/ingress-nginx-controller 6579:80 -n languagedepot &
@@ -97,7 +97,6 @@ jobs:
 
       ##playwright tests
       - name: Setup and run playwright tests
-        if: ${{ !cancelled() }}
         uses: ./.github/actions/playwright-tests
         with:
           lexbox-hostname: 'localhost:6579'
@@ -108,7 +107,7 @@ jobs:
         if: failure()
         run: |
           mkdir -p k8s-logs
-          for app in lexbox ui hg db; do
+          for app in lexbox ui hg db fw-headless; do
             kubectl describe pods -l "app=${app}" -n languagedepot > k8s-logs/describe-${app}.txt
             kubectl logs -l "app=${app}" -n languagedepot --prefix --all-containers --tail=-1 > k8s-logs/logs-${app}.txt
           done
diff --git a/backend/FwHeadless/FwHeadlessKernel.cs b/backend/FwHeadless/FwHeadlessKernel.cs
index 3b7f64a74..d567ab6e8 100644
--- a/backend/FwHeadless/FwHeadlessKernel.cs
+++ b/backend/FwHeadless/FwHeadlessKernel.cs
@@ -26,6 +26,10 @@ public static void AddFwHeadless(this IServiceCollection services)
             .AddLcmCrdtClient()
             .AddFwDataBridge()
             .AddFwLiteProjectSync();
+
+        services.AddSingleton<SyncHostedService>();
+        services.AddHostedService(s => s.GetRequiredService<SyncHostedService>());
+
         services.AddScoped<CrdtSyncService>();
         services.AddScoped<ProjectContextFromIdService>();
         services.AddTransient<HttpClientAuthHandler>();
diff --git a/backend/FwHeadless/Program.cs b/backend/FwHeadless/Program.cs
index 213c14382..f8e9b2116 100644
--- a/backend/FwHeadless/Program.cs
+++ b/backend/FwHeadless/Program.cs
@@ -62,71 +62,34 @@
 
 app.MapPost("/api/crdt-sync", ExecuteMergeRequest);
 app.MapGet("/api/crdt-sync-status", GetMergeStatus);
+app.MapGet("/api/await-sync-finished", AwaitSyncFinished);
 
 app.Run();
 
-static async Task<Results<Ok<SyncResult>, NotFound, ProblemHttpResult>> ExecuteMergeRequest(
-    ILogger<Program> logger,
-    IServiceProvider services,
-    SendReceiveService srService,
-    IOptions<FwHeadlessConfig> config,
-    FwDataFactory fwDataFactory,
-    CrdtProjectsService projectsService,
+static async Task<Results<Ok, NotFound, ProblemHttpResult>> ExecuteMergeRequest(
+    SyncHostedService syncHostedService,
     ProjectLookupService projectLookupService,
-    SyncJobStatusService syncStatusService,
-    CrdtFwdataProjectSyncService syncService,
+    ILogger<Program> logger,
     CrdtHttpSyncService crdtHttpSyncService,
     IHttpClientFactory httpClientFactory,
-    Guid projectId,
-    bool dryRun = false)
+    Guid projectId)
 {
-    logger.LogInformation("About to execute sync request for {projectId}", projectId);
-    if (dryRun)
-    {
-        logger.LogInformation("Dry run, not actually syncing");
-        return TypedResults.Ok(new SyncResult(0, 0));
-    }
-
-    syncStatusService.StartSyncing(projectId);
-    using var stopSyncing = Defer.Action(() => syncStatusService.StopSyncing(projectId));
-
     var projectCode = await projectLookupService.GetProjectCode(projectId);
     if (projectCode is null)
     {
         logger.LogError("Project ID {projectId} not found", projectId);
         return TypedResults.NotFound();
     }
+
     logger.LogInformation("Project code is {projectCode}", projectCode);
     //if we can't sync with lexbox fail fast
     if (!await crdtHttpSyncService.TestAuth(httpClientFactory.CreateClient(FwHeadlessKernel.LexboxHttpClientName)))
     {
+        logger.LogError("Unable to authenticate with Lexbox");
         return TypedResults.Problem("Unable to authenticate with Lexbox");
     }
-
-    var projectFolder = Path.Join(config.Value.ProjectStorageRoot, $"{projectCode}-{projectId}");
-    if (!Directory.Exists(projectFolder)) Directory.CreateDirectory(projectFolder);
-
-    var crdtFile = Path.Join(projectFolder, "crdt.sqlite");
-
-    var fwDataProject = new FwDataProject("fw", projectFolder);
-    logger.LogDebug("crdtFile: {crdtFile}", crdtFile);
-    logger.LogDebug("fwDataFile: {fwDataFile}", fwDataProject.FilePath);
-
-    var fwdataApi = await SetupFwData(fwDataProject, srService, projectCode, logger, fwDataFactory);
-    using var deferCloseFwData = fwDataFactory.DeferClose(fwDataProject);
-    var crdtProject = await SetupCrdtProject(crdtFile, projectLookupService, projectId, projectsService, projectFolder, fwdataApi.ProjectId, config.Value.LexboxUrl);
-
-    var miniLcmApi = await services.OpenCrdtProject(crdtProject);
-    var crdtSyncService = services.GetRequiredService<CrdtSyncService>();
-    await crdtSyncService.Sync();
-
-    var result = await syncService.Sync(miniLcmApi, fwdataApi, dryRun);
-    logger.LogInformation("Sync result, CrdtChanges: {CrdtChanges}, FwdataChanges: {FwdataChanges}", result.CrdtChanges, result.FwdataChanges);
-
-    await crdtSyncService.Sync();
-    var srResult2 = await srService.SendReceive(fwDataProject, projectCode);
-    logger.LogInformation("Send/Receive result after CRDT sync: {srResult2}", srResult2.Output);
-    return TypedResults.Ok(result);
+    syncHostedService.QueueJob(projectId);
+    return TypedResults.Ok();
 }
 
 static async Task<Results<Ok<ProjectSyncStatus>, NotFound>> GetMergeStatus(
@@ -137,10 +100,12 @@ static async Task<Results<Ok<ProjectSyncStatus>, NotFound>> GetMergeStatus(
     SyncJobStatusService syncJobStatusService,
     IServiceProvider services,
     LexBoxDbContext lexBoxDb,
+    SyncHostedService syncHostedService,
     Guid projectId)
 {
     var jobStatus = syncJobStatusService.SyncStatus(projectId);
     if (jobStatus == SyncJobStatus.Running) return TypedResults.Ok(ProjectSyncStatus.Syncing);
+    if (syncHostedService.IsJobQueuedOrRunning(projectId)) return TypedResults.Ok(ProjectSyncStatus.QueuedToSync);
     var project = projectContext.MaybeProject;
     if (project is null)
     {
@@ -170,53 +135,21 @@ static async Task<Results<Ok<ProjectSyncStatus>, NotFound>> GetMergeStatus(
     return TypedResults.Ok(ProjectSyncStatus.ReadyToSync(pendingCrdtCommits, await pendingHgCommits, lastCrdtCommitDate, lastHgCommitDate));
 }
 
-static async Task<FwDataMiniLcmApi> SetupFwData(FwDataProject fwDataProject,
-    SendReceiveService srService,
-    string projectCode,
-    ILogger<Program> logger,
-    FwDataFactory fwDataFactory)
-{
-    if (File.Exists(fwDataProject.FilePath))
-    {
-        var srResult = await srService.SendReceive(fwDataProject, projectCode);
-        logger.LogInformation("Send/Receive result: {srResult}", srResult.Output);
-    }
-    else
-    {
-        var srResult = await srService.Clone(fwDataProject, projectCode);
-        logger.LogInformation("Send/Receive result: {srResult}", srResult.Output);
-    }
-
-    var fwdataApi = fwDataFactory.GetFwDataMiniLcmApi(fwDataProject, true);
-    return fwdataApi;
-}
-
-static async Task<CrdtProject> SetupCrdtProject(string crdtFile,
-    ProjectLookupService projectLookupService,
-    Guid projectId,
-    CrdtProjectsService projectsService,
-    string projectFolder,
-    Guid fwProjectId,
-    string lexboxUrl)
+static async Task<Results<Ok<SyncJobResult>, NotFound, StatusCodeHttpResult>> AwaitSyncFinished(
+    SyncHostedService syncHostedService,
+    SyncJobStatusService syncJobStatusService,
+    CancellationToken cancellationToken,
+    Guid projectId)
 {
-    if (File.Exists(crdtFile))
+    if (!syncHostedService.IsJobQueuedOrRunning(projectId)) return TypedResults.NotFound();
+    try
     {
-        return new CrdtProject("crdt", crdtFile);
+        var result = await syncHostedService.AwaitSyncFinished(projectId, cancellationToken);
+        if (result is null) return TypedResults.NotFound();
+        return TypedResults.Ok(result);
     }
-    else
+    catch (OperationCanceledException)
     {
-        if (await projectLookupService.IsCrdtProject(projectId))
-        {
-            //todo determine what to do in this case, maybe we just download the project?
-            throw new InvalidOperationException("Project already exists, not sure why it's not on the server");
-        }
-        return await projectsService.CreateProject(new("crdt",
-            SeedNewProjectData: false,
-            Id: projectId,
-            Path: projectFolder,
-            FwProjectId: fwProjectId,
-            Domain: new Uri(lexboxUrl)));
+        return TypedResults.StatusCode(StatusCodes.Status408RequestTimeout);
     }
-
 }
-
diff --git a/backend/FwHeadless/Services/ProjectSyncStatusService.cs b/backend/FwHeadless/Services/ProjectSyncStatusService.cs
index 5edefe115..b856bbd1b 100644
--- a/backend/FwHeadless/Services/ProjectSyncStatusService.cs
+++ b/backend/FwHeadless/Services/ProjectSyncStatusService.cs
@@ -8,12 +8,12 @@ public class SyncJobStatusService()
 
     public void StartSyncing(Guid projectId)
     {
-        Status.AddOrUpdate(projectId, (_) => SyncJobStatus.Running, (_, _) => SyncJobStatus.Running);
+        Status.AddOrUpdate(projectId, SyncJobStatus.Running, (_, _) => SyncJobStatus.Running);
     }
 
     public void StopSyncing(Guid projectId)
     {
-        Status.Remove(projectId, out var _);
+        Status.Remove(projectId, out _);
     }
 
     public SyncJobStatus SyncStatus(Guid projectId)
diff --git a/backend/FwHeadless/Services/SyncHostedService.cs b/backend/FwHeadless/Services/SyncHostedService.cs
new file mode 100644
index 000000000..ff5ce9bf6
--- /dev/null
+++ b/backend/FwHeadless/Services/SyncHostedService.cs
@@ -0,0 +1,199 @@
+using System.Collections.Concurrent;
+using System.Threading.Channels;
+using FwDataMiniLcmBridge;
+using FwDataMiniLcmBridge.Api;
+using FwLiteProjectSync;
+using LcmCrdt;
+using LcmCrdt.RemoteSync;
+using LexCore.Sync;
+using LexCore.Utils;
+using Microsoft.Extensions.Options;
+
+namespace FwHeadless.Services;
+
+public class SyncHostedService(IServiceProvider services, ILogger<SyncHostedService> logger) : BackgroundService
+{
+    private readonly Channel<Guid> _projectsToSync = Channel.CreateUnbounded<Guid>();
+    private readonly ConcurrentDictionary<Guid, TaskCompletionSource<SyncJobResult>> _projectsQueuedOrRunning = new();
+
+    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+    {
+        await foreach (var projectId in _projectsToSync.Reader.ReadAllAsync(stoppingToken))
+        {
+            await using var scope = services.CreateAsyncScope();
+            var syncWorker = ActivatorUtilities.CreateInstance<SyncWorker>(scope.ServiceProvider, projectId);
+            SyncJobResult result;
+            try
+            {
+                result = await syncWorker.Execute(stoppingToken);
+                logger.LogInformation("Sync job result: {Result}", result);
+            }
+            catch (Exception e)
+            {
+                logger.LogError(e, "Sync job failed");
+                result = new SyncJobResult(SyncJobResultEnum.UnknownError, e.Message);
+            }
+            _projectsQueuedOrRunning.TryRemove(projectId, out var tcs);
+            tcs?.TrySetResult(result);
+        }
+    }
+
+    public bool IsJobQueuedOrRunning(Guid projectId)
+    {
+        return _projectsQueuedOrRunning.ContainsKey(projectId);
+    }
+
+    public async Task<SyncJobResult?> AwaitSyncFinished(Guid projectId, CancellationToken cancellationToken)
+    {
+        if (_projectsQueuedOrRunning.TryGetValue(projectId, out var tcs))
+            return await tcs.Task.WaitAsync(cancellationToken);
+
+        return null;
+    }
+
+    public bool QueueJob(Guid projectId)
+    {
+        //will only queue job if it's not already queued
+        var addedToQueue = _projectsQueuedOrRunning.TryAdd(projectId, new());
+        if (addedToQueue)
+        {
+            if (!_projectsToSync.Writer.TryWrite(projectId))
+            {
+                logger.LogError("Failed to queue sync job for project {ProjectId}, the channel is full", projectId);
+                _projectsQueuedOrRunning.TryRemove(projectId, out _);
+                return false;
+            }
+
+            logger.LogInformation("Queued sync job for project {ProjectId}", projectId);
+        }
+        else
+        {
+            logger.LogInformation("Project {ProjectId} is already queued", projectId);
+        }
+
+        return addedToQueue;
+    }
+}
+
+public class SyncWorker(
+    Guid projectId,
+    ILogger<SyncWorker> logger,
+    IServiceProvider services,
+    SendReceiveService srService,
+    IOptions<FwHeadlessConfig> config,
+    FwDataFactory fwDataFactory,
+    CrdtProjectsService projectsService,
+    ProjectLookupService projectLookupService,
+    SyncJobStatusService syncStatusService,
+    CrdtFwdataProjectSyncService syncService,
+    CrdtHttpSyncService crdtHttpSyncService,
+    IHttpClientFactory httpClientFactory
+)
+{
+    public async Task<SyncJobResult> Execute(CancellationToken stoppingToken)
+    {
+        logger.LogInformation("About to execute sync request for {projectId}", projectId);
+
+        syncStatusService.StartSyncing(projectId);
+        using var stopSyncing = Defer.Action(() => syncStatusService.StopSyncing(projectId));
+
+        var projectCode = await projectLookupService.GetProjectCode(projectId);
+        if (projectCode is null)
+        {
+            logger.LogError("Project ID {projectId} not found", projectId);
+            return new SyncJobResult(SyncJobResultEnum.ProjectNotFound, $"Project {projectId} not found");
+        }
+
+        logger.LogInformation("Project code is {projectCode}", projectCode);
+        //if we can't sync with lexbox fail fast
+        if (!await crdtHttpSyncService.TestAuth(httpClientFactory.CreateClient(FwHeadlessKernel.LexboxHttpClientName)))
+        {
+            logger.LogError("Unable to authenticate with Lexbox");
+            return new SyncJobResult(SyncJobResultEnum.UnableToAuthenticate, "Unable to authenticate with Lexbox");
+        }
+
+        var projectFolder = Path.Join(config.Value.ProjectStorageRoot, $"{projectCode}-{projectId}");
+        if (!Directory.Exists(projectFolder)) Directory.CreateDirectory(projectFolder);
+
+        var crdtFile = Path.Join(projectFolder, "crdt.sqlite");
+
+        var fwDataProject = new FwDataProject("fw", projectFolder);
+        logger.LogDebug("crdtFile: {crdtFile}", crdtFile);
+        logger.LogDebug("fwDataFile: {fwDataFile}", fwDataProject.FilePath);
+
+        var fwdataApi = await SetupFwData(fwDataProject, srService, projectCode, logger, fwDataFactory);
+        using var deferCloseFwData = fwDataFactory.DeferClose(fwDataProject);
+        var crdtProject = await SetupCrdtProject(crdtFile,
+            projectLookupService,
+            projectId,
+            projectsService,
+            projectFolder,
+            fwdataApi.ProjectId,
+            config.Value.LexboxUrl);
+
+        var miniLcmApi = await services.OpenCrdtProject(crdtProject);
+        var crdtSyncService = services.GetRequiredService<CrdtSyncService>();
+        await crdtSyncService.Sync();
+
+        var result = await syncService.Sync(miniLcmApi, fwdataApi);
+        logger.LogInformation("Sync result, CrdtChanges: {CrdtChanges}, FwdataChanges: {FwdataChanges}",
+            result.CrdtChanges,
+            result.FwdataChanges);
+
+        await crdtSyncService.Sync();
+        var srResult2 = await srService.SendReceive(fwDataProject, projectCode);
+        logger.LogInformation("Send/Receive result after CRDT sync: {srResult2}", srResult2.Output);
+        return new SyncJobResult(SyncJobResultEnum.Success, null, result);
+    }
+
+    static async Task<FwDataMiniLcmApi> SetupFwData(FwDataProject fwDataProject,
+        SendReceiveService srService,
+        string projectCode,
+        ILogger logger,
+        FwDataFactory fwDataFactory)
+    {
+        if (File.Exists(fwDataProject.FilePath))
+        {
+            var srResult = await srService.SendReceive(fwDataProject, projectCode);
+            logger.LogInformation("Send/Receive result: {srResult}", srResult.Output);
+        }
+        else
+        {
+            var srResult = await srService.Clone(fwDataProject, projectCode);
+            logger.LogInformation("Send/Receive result: {srResult}", srResult.Output);
+        }
+
+        var fwdataApi = fwDataFactory.GetFwDataMiniLcmApi(fwDataProject, true);
+        return fwdataApi;
+    }
+
+    static async Task<CrdtProject> SetupCrdtProject(string crdtFile,
+        ProjectLookupService projectLookupService,
+        Guid projectId,
+        CrdtProjectsService projectsService,
+        string projectFolder,
+        Guid fwProjectId,
+        string lexboxUrl)
+    {
+        if (File.Exists(crdtFile))
+        {
+            return new CrdtProject("crdt", crdtFile);
+        }
+        else
+        {
+            if (await projectLookupService.IsCrdtProject(projectId))
+            {
+                //todo determine what to do in this case, maybe we just download the project?
+                throw new InvalidOperationException("Project already exists, not sure why it's not on the server");
+            }
+
+            return await projectsService.CreateProject(new("crdt",
+                SeedNewProjectData: false,
+                Id: projectId,
+                Path: projectFolder,
+                FwProjectId: fwProjectId,
+                Domain: new Uri(lexboxUrl)));
+        }
+
+    }
+}
diff --git a/backend/FwHeadless/appsettings.json b/backend/FwHeadless/appsettings.json
index 07fe8e9d0..784d06fbf 100644
--- a/backend/FwHeadless/appsettings.json
+++ b/backend/FwHeadless/appsettings.json
@@ -6,7 +6,7 @@
     "LogLevel": {
       "Default": "Information",
       "Microsoft.AspNetCore": "Information",
-      "SendReceive": "Debug"
+      "SendReceive": "Information"
     }
   },
   "AllowedHosts": "*"
diff --git a/backend/LexBoxApi/Auth/Attributes/FeatureFlagRequiredAttribute.cs b/backend/LexBoxApi/Auth/Attributes/FeatureFlagRequiredAttribute.cs
new file mode 100644
index 000000000..4907052a0
--- /dev/null
+++ b/backend/LexBoxApi/Auth/Attributes/FeatureFlagRequiredAttribute.cs
@@ -0,0 +1,17 @@
+using LexCore;
+using Microsoft.AspNetCore.Authorization;
+
+namespace LexBoxApi.Auth.Attributes;
+
+[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
+public class FeatureFlagRequiredAttribute(FeatureFlag flag): LexboxAuthAttribute(PolicyName), IAuthorizationRequirement, IAuthorizationRequirementData
+{
+    public const string PolicyName = "FeatureFlagRequired";
+    public bool AllowAdmin { get; set; } = false;
+    public FeatureFlag Flag => flag;
+
+    public IEnumerable<IAuthorizationRequirement> GetRequirements()
+    {
+        yield return this;
+    }
+}
diff --git a/backend/LexBoxApi/Auth/AuthKernel.cs b/backend/LexBoxApi/Auth/AuthKernel.cs
index 038da232b..380ed9256 100644
--- a/backend/LexBoxApi/Auth/AuthKernel.cs
+++ b/backend/LexBoxApi/Auth/AuthKernel.cs
@@ -41,6 +41,7 @@ public static void AddLexBoxAuth(IServiceCollection services,
         services.AddScoped<LexAuthService>();
         services.AddSingleton<IAuthorizationHandler, AudienceRequirementHandler>();
         services.AddSingleton<IAuthorizationHandler, ValidateUserUpdatedHandler>();
+        services.AddSingleton<IAuthorizationHandler, FeatureFlagRequirementHandler>();
         services.AddAuthorization(options =>
         {
             //fallback policy is used when there's no auth attribute.
@@ -54,6 +55,7 @@ public static void AddLexBoxAuth(IServiceCollection services,
             options.AddPolicy(AllowAnyAudienceAttribute.PolicyName, builder => builder.RequireAuthenticatedUser());
             //we still need this policy, without it the default policy is used which requires the default audience
             options.AddPolicy(RequireAudienceAttribute.PolicyName, builder => builder.RequireAuthenticatedUser());
+            options.AddPolicy(FeatureFlagRequiredAttribute.PolicyName, builder => builder.RequireAuthenticatedUser());
 
             options.AddPolicy(AdminRequiredAttribute.PolicyName,
                 builder => builder.RequireDefaultLexboxAuth()
diff --git a/backend/LexBoxApi/Auth/Requirements/FeatureFlagRequirementHandler.cs b/backend/LexBoxApi/Auth/Requirements/FeatureFlagRequirementHandler.cs
new file mode 100644
index 000000000..26ce438e1
--- /dev/null
+++ b/backend/LexBoxApi/Auth/Requirements/FeatureFlagRequirementHandler.cs
@@ -0,0 +1,42 @@
+using System.Security.Claims;
+using LexBoxApi.Auth.Attributes;
+using LexCore;
+using LexCore.Auth;
+using Microsoft.AspNetCore.Authorization;
+
+namespace LexBoxApi.Auth.Requirements;
+
+public class FeatureFlagRequirementHandler(ILogger<FeatureFlagRequirementHandler> logger): AuthorizationHandler<FeatureFlagRequiredAttribute>
+{
+    protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, FeatureFlagRequiredAttribute requirement)
+    {
+        bool success = false;
+        var isAdmin = context.User.IsInRole(UserRole.admin.ToString());
+        if (isAdmin && requirement.AllowAdmin)
+        {
+            context.Succeed(requirement);
+            return Task.CompletedTask;
+        }
+        foreach (var userFlag in context.User.FindAll(LexAuthConstants.FeatureFlagsClaimType)
+                     .Where(c => Enum.IsDefined(typeof(FeatureFlag), c.Value))
+                     .Select(c => Enum.Parse<FeatureFlag>(c.Value)))
+        {
+            if (requirement.Flag == userFlag)
+            {
+                success = true;
+                break;
+            }
+        }
+
+        if (success)
+        {
+            context.Succeed(requirement);
+        }
+        else
+        {
+            context.Fail(new AuthorizationFailureReason(this, "User does not have the feature flag "  + requirement.Flag));
+            logger.LogError("User does not have the feature flag " + requirement.Flag);
+        }
+        return Task.CompletedTask;
+    }
+}
diff --git a/backend/LexBoxApi/Controllers/AuthTestingController.cs b/backend/LexBoxApi/Controllers/AuthTestingController.cs
index 908ab6289..28e471563 100644
--- a/backend/LexBoxApi/Controllers/AuthTestingController.cs
+++ b/backend/LexBoxApi/Controllers/AuthTestingController.cs
@@ -1,5 +1,6 @@
 using LexBoxApi.Auth;
 using LexBoxApi.Auth.Attributes;
+using LexCore;
 using LexCore.Auth;
 using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Mvc;
@@ -36,4 +37,11 @@ public ForbidResult Forbidden()
     {
         return Forbid();
     }
+
+    [HttpGet("requiresFwBetaFeatureFlag")]
+    [FeatureFlagRequired(FeatureFlag.FwLiteBeta)]
+    public ActionResult RequiresFwBetaFeatureFlag()
+    {
+        return Ok();
+    }
 }
diff --git a/backend/LexBoxApi/Controllers/CrdtController.cs b/backend/LexBoxApi/Controllers/CrdtController.cs
index 4051d2b52..5e47d3eef 100644
--- a/backend/LexBoxApi/Controllers/CrdtController.cs
+++ b/backend/LexBoxApi/Controllers/CrdtController.cs
@@ -4,6 +4,7 @@
 using LexBoxApi.Auth.Attributes;
 using LexBoxApi.Hub;
 using LexBoxApi.Services;
+using LexCore;
 using LexCore.Entities;
 using LexCore.ServiceInterfaces;
 using LexCore.Sync;
@@ -94,14 +95,4 @@ public async Task<ActionResult<Guid>> GetProjectId(string code)
 
         return Ok(projectId);
     }
-
-    [HttpPost("sync/{projectId}")]
-    [AdminRequired]
-    [RequestTimeout(300_000)]//5 minutes
-    public async Task<ActionResult<SyncResult?>> ExecuteMerge(Guid projectId)
-    {
-        var result = await fwHeadlessClient.CrdtSync(projectId);
-        if (result is null) return Problem("Failed to sync CRDT");
-        return result;
-    }
 }
diff --git a/backend/LexBoxApi/Controllers/SyncController.cs b/backend/LexBoxApi/Controllers/SyncController.cs
index a36585f6c..77fb4d039 100644
--- a/backend/LexBoxApi/Controllers/SyncController.cs
+++ b/backend/LexBoxApi/Controllers/SyncController.cs
@@ -1,4 +1,6 @@
+using LexBoxApi.Auth.Attributes;
 using LexBoxApi.Services;
+using LexCore;
 using LexCore.ServiceInterfaces;
 using LexCore.Sync;
 using Microsoft.AspNetCore.Mvc;
@@ -7,6 +9,7 @@ namespace LexBoxApi.Controllers;
 
 [ApiController]
 [Route("/api/fw-lite/sync")]
+[FeatureFlagRequired(FeatureFlag.FwLiteBeta, AllowAdmin = true)]
 [ApiExplorerSettings(GroupName = LexBoxKernel.OpenApiPublicDocumentName)]
 public class SyncController(
     IPermissionService permissionService,
@@ -22,11 +25,20 @@ public async Task<ActionResult<ProjectSyncStatus>> GetSyncStatus(Guid projectId)
     }
 
     [HttpPost("trigger/{projectId}")]
-    public async Task<ActionResult<SyncResult>> TriggerSync(Guid projectId)
+    public async Task<ActionResult> TriggerSync(Guid projectId)
     {
         if (!await permissionService.CanSyncProject(projectId)) return Forbid();
-        var result = await fwHeadlessClient.CrdtSync(projectId);
-        if (result is null) return StatusCode(500); // Apparently there's no InternalServerError()? Weird.
-        return Ok(result);
+        var started = await fwHeadlessClient.CrdtSync(projectId);
+        if (!started) return Problem("Failed to sync CRDT");
+        return Ok();
+    }
+
+    [HttpGet("await-sync-finished/{projectId}")]
+    public async Task<ActionResult<SyncResult>> AwaitSyncFinished(Guid projectId)
+    {
+        await permissionService.AssertCanSyncProject(projectId);
+        var result = await fwHeadlessClient.AwaitStatus(projectId);
+        if (result is null) return Problem("Failed to get sync status");
+        return result;
     }
 }
diff --git a/backend/LexBoxApi/Controllers/TestingController.cs b/backend/LexBoxApi/Controllers/TestingController.cs
index 93d91bfa5..4e888f7b2 100644
--- a/backend/LexBoxApi/Controllers/TestingController.cs
+++ b/backend/LexBoxApi/Controllers/TestingController.cs
@@ -2,6 +2,7 @@
 using LexBoxApi.Auth.Attributes;
 using LexBoxApi.Services;
 using LexCore.Auth;
+using LexCore.Entities;
 using LexCore.Exceptions;
 using LexCore.ServiceInterfaces;
 using LexData;
@@ -18,7 +19,9 @@ public class TestingController(
     LexAuthService lexAuthService,
     LexBoxDbContext lexBoxDbContext,
     IHgService hgService,
-    SeedingData seedingData)
+    SeedingData seedingData,
+    ProjectService projectService,
+    LoggedInContext loggedInContext)
     : ControllerBase
 {
 #if DEBUG
@@ -67,6 +70,24 @@ public ActionResult DebugConfiguration()
 
 #endif
 
+    [HttpPost("copyToNewProject")]
+    [AdminRequired]
+    public async Task<ActionResult<Guid>> CopyToNewProject(string newProjectCode, string existingProjectCode)
+    {
+        var id = Guid.NewGuid();
+        await projectService.CreateProject(new(id,
+            newProjectCode,
+            "Copy of " + existingProjectCode,
+            newProjectCode,
+            ProjectType.FLEx,
+            RetentionPolicy.Dev,
+            true,
+            loggedInContext.User.Id,
+            null));
+        await hgService.CopyRepo(existingProjectCode, newProjectCode);
+        return Ok(id);
+    }
+
     [HttpPost("seedDatabase")]
     [AdminRequired]
     public async Task<ActionResult> SeedDatabase()
diff --git a/backend/LexBoxApi/GraphQL/CustomTypes/IsHarmonyProjectDataLoader.cs b/backend/LexBoxApi/GraphQL/CustomTypes/IsHarmonyProjectDataLoader.cs
new file mode 100644
index 000000000..b4a67bdbe
--- /dev/null
+++ b/backend/LexBoxApi/GraphQL/CustomTypes/IsHarmonyProjectDataLoader.cs
@@ -0,0 +1,23 @@
+using LexCore.ServiceInterfaces;
+using LexData;
+using Microsoft.EntityFrameworkCore;
+using SIL.Harmony.Core;
+
+namespace LexBoxApi.GraphQL.CustomTypes;
+
+public class IsHarmonyProjectDataLoader(
+    LexBoxDbContext dbContext,
+    IBatchScheduler batchScheduler,
+    DataLoaderOptions options)
+    : BatchDataLoader<Guid, bool>(batchScheduler, options), IIsHarmonyProjectDataLoader
+{
+    protected override async Task<IReadOnlyDictionary<Guid, bool>> LoadBatchAsync(IReadOnlyList<Guid> keys, CancellationToken cancellationToken)
+    {
+        var isHarmonyProject = await dbContext.Set<ServerCommit>()
+            .Where(c => keys.Contains(c.ProjectId))
+            .Select(c => c.ProjectId)
+            .Distinct()
+            .ToArrayAsync(cancellationToken);
+        return keys.ToDictionary(k => k, k => isHarmonyProject.Contains(k));
+    }
+}
diff --git a/backend/LexBoxApi/GraphQL/GraphQlSetupKernel.cs b/backend/LexBoxApi/GraphQL/GraphQlSetupKernel.cs
index 97d7585a0..44a5fd8af 100644
--- a/backend/LexBoxApi/GraphQL/GraphQlSetupKernel.cs
+++ b/backend/LexBoxApi/GraphQL/GraphQlSetupKernel.cs
@@ -1,8 +1,13 @@
 using DataAnnotatedModelValidations;
 using HotChocolate.Diagnostics;
 using LexBoxApi.GraphQL.CustomFilters;
+using LexBoxApi.GraphQL.CustomTypes;
 using LexBoxApi.Services;
+using LexCore.ServiceInterfaces;
 using LexData;
+using LfClassicData;
+using Microsoft.Extensions.Options;
+using Polly;
 
 namespace LexBoxApi.GraphQL;
 
@@ -15,6 +20,18 @@ public static void AddLexGraphQL(this IServiceCollection services, IHostEnvironm
         if (forceGenerateSchema || env.IsDevelopment())
             services.AddHostedService<DevGqlSchemaWriterService>();
 
+        services.AddScoped<IIsHarmonyProjectDataLoader, IsHarmonyProjectDataLoader>();
+        services.AddScoped<IIsLanguageForgeProjectDataLoader, IsLanguageForgeProjectDataLoader>();
+        services.AddResiliencePipeline<string, IReadOnlyDictionary<string, bool>>(
+            IsLanguageForgeProjectDataLoader.ResiliencePolicyName,
+            (builder, context) =>
+            {
+                builder.ConfigureTelemetry(context.ServiceProvider.GetRequiredService<ILoggerFactory>());
+                IsLanguageForgeProjectDataLoader.ConfigureResiliencePipeline(builder,
+                    context.ServiceProvider.GetRequiredService<IOptions<LfClassicConfig>>().Value
+                        .IsLfProjectConnectionRetryTimeout);
+            });
+
         services
             .AddGraphQLServer()
             .ModifyCostOptions(options =>
diff --git a/backend/LexBoxApi/LexBoxKernel.cs b/backend/LexBoxApi/LexBoxKernel.cs
index ebfedd429..00eecd6f3 100644
--- a/backend/LexBoxApi/LexBoxKernel.cs
+++ b/backend/LexBoxApi/LexBoxKernel.cs
@@ -71,12 +71,6 @@ public static void AddLexBoxApi(this IServiceCollection services,
         services.AddHostedService<HgService>();
         services.AddTransient<HgWebHealthCheck>();
         services.AddTransient<FwHeadlessHealthCheck>();
-        services.AddScoped<IIsLanguageForgeProjectDataLoader, IsLanguageForgeProjectDataLoader>();
-        services.AddResiliencePipeline<string, IReadOnlyDictionary<string, bool>>(IsLanguageForgeProjectDataLoader.ResiliencePolicyName, (builder, context) =>
-        {
-            builder.ConfigureTelemetry(context.ServiceProvider.GetRequiredService<ILoggerFactory>());
-            IsLanguageForgeProjectDataLoader.ConfigureResiliencePipeline(builder, context.ServiceProvider.GetRequiredService<IOptions<LfClassicConfig>>().Value.IsLfProjectConnectionRetryTimeout);
-        });
         services.AddScoped<ILexProxyService, LexProxyService>();
         services.AddSingleton<ISendReceiveService, SendReceiveService>();
         services.AddSingleton<LexboxLinkGenerator>();
diff --git a/backend/LexBoxApi/Services/DevGqlSchemaWriterService.cs b/backend/LexBoxApi/Services/DevGqlSchemaWriterService.cs
index c0b77997e..fad0a0d33 100644
--- a/backend/LexBoxApi/Services/DevGqlSchemaWriterService.cs
+++ b/backend/LexBoxApi/Services/DevGqlSchemaWriterService.cs
@@ -28,7 +28,6 @@ public static async Task GenerateGqlSchema(string[] args)
             .AddSingleton<IHostLifetime, ConsoleLifetime>()
             .AddScoped<IEmailService, EmailService>()
             .AddScoped<IHgService, HgService>()
-            .AddScoped<IIsLanguageForgeProjectDataLoader, IsLanguageForgeProjectDataLoader>()
             .AddScoped<LoggedInContext>()
             .AddScoped<LexBoxDbContext>()
             .AddScoped<IPermissionService, PermissionService>()
diff --git a/backend/LexBoxApi/Services/FwHeadlessClient.cs b/backend/LexBoxApi/Services/FwHeadlessClient.cs
index 06a82e257..50b5322e6 100644
--- a/backend/LexBoxApi/Services/FwHeadlessClient.cs
+++ b/backend/LexBoxApi/Services/FwHeadlessClient.cs
@@ -4,16 +4,42 @@ namespace LexBoxApi.Services;
 
 public class FwHeadlessClient(HttpClient httpClient, ILogger<FwHeadlessClient> logger)
 {
-    public async Task<SyncResult?> CrdtSync(Guid projectId)
+    public async Task<bool> CrdtSync(Guid projectId)
     {
         var response = await httpClient.PostAsync($"/api/crdt-sync?projectId={projectId}", null);
         if (response.IsSuccessStatusCode)
-            return await response.Content.ReadFromJsonAsync<SyncResult>();
-        logger.LogError("Failed to sync CRDT: {StatusCode} {StatusDescription}, projectId: {ProjectId}, response: {Response}",
+            return true;
+        logger.LogError("Failed to sync CRDT: {StatusCode} {StatusDescription}, projectId: {ProjectId}",
             response.StatusCode,
             response.ReasonPhrase,
-            projectId,
-            await response.Content.ReadAsStringAsync());
+            projectId);
+        return false;
+    }
+
+    public async Task<SyncResult?> AwaitStatus(Guid projectId)
+    {
+        var response = await httpClient.GetAsync($"/api/await-sync-finished?projectId={projectId}");
+        if (!response.IsSuccessStatusCode)
+        {
+            logger.LogError("Failed to get sync status: {StatusCode} {StatusDescription}, projectId: {ProjectId}, response: {Response}",
+                response.StatusCode,
+                response.ReasonPhrase,
+                projectId,
+                await response.Content.ReadAsStringAsync());
+            return null;
+        }
+        var jobResult = await response.Content.ReadFromJsonAsync<SyncJobResult>();
+        if (jobResult is null)
+        {
+            logger.LogError("Failed to get sync status");
+            return null;
+        }
+
+        if (jobResult.Result == SyncJobResultEnum.Success)
+        {
+            return jobResult.SyncResult;
+        }
+        logger.LogError("Sync failed: {JobResult}", jobResult);
         return null;
     }
 
diff --git a/backend/LexBoxApi/Services/HgService.cs b/backend/LexBoxApi/Services/HgService.cs
index 2c9b45b6a..f5b125b36 100644
--- a/backend/LexBoxApi/Services/HgService.cs
+++ b/backend/LexBoxApi/Services/HgService.cs
@@ -78,6 +78,36 @@ private void InitRepoAt(DirectoryInfo repoDirectory)
         );
     }
 
+    /// <summary>
+    /// danger, this will replace the repo at the destination with the source
+    /// </summary>
+    public async Task CopyRepo(ProjectCode sourceCode, ProjectCode destCode)
+    {
+        var sourceFolder = new DirectoryInfo(PrefixRepoFilePath(sourceCode));
+        if (!sourceFolder.Exists)
+        {
+            throw new DirectoryNotFoundException($"Source repo {sourceCode} not found");
+        }
+        var repoDirectory = new DirectoryInfo(PrefixRepoFilePath(destCode));
+        if (repoDirectory.Exists && (await GetTipHash(destCode) != AllZeroHash))
+        {
+            throw new InvalidOperationException($"Destination repo {destCode} already exists and is not empty");
+        }
+
+        await Task.Run(() =>
+            {
+                if (repoDirectory.Exists) repoDirectory.Delete(true);
+                repoDirectory.Create();
+                FileUtils.CopyFilesRecursively(
+                    sourceFolder,
+                    repoDirectory,
+                    Permissions
+                );
+            }
+        );
+        await InvalidateDirCache(destCode);
+    }
+
     public async Task DeleteRepo(ProjectCode code)
     {
         await Task.Run(() => Directory.Delete(PrefixRepoFilePath(code), true));
diff --git a/backend/LexBoxApi/appsettings.json b/backend/LexBoxApi/appsettings.json
index 1f224fced..99513b61f 100644
--- a/backend/LexBoxApi/appsettings.json
+++ b/backend/LexBoxApi/appsettings.json
@@ -73,7 +73,7 @@
   },
   "Services": {
     "fwHeadless": {
-      "http": ["fw-headless"]
+      "http": ["fw-headless:8081"]
     }
   },
   "FwLiteRelease": {
diff --git a/backend/LexCore/Entities/Project.cs b/backend/LexCore/Entities/Project.cs
index f3b56e713..d8597ce17 100644
--- a/backend/LexCore/Entities/Project.cs
+++ b/backend/LexCore/Entities/Project.cs
@@ -61,6 +61,15 @@ public Task<bool> GetIsLanguageForgeProject(IIsLanguageForgeProjectDataLoader lo
         }
         return Task.FromResult(false);
     }
+
+    public Task<bool> GetHasHarmonyCommits(IIsHarmonyProjectDataLoader loader)
+    {
+        if (Type is ProjectType.Unknown or ProjectType.FLEx)
+        {
+            return loader.LoadAsync(Id);
+        }
+        return Task.FromResult(false);
+    }
 }
 
 public enum ProjectMigrationStatus
diff --git a/backend/LexCore/ServiceInterfaces/IHgService.cs b/backend/LexCore/ServiceInterfaces/IHgService.cs
index 28cc78723..cf427655b 100644
--- a/backend/LexCore/ServiceInterfaces/IHgService.cs
+++ b/backend/LexCore/ServiceInterfaces/IHgService.cs
@@ -7,6 +7,7 @@ public record BackupExecutor(Func<Stream, CancellationToken, Task> ExecuteBackup
 public interface IHgService
 {
     Task InitRepo(ProjectCode code);
+    Task CopyRepo(ProjectCode sourceCode, ProjectCode destCode);
     Task<DateTimeOffset?> GetLastCommitTimeFromHg(ProjectCode projectCode);
     Task<Changeset[]> GetChangesets(ProjectCode projectCode);
     Task<ProjectType> DetermineProjectType(ProjectCode projectCode);
diff --git a/backend/LexCore/ServiceInterfaces/IIsHarmonyProjectDataLoader.cs b/backend/LexCore/ServiceInterfaces/IIsHarmonyProjectDataLoader.cs
new file mode 100644
index 000000000..5c6fa2bbd
--- /dev/null
+++ b/backend/LexCore/ServiceInterfaces/IIsHarmonyProjectDataLoader.cs
@@ -0,0 +1,6 @@
+namespace LexCore.ServiceInterfaces;
+
+public interface IIsHarmonyProjectDataLoader
+{
+    Task<bool> LoadAsync(Guid projectId, CancellationToken cancellationToken = default);
+}
diff --git a/backend/LexCore/Sync/SyncJobResult.cs b/backend/LexCore/Sync/SyncJobResult.cs
new file mode 100644
index 000000000..a46294e87
--- /dev/null
+++ b/backend/LexCore/Sync/SyncJobResult.cs
@@ -0,0 +1,14 @@
+namespace LexCore.Sync;
+
+public record SyncJobResult(SyncJobResultEnum Result, string? Error, SyncResult? SyncResult = null);
+
+public enum SyncJobResultEnum
+{
+    Success,
+    ProjectNotFound,
+    UnableToAuthenticate,
+    UnableToSync,
+    CrdtSyncFailed,
+    SendReceiveFailed,
+    UnknownError,
+}
diff --git a/backend/LexCore/Sync/SyncStatus.cs b/backend/LexCore/Sync/SyncStatus.cs
index 5ecd2a865..9b3c76ff9 100644
--- a/backend/LexCore/Sync/SyncStatus.cs
+++ b/backend/LexCore/Sync/SyncStatus.cs
@@ -5,17 +5,19 @@ public enum ProjectSyncStatusEnum
     NeverSynced,
     ReadyToSync,
     Syncing,
+    QueuedToSync
 }
 
 public record ProjectSyncStatus(
     ProjectSyncStatusEnum status,
-    int PendingCrdtChanges,
-    int PendingMercurialChanges, // Will be -1 if there is no clone yet; this means "all the commits, but we don't know how many there will be"
-    DateTimeOffset? LastCrdtCommitDate,
-    DateTimeOffset? LastMercurialCommitDate)
+    int PendingCrdtChanges = 0,
+    int PendingMercurialChanges = 0, // Will be -1 if there is no clone yet; this means "all the commits, but we don't know how many there will be"
+    DateTimeOffset? LastCrdtCommitDate = null,
+    DateTimeOffset? LastMercurialCommitDate = null)
 {
-    public static ProjectSyncStatus NeverSynced => new(ProjectSyncStatusEnum.NeverSynced, 0, 0, null, null);
-    public static ProjectSyncStatus Syncing => new(ProjectSyncStatusEnum.Syncing, 0, 0, null, null);
+    public static ProjectSyncStatus NeverSynced => new(ProjectSyncStatusEnum.NeverSynced);
+    public static ProjectSyncStatus Syncing => new(ProjectSyncStatusEnum.Syncing);
+    public static ProjectSyncStatus QueuedToSync => new(ProjectSyncStatusEnum.QueuedToSync);
     public static ProjectSyncStatus ReadyToSync(
         int pendingCrdtChanges,
         int pendingMercurialChanges,
diff --git a/backend/LexData/Entities/CommitEntityConfiguration.cs b/backend/LexData/Entities/CommitEntityConfiguration.cs
index 953395476..b8795aa9e 100644
--- a/backend/LexData/Entities/CommitEntityConfiguration.cs
+++ b/backend/LexData/Entities/CommitEntityConfiguration.cs
@@ -16,8 +16,8 @@ public void Configure(EntityTypeBuilder<ServerCommit> builder)
         builder.ToTable("CrdtCommits");
         builder.HasKey(c => c.Id);
         builder.ComplexProperty(c => c.HybridDateTime);
-        builder.HasOne<FlexProjectMetadata>().WithMany()
-            .HasPrincipalKey(f => f.ProjectId)
+        builder.HasOne<Project>().WithMany()
+            .HasPrincipalKey(project => project.Id)
             .HasForeignKey(c => c.ProjectId);
         builder.Property(c => c.Metadata).HasConversion(
             m => JsonSerializer.Serialize(m, (JsonSerializerOptions?)null),
diff --git a/backend/LexData/Migrations/20250114091559_ChangeFkRelationOnCrdtCommitsToPointToProject.Designer.cs b/backend/LexData/Migrations/20250114091559_ChangeFkRelationOnCrdtCommitsToPointToProject.Designer.cs
new file mode 100644
index 000000000..f6550f4e4
--- /dev/null
+++ b/backend/LexData/Migrations/20250114091559_ChangeFkRelationOnCrdtCommitsToPointToProject.Designer.cs
@@ -0,0 +1,1406 @@
+// <auto-generated />
+using System;
+using System.Collections.Generic;
+using LexData;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace LexData.Migrations
+{
+    [DbContext(typeof(LexBoxDbContext))]
+    [Migration("20250114091559_ChangeFkRelationOnCrdtCommitsToPointToProject")]
+    partial class ChangeFkRelationOnCrdtCommitsToPointToProject
+    {
+        /// <inheritdoc />
+        protected override void BuildTargetModel(ModelBuilder modelBuilder)
+        {
+#pragma warning disable 612, 618
+            modelBuilder
+                .HasAnnotation("Npgsql:CollationDefinition:case_insensitive", "und-u-ks-level2,und-u-ks-level2,icu,False")
+                .HasAnnotation("ProductVersion", "8.0.11")
+                .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+            NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+            modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzBlobTrigger", b =>
+                {
+                    b.Property<string>("SchedulerName")
+                        .HasColumnType("text")
+                        .HasColumnName("sched_name");
+
+                    b.Property<string>("TriggerName")
+                        .HasColumnType("text")
+                        .HasColumnName("trigger_name");
+
+                    b.Property<string>("TriggerGroup")
+                        .HasColumnType("text")
+                        .HasColumnName("trigger_group");
+
+                    b.Property<byte[]>("BlobData")
+                        .HasColumnType("bytea")
+                        .HasColumnName("blob_data");
+
+                    b.HasKey("SchedulerName", "TriggerName", "TriggerGroup");
+
+                    b.ToTable("qrtz_blob_triggers", "quartz");
+                });
+
+            modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzCalendar", b =>
+                {
+                    b.Property<string>("SchedulerName")
+                        .HasColumnType("text")
+                        .HasColumnName("sched_name");
+
+                    b.Property<string>("CalendarName")
+                        .HasColumnType("text")
+                        .HasColumnName("calendar_name");
+
+                    b.Property<byte[]>("Calendar")
+                        .IsRequired()
+                        .HasColumnType("bytea")
+                        .HasColumnName("calendar");
+
+                    b.HasKey("SchedulerName", "CalendarName");
+
+                    b.ToTable("qrtz_calendars", "quartz");
+                });
+
+            modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzCronTrigger", b =>
+                {
+                    b.Property<string>("SchedulerName")
+                        .HasColumnType("text")
+                        .HasColumnName("sched_name");
+
+                    b.Property<string>("TriggerName")
+                        .HasColumnType("text")
+                        .HasColumnName("trigger_name");
+
+                    b.Property<string>("TriggerGroup")
+                        .HasColumnType("text")
+                        .HasColumnName("trigger_group");
+
+                    b.Property<string>("CronExpression")
+                        .IsRequired()
+                        .HasColumnType("text")
+                        .HasColumnName("cron_expression");
+
+                    b.Property<string>("TimeZoneId")
+                        .HasColumnType("text")
+                        .HasColumnName("time_zone_id");
+
+                    b.HasKey("SchedulerName", "TriggerName", "TriggerGroup");
+
+                    b.ToTable("qrtz_cron_triggers", "quartz");
+                });
+
+            modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzFiredTrigger", b =>
+                {
+                    b.Property<string>("SchedulerName")
+                        .HasColumnType("text")
+                        .HasColumnName("sched_name");
+
+                    b.Property<string>("EntryId")
+                        .HasColumnType("text")
+                        .HasColumnName("entry_id");
+
+                    b.Property<long>("FiredTime")
+                        .HasColumnType("bigint")
+                        .HasColumnName("fired_time");
+
+                    b.Property<string>("InstanceName")
+                        .IsRequired()
+                        .HasColumnType("text")
+                        .HasColumnName("instance_name");
+
+                    b.Property<bool>("IsNonConcurrent")
+                        .HasColumnType("bool")
+                        .HasColumnName("is_nonconcurrent");
+
+                    b.Property<string>("JobGroup")
+                        .HasColumnType("text")
+                        .HasColumnName("job_group");
+
+                    b.Property<string>("JobName")
+                        .HasColumnType("text")
+                        .HasColumnName("job_name");
+
+                    b.Property<int>("Priority")
+                        .HasColumnType("integer")
+                        .HasColumnName("priority");
+
+                    b.Property<bool?>("RequestsRecovery")
+                        .HasColumnType("bool")
+                        .HasColumnName("requests_recovery");
+
+                    b.Property<long>("ScheduledTime")
+                        .HasColumnType("bigint")
+                        .HasColumnName("sched_time");
+
+                    b.Property<string>("State")
+                        .IsRequired()
+                        .HasColumnType("text")
+                        .HasColumnName("state");
+
+                    b.Property<string>("TriggerGroup")
+                        .IsRequired()
+                        .HasColumnType("text")
+                        .HasColumnName("trigger_group");
+
+                    b.Property<string>("TriggerName")
+                        .IsRequired()
+                        .HasColumnType("text")
+                        .HasColumnName("trigger_name");
+
+                    b.HasKey("SchedulerName", "EntryId");
+
+                    b.HasIndex("InstanceName")
+                        .HasDatabaseName("idx_qrtz_ft_trig_inst_name");
+
+                    b.HasIndex("JobGroup")
+                        .HasDatabaseName("idx_qrtz_ft_job_group");
+
+                    b.HasIndex("JobName")
+                        .HasDatabaseName("idx_qrtz_ft_job_name");
+
+                    b.HasIndex("RequestsRecovery")
+                        .HasDatabaseName("idx_qrtz_ft_job_req_recovery");
+
+                    b.HasIndex("TriggerGroup")
+                        .HasDatabaseName("idx_qrtz_ft_trig_group");
+
+                    b.HasIndex("TriggerName")
+                        .HasDatabaseName("idx_qrtz_ft_trig_name");
+
+                    b.HasIndex("SchedulerName", "TriggerName", "TriggerGroup")
+                        .HasDatabaseName("idx_qrtz_ft_trig_nm_gp");
+
+                    b.ToTable("qrtz_fired_triggers", "quartz");
+                });
+
+            modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzJobDetail", b =>
+                {
+                    b.Property<string>("SchedulerName")
+                        .HasColumnType("text")
+                        .HasColumnName("sched_name");
+
+                    b.Property<string>("JobName")
+                        .HasColumnType("text")
+                        .HasColumnName("job_name");
+
+                    b.Property<string>("JobGroup")
+                        .HasColumnType("text")
+                        .HasColumnName("job_group");
+
+                    b.Property<string>("Description")
+                        .HasColumnType("text")
+                        .HasColumnName("description");
+
+                    b.Property<bool>("IsDurable")
+                        .HasColumnType("bool")
+                        .HasColumnName("is_durable");
+
+                    b.Property<bool>("IsNonConcurrent")
+                        .HasColumnType("bool")
+                        .HasColumnName("is_nonconcurrent");
+
+                    b.Property<bool>("IsUpdateData")
+                        .HasColumnType("bool")
+                        .HasColumnName("is_update_data");
+
+                    b.Property<string>("JobClassName")
+                        .IsRequired()
+                        .HasColumnType("text")
+                        .HasColumnName("job_class_name");
+
+                    b.Property<byte[]>("JobData")
+                        .HasColumnType("bytea")
+                        .HasColumnName("job_data");
+
+                    b.Property<bool>("RequestsRecovery")
+                        .HasColumnType("bool")
+                        .HasColumnName("requests_recovery");
+
+                    b.HasKey("SchedulerName", "JobName", "JobGroup");
+
+                    b.HasIndex("RequestsRecovery")
+                        .HasDatabaseName("idx_qrtz_j_req_recovery");
+
+                    b.ToTable("qrtz_job_details", "quartz");
+                });
+
+            modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzLock", b =>
+                {
+                    b.Property<string>("SchedulerName")
+                        .HasColumnType("text")
+                        .HasColumnName("sched_name");
+
+                    b.Property<string>("LockName")
+                        .HasColumnType("text")
+                        .HasColumnName("lock_name");
+
+                    b.HasKey("SchedulerName", "LockName");
+
+                    b.ToTable("qrtz_locks", "quartz");
+                });
+
+            modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzPausedTriggerGroup", b =>
+                {
+                    b.Property<string>("SchedulerName")
+                        .HasColumnType("text")
+                        .HasColumnName("sched_name");
+
+                    b.Property<string>("TriggerGroup")
+                        .HasColumnType("text")
+                        .HasColumnName("trigger_group");
+
+                    b.HasKey("SchedulerName", "TriggerGroup");
+
+                    b.ToTable("qrtz_paused_trigger_grps", "quartz");
+                });
+
+            modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSchedulerState", b =>
+                {
+                    b.Property<string>("SchedulerName")
+                        .HasColumnType("text")
+                        .HasColumnName("sched_name");
+
+                    b.Property<string>("InstanceName")
+                        .HasColumnType("text")
+                        .HasColumnName("instance_name");
+
+                    b.Property<long>("CheckInInterval")
+                        .HasColumnType("bigint")
+                        .HasColumnName("checkin_interval");
+
+                    b.Property<long>("LastCheckInTime")
+                        .HasColumnType("bigint")
+                        .HasColumnName("last_checkin_time");
+
+                    b.HasKey("SchedulerName", "InstanceName");
+
+                    b.ToTable("qrtz_scheduler_state", "quartz");
+                });
+
+            modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSimplePropertyTrigger", b =>
+                {
+                    b.Property<string>("SchedulerName")
+                        .HasColumnType("text")
+                        .HasColumnName("sched_name");
+
+                    b.Property<string>("TriggerName")
+                        .HasColumnType("text")
+                        .HasColumnName("trigger_name");
+
+                    b.Property<string>("TriggerGroup")
+                        .HasColumnType("text")
+                        .HasColumnName("trigger_group");
+
+                    b.Property<bool?>("BooleanProperty1")
+                        .HasColumnType("bool")
+                        .HasColumnName("bool_prop_1");
+
+                    b.Property<bool?>("BooleanProperty2")
+                        .HasColumnType("bool")
+                        .HasColumnName("bool_prop_2");
+
+                    b.Property<decimal?>("DecimalProperty1")
+                        .HasColumnType("numeric")
+                        .HasColumnName("dec_prop_1");
+
+                    b.Property<decimal?>("DecimalProperty2")
+                        .HasColumnType("numeric")
+                        .HasColumnName("dec_prop_2");
+
+                    b.Property<int?>("IntegerProperty1")
+                        .HasColumnType("integer")
+                        .HasColumnName("int_prop_1");
+
+                    b.Property<int?>("IntegerProperty2")
+                        .HasColumnType("integer")
+                        .HasColumnName("int_prop_2");
+
+                    b.Property<long?>("LongProperty1")
+                        .HasColumnType("bigint")
+                        .HasColumnName("long_prop_1");
+
+                    b.Property<long?>("LongProperty2")
+                        .HasColumnType("bigint")
+                        .HasColumnName("long_prop_2");
+
+                    b.Property<string>("StringProperty1")
+                        .HasColumnType("text")
+                        .HasColumnName("str_prop_1");
+
+                    b.Property<string>("StringProperty2")
+                        .HasColumnType("text")
+                        .HasColumnName("str_prop_2");
+
+                    b.Property<string>("StringProperty3")
+                        .HasColumnType("text")
+                        .HasColumnName("str_prop_3");
+
+                    b.Property<string>("TimeZoneId")
+                        .HasColumnType("text")
+                        .HasColumnName("time_zone_id");
+
+                    b.HasKey("SchedulerName", "TriggerName", "TriggerGroup");
+
+                    b.ToTable("qrtz_simprop_triggers", "quartz");
+                });
+
+            modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSimpleTrigger", b =>
+                {
+                    b.Property<string>("SchedulerName")
+                        .HasColumnType("text")
+                        .HasColumnName("sched_name");
+
+                    b.Property<string>("TriggerName")
+                        .HasColumnType("text")
+                        .HasColumnName("trigger_name");
+
+                    b.Property<string>("TriggerGroup")
+                        .HasColumnType("text")
+                        .HasColumnName("trigger_group");
+
+                    b.Property<long>("RepeatCount")
+                        .HasColumnType("bigint")
+                        .HasColumnName("repeat_count");
+
+                    b.Property<long>("RepeatInterval")
+                        .HasColumnType("bigint")
+                        .HasColumnName("repeat_interval");
+
+                    b.Property<long>("TimesTriggered")
+                        .HasColumnType("bigint")
+                        .HasColumnName("times_triggered");
+
+                    b.HasKey("SchedulerName", "TriggerName", "TriggerGroup");
+
+                    b.ToTable("qrtz_simple_triggers", "quartz");
+                });
+
+            modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", b =>
+                {
+                    b.Property<string>("SchedulerName")
+                        .HasColumnType("text")
+                        .HasColumnName("sched_name");
+
+                    b.Property<string>("TriggerName")
+                        .HasColumnType("text")
+                        .HasColumnName("trigger_name");
+
+                    b.Property<string>("TriggerGroup")
+                        .HasColumnType("text")
+                        .HasColumnName("trigger_group");
+
+                    b.Property<string>("CalendarName")
+                        .HasColumnType("text")
+                        .HasColumnName("calendar_name");
+
+                    b.Property<string>("Description")
+                        .HasColumnType("text")
+                        .HasColumnName("description");
+
+                    b.Property<long?>("EndTime")
+                        .HasColumnType("bigint")
+                        .HasColumnName("end_time");
+
+                    b.Property<byte[]>("JobData")
+                        .HasColumnType("bytea")
+                        .HasColumnName("job_data");
+
+                    b.Property<string>("JobGroup")
+                        .IsRequired()
+                        .HasColumnType("text")
+                        .HasColumnName("job_group");
+
+                    b.Property<string>("JobName")
+                        .IsRequired()
+                        .HasColumnType("text")
+                        .HasColumnName("job_name");
+
+                    b.Property<short?>("MisfireInstruction")
+                        .HasColumnType("smallint")
+                        .HasColumnName("misfire_instr");
+
+                    b.Property<long?>("NextFireTime")
+                        .HasColumnType("bigint")
+                        .HasColumnName("next_fire_time");
+
+                    b.Property<long?>("PreviousFireTime")
+                        .HasColumnType("bigint")
+                        .HasColumnName("prev_fire_time");
+
+                    b.Property<int?>("Priority")
+                        .HasColumnType("integer")
+                        .HasColumnName("priority");
+
+                    b.Property<long>("StartTime")
+                        .HasColumnType("bigint")
+                        .HasColumnName("start_time");
+
+                    b.Property<string>("TriggerState")
+                        .IsRequired()
+                        .HasColumnType("text")
+                        .HasColumnName("trigger_state");
+
+                    b.Property<string>("TriggerType")
+                        .IsRequired()
+                        .HasColumnType("text")
+                        .HasColumnName("trigger_type");
+
+                    b.HasKey("SchedulerName", "TriggerName", "TriggerGroup");
+
+                    b.HasIndex("NextFireTime")
+                        .HasDatabaseName("idx_qrtz_t_next_fire_time");
+
+                    b.HasIndex("TriggerState")
+                        .HasDatabaseName("idx_qrtz_t_state");
+
+                    b.HasIndex("NextFireTime", "TriggerState")
+                        .HasDatabaseName("idx_qrtz_t_nft_st");
+
+                    b.HasIndex("SchedulerName", "JobName", "JobGroup");
+
+                    b.ToTable("qrtz_triggers", "quartz");
+                });
+
+            modelBuilder.Entity("LexCore.Entities.DraftProject", b =>
+                {
+                    b.Property<Guid>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("uuid");
+
+                    b.Property<string>("Code")
+                        .IsRequired()
+                        .HasColumnType("text");
+
+                    b.Property<DateTimeOffset>("CreatedDate")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("timestamp with time zone")
+                        .HasDefaultValueSql("now()");
+
+                    b.Property<string>("Description")
+                        .IsRequired()
+                        .HasColumnType("text");
+
+                    b.Property<bool?>("IsConfidential")
+                        .HasColumnType("boolean");
+
+                    b.Property<string>("Name")
+                        .IsRequired()
+                        .HasColumnType("text");
+
+                    b.Property<Guid?>("OrgId")
+                        .HasColumnType("uuid");
+
+                    b.Property<Guid?>("ProjectManagerId")
+                        .HasColumnType("uuid");
+
+                    b.Property<int>("RetentionPolicy")
+                        .HasColumnType("integer");
+
+                    b.Property<int>("Type")
+                        .HasColumnType("integer");
+
+                    b.Property<DateTimeOffset>("UpdatedDate")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("timestamp with time zone")
+                        .HasDefaultValueSql("now()");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("Code")
+                        .IsUnique();
+
+                    b.HasIndex("ProjectManagerId");
+
+                    b.ToTable("DraftProjects");
+                });
+
+            modelBuilder.Entity("LexCore.Entities.FlexProjectMetadata", b =>
+                {
+                    b.Property<Guid>("ProjectId")
+                        .HasColumnType("uuid");
+
+                    b.Property<int?>("FlexModelVersion")
+                        .HasColumnType("integer");
+
+                    b.Property<Guid?>("LangProjectId")
+                        .HasColumnType("uuid");
+
+                    b.Property<int?>("LexEntryCount")
+                        .HasColumnType("integer");
+
+                    b.HasKey("ProjectId");
+
+                    b.ToTable("FlexProjectMetadata");
+                });
+
+            modelBuilder.Entity("LexCore.Entities.OrgMember", b =>
+                {
+                    b.Property<Guid>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("uuid");
+
+                    b.Property<DateTimeOffset>("CreatedDate")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("timestamp with time zone")
+                        .HasDefaultValueSql("now()");
+
+                    b.Property<Guid>("OrgId")
+                        .HasColumnType("uuid");
+
+                    b.Property<int>("Role")
+                        .HasColumnType("integer");
+
+                    b.Property<DateTimeOffset>("UpdatedDate")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("timestamp with time zone")
+                        .HasDefaultValueSql("now()");
+
+                    b.Property<Guid>("UserId")
+                        .HasColumnType("uuid");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("OrgId");
+
+                    b.HasIndex("UserId", "OrgId")
+                        .IsUnique();
+
+                    b.ToTable("OrgMembers", (string)null);
+                });
+
+            modelBuilder.Entity("LexCore.Entities.OrgProjects", b =>
+                {
+                    b.Property<Guid>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("uuid");
+
+                    b.Property<DateTimeOffset>("CreatedDate")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("timestamp with time zone")
+                        .HasDefaultValueSql("now()");
+
+                    b.Property<Guid>("OrgId")
+                        .HasColumnType("uuid");
+
+                    b.Property<Guid>("ProjectId")
+                        .HasColumnType("uuid");
+
+                    b.Property<DateTimeOffset>("UpdatedDate")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("timestamp with time zone")
+                        .HasDefaultValueSql("now()");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("ProjectId");
+
+                    b.HasIndex("OrgId", "ProjectId")
+                        .IsUnique();
+
+                    b.ToTable("OrgProjects");
+                });
+
+            modelBuilder.Entity("LexCore.Entities.Organization", b =>
+                {
+                    b.Property<Guid>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("uuid");
+
+                    b.Property<DateTimeOffset>("CreatedDate")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("timestamp with time zone")
+                        .HasDefaultValueSql("now()");
+
+                    b.Property<string>("Name")
+                        .IsRequired()
+                        .HasColumnType("text")
+                        .UseCollation("case_insensitive");
+
+                    b.Property<DateTimeOffset>("UpdatedDate")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("timestamp with time zone")
+                        .HasDefaultValueSql("now()");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("Name")
+                        .IsUnique();
+
+                    b.ToTable("Orgs", (string)null);
+                });
+
+            modelBuilder.Entity("LexCore.Entities.Project", b =>
+                {
+                    b.Property<Guid>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("uuid");
+
+                    b.Property<string>("Code")
+                        .IsRequired()
+                        .HasColumnType("text");
+
+                    b.Property<DateTimeOffset>("CreatedDate")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("timestamp with time zone")
+                        .HasDefaultValueSql("now()");
+
+                    b.Property<DateTimeOffset?>("DeletedDate")
+                        .HasColumnType("timestamp with time zone");
+
+                    b.Property<string>("Description")
+                        .HasColumnType("text");
+
+                    b.Property<bool?>("IsConfidential")
+                        .HasColumnType("boolean");
+
+                    b.Property<DateTimeOffset?>("LastCommit")
+                        .HasColumnType("timestamp with time zone");
+
+                    b.Property<DateTimeOffset?>("MigratedDate")
+                        .HasColumnType("timestamp with time zone");
+
+                    b.Property<string>("Name")
+                        .IsRequired()
+                        .HasColumnType("text");
+
+                    b.Property<Guid?>("ParentId")
+                        .HasColumnType("uuid");
+
+                    b.Property<int>("ProjectOrigin")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("integer")
+                        .HasDefaultValue(1);
+
+                    b.Property<int?>("RepoSizeInKb")
+                        .HasColumnType("integer");
+
+                    b.Property<int>("ResetStatus")
+                        .HasColumnType("integer");
+
+                    b.Property<int>("RetentionPolicy")
+                        .HasColumnType("integer");
+
+                    b.Property<int>("Type")
+                        .HasColumnType("integer");
+
+                    b.Property<DateTimeOffset>("UpdatedDate")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("timestamp with time zone")
+                        .HasDefaultValueSql("now()");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("Code")
+                        .IsUnique();
+
+                    b.HasIndex("ParentId");
+
+                    b.ToTable("Projects");
+                });
+
+            modelBuilder.Entity("LexCore.Entities.ProjectUsers", b =>
+                {
+                    b.Property<Guid>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("uuid");
+
+                    b.Property<DateTimeOffset>("CreatedDate")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("timestamp with time zone")
+                        .HasDefaultValueSql("now()");
+
+                    b.Property<Guid>("ProjectId")
+                        .HasColumnType("uuid");
+
+                    b.Property<int>("Role")
+                        .HasColumnType("integer");
+
+                    b.Property<DateTimeOffset>("UpdatedDate")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("timestamp with time zone")
+                        .HasDefaultValueSql("now()");
+
+                    b.Property<Guid>("UserId")
+                        .HasColumnType("uuid");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("ProjectId");
+
+                    b.HasIndex("UserId", "ProjectId")
+                        .IsUnique();
+
+                    b.ToTable("ProjectUsers");
+                });
+
+            modelBuilder.Entity("LexCore.Entities.User", b =>
+                {
+                    b.Property<Guid>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("uuid");
+
+                    b.Property<bool>("CanCreateProjects")
+                        .HasColumnType("boolean");
+
+                    b.Property<Guid?>("CreatedById")
+                        .HasColumnType("uuid");
+
+                    b.Property<DateTimeOffset>("CreatedDate")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("timestamp with time zone")
+                        .HasDefaultValueSql("now()");
+
+                    b.Property<string>("Email")
+                        .HasColumnType("text")
+                        .UseCollation("case_insensitive");
+
+                    b.Property<bool>("EmailVerified")
+                        .HasColumnType("boolean");
+
+                    b.Property<string>("GoogleId")
+                        .HasColumnType("text");
+
+                    b.Property<bool>("IsAdmin")
+                        .HasColumnType("boolean");
+
+                    b.Property<DateTimeOffset>("LastActive")
+                        .HasColumnType("timestamp with time zone");
+
+                    b.Property<string>("LocalizationCode")
+                        .IsRequired()
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("text")
+                        .HasDefaultValue("en");
+
+                    b.Property<bool>("Locked")
+                        .HasColumnType("boolean");
+
+                    b.Property<string>("Name")
+                        .IsRequired()
+                        .HasColumnType("text");
+
+                    b.Property<string>("PasswordHash")
+                        .IsRequired()
+                        .HasColumnType("text");
+
+                    b.Property<int?>("PasswordStrength")
+                        .HasColumnType("integer");
+
+                    b.Property<string>("Salt")
+                        .IsRequired()
+                        .HasColumnType("text");
+
+                    b.Property<DateTimeOffset>("UpdatedDate")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("timestamp with time zone")
+                        .HasDefaultValueSql("now()");
+
+                    b.Property<string>("Username")
+                        .HasColumnType("text")
+                        .UseCollation("case_insensitive");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("CreatedById");
+
+                    b.HasIndex("Email")
+                        .IsUnique();
+
+                    b.HasIndex("Username")
+                        .IsUnique();
+
+                    b.ToTable("Users");
+                });
+
+            modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b =>
+                {
+                    b.Property<string>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("text");
+
+                    b.Property<string>("ApplicationType")
+                        .HasMaxLength(50)
+                        .HasColumnType("character varying(50)");
+
+                    b.Property<string>("ClientId")
+                        .HasMaxLength(100)
+                        .HasColumnType("character varying(100)");
+
+                    b.Property<string>("ClientSecret")
+                        .HasColumnType("text");
+
+                    b.Property<string>("ClientType")
+                        .HasMaxLength(50)
+                        .HasColumnType("character varying(50)");
+
+                    b.Property<string>("ConcurrencyToken")
+                        .IsConcurrencyToken()
+                        .HasMaxLength(50)
+                        .HasColumnType("character varying(50)");
+
+                    b.Property<string>("ConsentType")
+                        .HasMaxLength(50)
+                        .HasColumnType("character varying(50)");
+
+                    b.Property<string>("DisplayName")
+                        .HasColumnType("text");
+
+                    b.Property<string>("DisplayNames")
+                        .HasColumnType("text");
+
+                    b.Property<string>("JsonWebKeySet")
+                        .HasColumnType("text");
+
+                    b.Property<string>("Permissions")
+                        .HasColumnType("text");
+
+                    b.Property<string>("PostLogoutRedirectUris")
+                        .HasColumnType("text");
+
+                    b.Property<string>("Properties")
+                        .HasColumnType("text");
+
+                    b.Property<string>("RedirectUris")
+                        .HasColumnType("text");
+
+                    b.Property<string>("Requirements")
+                        .HasColumnType("text");
+
+                    b.Property<string>("Settings")
+                        .HasColumnType("text");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("ClientId")
+                        .IsUnique();
+
+                    b.ToTable("OpenIddictApplications", (string)null);
+                });
+
+            modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b =>
+                {
+                    b.Property<string>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("text");
+
+                    b.Property<string>("ApplicationId")
+                        .HasColumnType("text");
+
+                    b.Property<string>("ConcurrencyToken")
+                        .IsConcurrencyToken()
+                        .HasMaxLength(50)
+                        .HasColumnType("character varying(50)");
+
+                    b.Property<DateTime?>("CreationDate")
+                        .HasColumnType("timestamp with time zone");
+
+                    b.Property<string>("Properties")
+                        .HasColumnType("text");
+
+                    b.Property<string>("Scopes")
+                        .HasColumnType("text");
+
+                    b.Property<string>("Status")
+                        .HasMaxLength(50)
+                        .HasColumnType("character varying(50)");
+
+                    b.Property<string>("Subject")
+                        .HasMaxLength(400)
+                        .HasColumnType("character varying(400)");
+
+                    b.Property<string>("Type")
+                        .HasMaxLength(50)
+                        .HasColumnType("character varying(50)");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("ApplicationId", "Status", "Subject", "Type");
+
+                    b.ToTable("OpenIddictAuthorizations", (string)null);
+                });
+
+            modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreScope", b =>
+                {
+                    b.Property<string>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("text");
+
+                    b.Property<string>("ConcurrencyToken")
+                        .IsConcurrencyToken()
+                        .HasMaxLength(50)
+                        .HasColumnType("character varying(50)");
+
+                    b.Property<string>("Description")
+                        .HasColumnType("text");
+
+                    b.Property<string>("Descriptions")
+                        .HasColumnType("text");
+
+                    b.Property<string>("DisplayName")
+                        .HasColumnType("text");
+
+                    b.Property<string>("DisplayNames")
+                        .HasColumnType("text");
+
+                    b.Property<string>("Name")
+                        .HasMaxLength(200)
+                        .HasColumnType("character varying(200)");
+
+                    b.Property<string>("Properties")
+                        .HasColumnType("text");
+
+                    b.Property<string>("Resources")
+                        .HasColumnType("text");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("Name")
+                        .IsUnique();
+
+                    b.ToTable("OpenIddictScopes", (string)null);
+                });
+
+            modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b =>
+                {
+                    b.Property<string>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("text");
+
+                    b.Property<string>("ApplicationId")
+                        .HasColumnType("text");
+
+                    b.Property<string>("AuthorizationId")
+                        .HasColumnType("text");
+
+                    b.Property<string>("ConcurrencyToken")
+                        .IsConcurrencyToken()
+                        .HasMaxLength(50)
+                        .HasColumnType("character varying(50)");
+
+                    b.Property<DateTime?>("CreationDate")
+                        .HasColumnType("timestamp with time zone");
+
+                    b.Property<DateTime?>("ExpirationDate")
+                        .HasColumnType("timestamp with time zone");
+
+                    b.Property<string>("Payload")
+                        .HasColumnType("text");
+
+                    b.Property<string>("Properties")
+                        .HasColumnType("text");
+
+                    b.Property<DateTime?>("RedemptionDate")
+                        .HasColumnType("timestamp with time zone");
+
+                    b.Property<string>("ReferenceId")
+                        .HasMaxLength(100)
+                        .HasColumnType("character varying(100)");
+
+                    b.Property<string>("Status")
+                        .HasMaxLength(50)
+                        .HasColumnType("character varying(50)");
+
+                    b.Property<string>("Subject")
+                        .HasMaxLength(400)
+                        .HasColumnType("character varying(400)");
+
+                    b.Property<string>("Type")
+                        .HasMaxLength(50)
+                        .HasColumnType("character varying(50)");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("AuthorizationId");
+
+                    b.HasIndex("ReferenceId")
+                        .IsUnique();
+
+                    b.HasIndex("ApplicationId", "Status", "Subject", "Type");
+
+                    b.ToTable("OpenIddictTokens", (string)null);
+                });
+
+            modelBuilder.Entity("SIL.Harmony.Core.ServerCommit", b =>
+                {
+                    b.Property<Guid>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("uuid");
+
+                    b.Property<Guid>("ClientId")
+                        .HasColumnType("uuid");
+
+                    b.Property<string>("Metadata")
+                        .IsRequired()
+                        .HasColumnType("text");
+
+                    b.Property<Guid>("ProjectId")
+                        .HasColumnType("uuid");
+
+                    b.ComplexProperty<Dictionary<string, object>>("HybridDateTime", "SIL.Harmony.Core.ServerCommit.HybridDateTime#HybridDateTime", b1 =>
+                        {
+                            b1.IsRequired();
+
+                            b1.Property<long>("Counter")
+                                .HasColumnType("bigint");
+
+                            b1.Property<DateTimeOffset>("DateTime")
+                                .HasColumnType("timestamp with time zone");
+                        });
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("ProjectId");
+
+                    b.ToTable("CrdtCommits", (string)null);
+                });
+
+            modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzBlobTrigger", b =>
+                {
+                    b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", "Trigger")
+                        .WithMany("BlobTriggers")
+                        .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+
+                    b.Navigation("Trigger");
+                });
+
+            modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzCronTrigger", b =>
+                {
+                    b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", "Trigger")
+                        .WithMany("CronTriggers")
+                        .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+
+                    b.Navigation("Trigger");
+                });
+
+            modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSimplePropertyTrigger", b =>
+                {
+                    b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", "Trigger")
+                        .WithMany("SimplePropertyTriggers")
+                        .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+
+                    b.Navigation("Trigger");
+                });
+
+            modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSimpleTrigger", b =>
+                {
+                    b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", "Trigger")
+                        .WithMany("SimpleTriggers")
+                        .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+
+                    b.Navigation("Trigger");
+                });
+
+            modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", b =>
+                {
+                    b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzJobDetail", "JobDetail")
+                        .WithMany("Triggers")
+                        .HasForeignKey("SchedulerName", "JobName", "JobGroup")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+
+                    b.Navigation("JobDetail");
+                });
+
+            modelBuilder.Entity("LexCore.Entities.DraftProject", b =>
+                {
+                    b.HasOne("LexCore.Entities.User", "ProjectManager")
+                        .WithMany()
+                        .HasForeignKey("ProjectManagerId")
+                        .OnDelete(DeleteBehavior.Cascade);
+
+                    b.Navigation("ProjectManager");
+                });
+
+            modelBuilder.Entity("LexCore.Entities.FlexProjectMetadata", b =>
+                {
+                    b.HasOne("LexCore.Entities.Project", null)
+                        .WithOne("FlexProjectMetadata")
+                        .HasForeignKey("LexCore.Entities.FlexProjectMetadata", "ProjectId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+
+                    b.OwnsOne("LexCore.Entities.ProjectWritingSystems", "WritingSystems", b1 =>
+                        {
+                            b1.Property<Guid>("FlexProjectMetadataProjectId")
+                                .HasColumnType("uuid");
+
+                            b1.HasKey("FlexProjectMetadataProjectId");
+
+                            b1.ToTable("FlexProjectMetadata");
+
+                            b1.ToJson("WritingSystems");
+
+                            b1.WithOwner()
+                                .HasForeignKey("FlexProjectMetadataProjectId");
+
+                            b1.OwnsMany("LexCore.Entities.FLExWsId", "AnalysisWss", b2 =>
+                                {
+                                    b2.Property<Guid>("ProjectWritingSystemsFlexProjectMetadataProjectId")
+                                        .HasColumnType("uuid");
+
+                                    b2.Property<int>("Id")
+                                        .ValueGeneratedOnAdd()
+                                        .HasColumnType("integer");
+
+                                    b2.Property<bool>("IsActive")
+                                        .HasColumnType("boolean");
+
+                                    b2.Property<bool>("IsDefault")
+                                        .HasColumnType("boolean");
+
+                                    b2.Property<string>("Tag")
+                                        .IsRequired()
+                                        .HasColumnType("text");
+
+                                    b2.HasKey("ProjectWritingSystemsFlexProjectMetadataProjectId", "Id");
+
+                                    b2.ToTable("FlexProjectMetadata");
+
+                                    b2.WithOwner()
+                                        .HasForeignKey("ProjectWritingSystemsFlexProjectMetadataProjectId");
+                                });
+
+                            b1.OwnsMany("LexCore.Entities.FLExWsId", "VernacularWss", b2 =>
+                                {
+                                    b2.Property<Guid>("ProjectWritingSystemsFlexProjectMetadataProjectId")
+                                        .HasColumnType("uuid");
+
+                                    b2.Property<int>("Id")
+                                        .ValueGeneratedOnAdd()
+                                        .HasColumnType("integer");
+
+                                    b2.Property<bool>("IsActive")
+                                        .HasColumnType("boolean");
+
+                                    b2.Property<bool>("IsDefault")
+                                        .HasColumnType("boolean");
+
+                                    b2.Property<string>("Tag")
+                                        .IsRequired()
+                                        .HasColumnType("text");
+
+                                    b2.HasKey("ProjectWritingSystemsFlexProjectMetadataProjectId", "Id");
+
+                                    b2.ToTable("FlexProjectMetadata");
+
+                                    b2.WithOwner()
+                                        .HasForeignKey("ProjectWritingSystemsFlexProjectMetadataProjectId");
+                                });
+
+                            b1.Navigation("AnalysisWss");
+
+                            b1.Navigation("VernacularWss");
+                        });
+
+                    b.Navigation("WritingSystems");
+                });
+
+            modelBuilder.Entity("LexCore.Entities.OrgMember", b =>
+                {
+                    b.HasOne("LexCore.Entities.Organization", "Organization")
+                        .WithMany("Members")
+                        .HasForeignKey("OrgId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+
+                    b.HasOne("LexCore.Entities.User", "User")
+                        .WithMany("Organizations")
+                        .HasForeignKey("UserId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+
+                    b.Navigation("Organization");
+
+                    b.Navigation("User");
+                });
+
+            modelBuilder.Entity("LexCore.Entities.OrgProjects", b =>
+                {
+                    b.HasOne("LexCore.Entities.Organization", "Org")
+                        .WithMany()
+                        .HasForeignKey("OrgId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+
+                    b.HasOne("LexCore.Entities.Project", "Project")
+                        .WithMany()
+                        .HasForeignKey("ProjectId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+
+                    b.Navigation("Org");
+
+                    b.Navigation("Project");
+                });
+
+            modelBuilder.Entity("LexCore.Entities.Project", b =>
+                {
+                    b.HasOne("LexCore.Entities.Project", null)
+                        .WithMany()
+                        .HasForeignKey("ParentId");
+                });
+
+            modelBuilder.Entity("LexCore.Entities.ProjectUsers", b =>
+                {
+                    b.HasOne("LexCore.Entities.Project", "Project")
+                        .WithMany("Users")
+                        .HasForeignKey("ProjectId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+
+                    b.HasOne("LexCore.Entities.User", "User")
+                        .WithMany("Projects")
+                        .HasForeignKey("UserId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+
+                    b.Navigation("Project");
+
+                    b.Navigation("User");
+                });
+
+            modelBuilder.Entity("LexCore.Entities.User", b =>
+                {
+                    b.HasOne("LexCore.Entities.User", "CreatedBy")
+                        .WithMany("UsersICreated")
+                        .HasForeignKey("CreatedById")
+                        .OnDelete(DeleteBehavior.Restrict);
+
+                    b.Navigation("CreatedBy");
+                });
+
+            modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b =>
+                {
+                    b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application")
+                        .WithMany("Authorizations")
+                        .HasForeignKey("ApplicationId");
+
+                    b.Navigation("Application");
+                });
+
+            modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b =>
+                {
+                    b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application")
+                        .WithMany("Tokens")
+                        .HasForeignKey("ApplicationId");
+
+                    b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", "Authorization")
+                        .WithMany("Tokens")
+                        .HasForeignKey("AuthorizationId");
+
+                    b.Navigation("Application");
+
+                    b.Navigation("Authorization");
+                });
+
+            modelBuilder.Entity("SIL.Harmony.Core.ServerCommit", b =>
+                {
+                    b.HasOne("LexCore.Entities.Project", null)
+                        .WithMany()
+                        .HasForeignKey("ProjectId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+
+                    b.OwnsMany("SIL.Harmony.Core.ChangeEntity<SIL.Harmony.Core.ServerJsonChange>", "ChangeEntities", b1 =>
+                        {
+                            b1.Property<Guid>("ServerCommitId")
+                                .HasColumnType("uuid");
+
+                            b1.Property<int>("Id")
+                                .ValueGeneratedOnAdd()
+                                .HasColumnType("integer");
+
+                            b1.Property<string>("Change")
+                                .HasColumnType("text");
+
+                            b1.Property<Guid>("CommitId")
+                                .HasColumnType("uuid");
+
+                            b1.Property<Guid>("EntityId")
+                                .HasColumnType("uuid");
+
+                            b1.Property<int>("Index")
+                                .HasColumnType("integer");
+
+                            b1.HasKey("ServerCommitId", "Id");
+
+                            b1.ToTable("CrdtCommits");
+
+                            b1.ToJson("ChangeEntities");
+
+                            b1.WithOwner()
+                                .HasForeignKey("ServerCommitId");
+                        });
+
+                    b.Navigation("ChangeEntities");
+                });
+
+            modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzJobDetail", b =>
+                {
+                    b.Navigation("Triggers");
+                });
+
+            modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", b =>
+                {
+                    b.Navigation("BlobTriggers");
+
+                    b.Navigation("CronTriggers");
+
+                    b.Navigation("SimplePropertyTriggers");
+
+                    b.Navigation("SimpleTriggers");
+                });
+
+            modelBuilder.Entity("LexCore.Entities.Organization", b =>
+                {
+                    b.Navigation("Members");
+                });
+
+            modelBuilder.Entity("LexCore.Entities.Project", b =>
+                {
+                    b.Navigation("FlexProjectMetadata");
+
+                    b.Navigation("Users");
+                });
+
+            modelBuilder.Entity("LexCore.Entities.User", b =>
+                {
+                    b.Navigation("Organizations");
+
+                    b.Navigation("Projects");
+
+                    b.Navigation("UsersICreated");
+                });
+
+            modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b =>
+                {
+                    b.Navigation("Authorizations");
+
+                    b.Navigation("Tokens");
+                });
+
+            modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b =>
+                {
+                    b.Navigation("Tokens");
+                });
+#pragma warning restore 612, 618
+        }
+    }
+}
diff --git a/backend/LexData/Migrations/20250114091559_ChangeFkRelationOnCrdtCommitsToPointToProject.cs b/backend/LexData/Migrations/20250114091559_ChangeFkRelationOnCrdtCommitsToPointToProject.cs
new file mode 100644
index 000000000..65f93b1e3
--- /dev/null
+++ b/backend/LexData/Migrations/20250114091559_ChangeFkRelationOnCrdtCommitsToPointToProject.cs
@@ -0,0 +1,42 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace LexData.Migrations
+{
+    /// <inheritdoc />
+    public partial class ChangeFkRelationOnCrdtCommitsToPointToProject : Migration
+    {
+        /// <inheritdoc />
+        protected override void Up(MigrationBuilder migrationBuilder)
+        {
+            migrationBuilder.DropForeignKey(
+                name: "FK_CrdtCommits_FlexProjectMetadata_ProjectId",
+                table: "CrdtCommits");
+
+            migrationBuilder.AddForeignKey(
+                name: "FK_CrdtCommits_Projects_ProjectId",
+                table: "CrdtCommits",
+                column: "ProjectId",
+                principalTable: "Projects",
+                principalColumn: "Id",
+                onDelete: ReferentialAction.Cascade);
+        }
+
+        /// <inheritdoc />
+        protected override void Down(MigrationBuilder migrationBuilder)
+        {
+            migrationBuilder.DropForeignKey(
+                name: "FK_CrdtCommits_Projects_ProjectId",
+                table: "CrdtCommits");
+
+            migrationBuilder.AddForeignKey(
+                name: "FK_CrdtCommits_FlexProjectMetadata_ProjectId",
+                table: "CrdtCommits",
+                column: "ProjectId",
+                principalTable: "FlexProjectMetadata",
+                principalColumn: "ProjectId",
+                onDelete: ReferentialAction.Cascade);
+        }
+    }
+}
diff --git a/backend/LexData/Migrations/LexBoxDbContextModelSnapshot.cs b/backend/LexData/Migrations/LexBoxDbContextModelSnapshot.cs
index 349d2c0da..1a4f9a84e 100644
--- a/backend/LexData/Migrations/LexBoxDbContextModelSnapshot.cs
+++ b/backend/LexData/Migrations/LexBoxDbContextModelSnapshot.cs
@@ -1312,7 +1312,7 @@ protected override void BuildModel(ModelBuilder modelBuilder)
 
             modelBuilder.Entity("SIL.Harmony.Core.ServerCommit", b =>
                 {
-                    b.HasOne("LexCore.Entities.FlexProjectMetadata", null)
+                    b.HasOne("LexCore.Entities.Project", null)
                         .WithMany()
                         .HasForeignKey("ProjectId")
                         .OnDelete(DeleteBehavior.Cascade)
diff --git a/backend/Testing/FwHeadless/MergeFwDataWithHarmonyTests.cs b/backend/Testing/FwHeadless/MergeFwDataWithHarmonyTests.cs
new file mode 100644
index 000000000..2f88f429c
--- /dev/null
+++ b/backend/Testing/FwHeadless/MergeFwDataWithHarmonyTests.cs
@@ -0,0 +1,115 @@
+using System.Net.Http.Json;
+using System.Text.Json;
+using FluentAssertions;
+using LexCore.Sync;
+using SIL.Harmony.Core;
+using Testing.ApiTests;
+using Testing.Services;
+
+namespace Testing.FwHeadless;
+
+[Trait("Category", "Integration")]
+public class MergeFwDataWithHarmonyTests : ApiTestBase, IAsyncLifetime
+{
+    private async Task<Guid> CopyProjectToNewProject(string newProjectCode, string existingProjectCode)
+    {
+        var result = await HttpClient.PostAsync(
+            $"api/Testing/copyToNewProject?newProjectCode={newProjectCode}&existingProjectCode={existingProjectCode}",
+            null);
+        result.EnsureSuccessStatusCode();
+        return await result.Content.ReadFromJsonAsync<Guid>();
+    }
+
+    private async Task TriggerSync(Guid projectId)
+    {
+        var result = await HttpClient.PostAsync($"api/fw-lite/sync/trigger/{projectId}", null);
+        if (result.IsSuccessStatusCode) return;
+        var responseString = await result.Content.ReadAsStringAsync();
+        Assert.Fail($"trigger failed with error {result.ReasonPhrase}, body: {responseString}" );
+    }
+
+    private async Task<SyncResult?> AwaitSyncFinished(Guid projectId)
+    {
+        var result = await HttpClient.GetAsync($"api/fw-lite/sync/await-sync-finished/{projectId}");
+        result.EnsureSuccessStatusCode();
+        return await result.Content.ReadFromJsonAsync<SyncResult>();
+    }
+
+    private async Task AddTestCommit(Guid projectId)
+    {
+        var entryId = Guid.NewGuid();
+        ServerCommit[] serverCommits =
+        [
+            new ServerCommit(Guid.NewGuid())
+            {
+                ChangeEntities =
+                [
+                    new ChangeEntity<ServerJsonChange>()
+                    {
+                        Change = JsonSerializer.Deserialize<ServerJsonChange>(
+                            $$"""
+                              {
+                                "$type": "CreateEntryChange",
+                                "LexemeForm": {
+                                  "en": "Apple"
+                                },
+                                "CitationForm": {
+                                  "en": "Apple"
+                                },
+                                "Note": {},
+                                "EntityId": "{{entryId}}"
+                              }
+                              """
+                        ) ?? throw new JsonException("unable to deserialize"),
+                        Index = 0,
+                        CommitId = Guid.NewGuid(),
+                        EntityId = entryId
+                    }
+                ],
+                ClientId = Guid.NewGuid(),
+                ProjectId = projectId,
+                HybridDateTime = new HybridDateTime(DateTime.UtcNow, 0)
+            }
+        ];
+        var result = await HttpClient.PostAsJsonAsync($"api/crdt/{projectId}/add", serverCommits);
+        result.EnsureSuccessStatusCode();
+    }
+
+    private Guid _projectId;
+
+    public async Task InitializeAsync()
+    {
+        await LoginAs("admin");
+        var projectCode = Utils.NewProjectCode();
+        _projectId = await CopyProjectToNewProject(projectCode, "sena-3");
+    }
+
+    public async Task DisposeAsync()
+    {
+        await HttpClient.DeleteAsync($"api/project/{_projectId}");
+    }
+
+    [Fact]
+    public async Task TriggerSync_WorksTheFirstTime()
+    {
+        await TriggerSync(_projectId);
+        var result = await AwaitSyncFinished(_projectId);
+        result.Should().NotBeNull();
+        result.CrdtChanges.Should().BeGreaterThan(100);
+        result.FwdataChanges.Should().Be(0);
+    }
+
+    [Fact]
+    public async Task TriggerSync_WorksWithSomeCommits()
+    {
+        await TriggerSync(_projectId);
+        await AwaitSyncFinished(_projectId);
+
+        await AddTestCommit(_projectId);
+        await TriggerSync(_projectId);
+        var result = await AwaitSyncFinished(_projectId);
+        result.Should().NotBeNull();
+        result.CrdtChanges.Should().Be(0);
+        result.FwdataChanges.Should().BeGreaterThan(0);
+    }
+}
diff --git a/backend/Testing/Services/Utils.cs b/backend/Testing/Services/Utils.cs
index 48117e172..fc896d1e6 100644
--- a/backend/Testing/Services/Utils.cs
+++ b/backend/Testing/Services/Utils.cs
@@ -27,12 +27,19 @@ public static ProjectConfig GetNewProjectConfig(HgProtocol? protocol = null, boo
         projectName = projectName[..Math.Min(projectName.Length, 40)]; // make sure the path isn't too long
         if (protocol.HasValue) projectName += $" ({protocol.Value.ToString()[..5]})";
         var id = Guid.NewGuid();
-        var shortId = id.ToString().Split("-")[0];
-        var projectCode = $"{ToProjectCodeFriendlyString(projectName)}-{shortId}-dev-flex";
+        var projectCode = NewProjectCode(projectName, id);
         var dir = GetNewProjectDir(projectCode, "");
         return new ProjectConfig(id, projectName, projectCode, dir, isConfidential, owningOrgId);
     }
 
+    public static string NewProjectCode([CallerMemberName] string projectName = "", Guid? id = null)
+    {
+        id ??= Guid.NewGuid();
+        var shortId = id.Value.ToString().Split("-")[0];
+        var projectCode = $"{ToProjectCodeFriendlyString(projectName)}-{shortId}-dev-flex";
+        return projectCode;
+    }
+
     public static async Task<LexboxProject> RegisterProjectInLexBox(
         this ApiTestBase apiTester,
         ProjectConfig config,
diff --git a/deployment/base/fw-headless-deployment.yaml b/deployment/base/fw-headless-deployment.yaml
index efe6175d8..1f036609f 100644
--- a/deployment/base/fw-headless-deployment.yaml
+++ b/deployment/base/fw-headless-deployment.yaml
@@ -14,7 +14,7 @@ spec:
   ports:
   - name: http
     protocol: TCP
-    port: 80
+    port: 8081
 
 ---
 
@@ -54,13 +54,13 @@ spec:
             memory: 2400Mi
         startupProbe:
           httpGet:
-            port: 80
+            port: 8081
             path: /api/healthz
           failureThreshold: 30
           periodSeconds: 10
           timeoutSeconds: 5
         ports:
-          - containerPort: 80
+          - containerPort: 8081
 
         volumeMounts:
         - name: fw-headless
@@ -68,7 +68,7 @@ spec:
 
         env:
           - name: DOTNET_URLS
-            value: http://0.0.0.0:80
+            value: http://0.0.0.0:8081
           - name: ASPNETCORE_ENVIRONMENT
             valueFrom:
               configMapKeyRef:
diff --git a/deployment/base/lexbox-deployment.yaml b/deployment/base/lexbox-deployment.yaml
index a7c4da81a..7519e385e 100644
--- a/deployment/base/lexbox-deployment.yaml
+++ b/deployment/base/lexbox-deployment.yaml
@@ -206,7 +206,7 @@ spec:
           - name: Tus__ResetUploadPath
             value: /tmp/tus-reset-upload
           - name: Services__fwHeadless__http__0
-            value: fw-headless
+            value: fw-headless:8081
 
 
       - name: otel-collector
diff --git a/deployment/local-dev/lexbox-deployment.patch.yaml b/deployment/local-dev/lexbox-deployment.patch.yaml
index 08ebb18ef..443da3d9c 100644
--- a/deployment/local-dev/lexbox-deployment.patch.yaml
+++ b/deployment/local-dev/lexbox-deployment.patch.yaml
@@ -18,7 +18,7 @@ spec:
           $patch: delete
         resources:
             requests:
-                memory: 2Gi
+                memory: 1Gi
             limits:
                 memory: 2Gi
         env:
diff --git a/frontend/schema.graphql b/frontend/schema.graphql
index 60ab64c05..78b9fdb9f 100644
--- a/frontend/schema.graphql
+++ b/frontend/schema.graphql
@@ -393,6 +393,7 @@ type Project {
   changesets: [Changeset!]! @cost(weight: "10")
   hasAbandonedTransactions: Boolean!
   isLanguageForgeProject: Boolean! @cost(weight: "10")
+  hasHarmonyCommits: Boolean! @cost(weight: "10")
   parentId: UUID
   name: String!
   description: String
diff --git a/frontend/src/hooks.shared.ts b/frontend/src/hooks.shared.ts
index f27a00c78..9db709891 100644
--- a/frontend/src/hooks.shared.ts
+++ b/frontend/src/hooks.shared.ts
@@ -1,23 +1,10 @@
-import { redirect } from '@sveltejs/kit';
+import {redirect} from '@sveltejs/kit';
+import {tryGetErrorMessage} from '$lib/error/utils';
 
 const sayWuuuuuuut = 'We\'re not sure what happened.';
 
 export function getErrorMessage(error: unknown): string {
-  if (error === null || error === undefined) {
-    return sayWuuuuuuut;
-  } else if (typeof error === 'string') {
-    return error;
-  }
-
-  const _error = (error ?? {}) as Record<string, string>;
-  return (
-      _error.message ??
-      _error.reason ??
-      _error.cause ??
-      _error.error ??
-      _error.code ??
-      sayWuuuuuuut
-  );
+  return tryGetErrorMessage(error) ?? sayWuuuuuuut;
 }
 
 export function validateFetchResponse(
diff --git a/frontend/src/lib/app.postcss b/frontend/src/lib/app.postcss
index eee688940..fd282bccc 100644
--- a/frontend/src/lib/app.postcss
+++ b/frontend/src/lib/app.postcss
@@ -156,7 +156,7 @@ input[readonly]:focus {
   the microscopic size they have by default is not what we're looking for.
   If we ever want to use badges of different sizes, we can revisit this.
 */
-.badge {
+.badge:not(.badge-xs, .badge-sm, .badge-md, .badge-lg) {
   @apply btn-sm;
 }
 
@@ -224,3 +224,25 @@ img[src*="onestory-editor-logo"] {
   /* Ensures dangling letters are not clipped (e.g. "g") */
   line-height: 1.3em;
 }
+
+
+.details {
+    display: grid;
+    column-gap: 2rem;
+    grid-template-columns: 1fr;
+    @media screen(md) {
+        grid-template-columns: minmax(0, 1fr) 1fr;
+
+        .detail-item {
+            grid-column: auto;
+        }
+
+        > * {
+            grid-column: span 2;
+        }
+    }
+}
+
+.underline-links a {
+  @apply link hover:contrast-200;
+}
diff --git a/frontend/src/lib/components/Markdown/NewTabLinkRenderer.svelte b/frontend/src/lib/components/Markdown/NewTabLinkRenderer.svelte
index e1b72a91d..f7af88533 100644
--- a/frontend/src/lib/components/Markdown/NewTabLinkRenderer.svelte
+++ b/frontend/src/lib/components/Markdown/NewTabLinkRenderer.svelte
@@ -10,7 +10,7 @@
 
 <style>
   .external-link-icon {
-    margin-bottom: -0.7%;
+    margin-bottom: -0.4%;
     font-size: 0.9em;
   }
 </style>
diff --git a/frontend/src/lib/components/Projects/FlexModelVersionText.svelte b/frontend/src/lib/components/Projects/FlexModelVersionText.svelte
index 5adde58e2..c76300237 100644
--- a/frontend/src/lib/components/Projects/FlexModelVersionText.svelte
+++ b/frontend/src/lib/components/Projects/FlexModelVersionText.svelte
@@ -24,8 +24,7 @@
 <script lang="ts">
 
   export let modelVersion: number;
-  export let extraClass = '';
   $: fwModel = versionLookupTable[modelVersion] ?? 'Unknown FieldWorks version';
 </script>
 
-<span title="FLEx data model {modelVersion}" class={extraClass}>{fwModel}</span>
+<span title="FLEx data model {modelVersion}" class="text-secondary">{fwModel}</span>
diff --git a/frontend/src/lib/components/Projects/WritingSystemList.svelte b/frontend/src/lib/components/Projects/WritingSystemList.svelte
index cda69106d..b824a26b4 100644
--- a/frontend/src/lib/components/Projects/WritingSystemList.svelte
+++ b/frontend/src/lib/components/Projects/WritingSystemList.svelte
@@ -6,8 +6,12 @@
   export let writingSystems: FlExWsId[] = [];
 </script>
 
-<BadgeList>
-  {#each  writingSystems as ws}
-    <WritingSystemBadge tag={ws.tag} isActive={ws.isActive} isDefault={ws.isDefault} />
-  {/each}
-</BadgeList>
+<div class="w-full">
+  {#if writingSystems.length > 0}
+    <BadgeList>
+      {#each  writingSystems as ws}
+        <WritingSystemBadge tag={ws.tag} isActive={ws.isActive} isDefault={ws.isDefault} />
+      {/each}
+    </BadgeList>
+  {/if}
+</div>
diff --git a/frontend/src/lib/components/modals/ConfirmModal.svelte b/frontend/src/lib/components/modals/ConfirmModal.svelte
index 4c451a686..4550aae89 100644
--- a/frontend/src/lib/components/modals/ConfirmModal.svelte
+++ b/frontend/src/lib/components/modals/ConfirmModal.svelte
@@ -2,6 +2,7 @@
   import {type IconString, Icon} from '$lib/icons';
   import Modal, {DialogResponse} from './Modal.svelte';
   import {Button, type ErrorMessage, FormError} from '$lib/forms';
+  import t from '$lib/i18n';
 
   export let title: string;
   export let submitText: string;
@@ -9,8 +10,15 @@
   export let submitVariant: 'btn-primary' |  'btn-error' = 'btn-primary';
 
   export let cancelText: string;
+  export let hideActions: boolean = false;
+
+  export let doneText = $t('common.close');
+  export let showDoneState = false;
+
+  let done = false;
 
   export async function open(onSubmit: () => Promise<ErrorMessage>): Promise<boolean> {
+    done = false;
     if ((await modal.openModal()) === DialogResponse.Cancel) {
       error = undefined;
       return false;
@@ -20,7 +28,8 @@
     if (error) {
       return open(onSubmit);
     }
-    modal.close();
+    done = true;
+    if (!showDoneState) modal.close();
     error = undefined;
     return true;
   }
@@ -30,21 +39,27 @@
 </script>
 
 
-<Modal bind:this={modal} showCloseButton={false}>
+<Modal bind:this={modal} showCloseButton={false} {hideActions}>
   <h2 class="text-xl mb-2">
     {title}
   </h2>
-  <slot/>
+  <slot {done} {error} />
   <FormError {error} right/>
-  <svelte:fragment slot="actions" let:submitting>
-    <Button variant={submitVariant} loading={submitting} on:click={() => modal.submitModal()}>
-      {submitText}
-      {#if submitIcon}
-        <Icon icon={submitIcon}/>
-      {/if}
-    </Button>
-    <Button disabled={submitting} on:click={() => modal.cancelModal()}>
-      {cancelText}
-    </Button>
+  <svelte:fragment slot="actions" let:submitting let:close>
+    {#if !done}
+      <Button variant={submitVariant} loading={submitting} on:click={() => modal.submitModal()}>
+        {submitText}
+        {#if submitIcon}
+          <Icon icon={submitIcon}/>
+        {/if}
+      </Button>
+      <Button disabled={submitting} on:click={() => modal.cancelModal()}>
+        {cancelText}
+      </Button>
+    {:else}
+      <Button variant="btn-primary" on:click={close}>
+        {doneText}
+      </Button>
+    {/if}
   </svelte:fragment>
 </Modal>
diff --git a/frontend/src/lib/components/modals/FormModal.svelte b/frontend/src/lib/components/modals/FormModal.svelte
index 3372e7db9..c4956060b 100644
--- a/frontend/src/lib/components/modals/FormModal.svelte
+++ b/frontend/src/lib/components/modals/FormModal.svelte
@@ -22,13 +22,11 @@
   type Schema = $$Generic<ZodObject>;
   type FormType = z.infer<Schema>;
   type SubmitCallback = FormSubmitCallback<Schema>;
-  type FormModalOptions = {
-    keepOpenOnSubmit?: boolean;
-  };
 
   export let schema: Schema;
   export let submitVariant: SubmitVariant = 'btn-primary';
   export let hideActions: boolean = false;
+  export let showDoneState: boolean = false;
 
   const superForm = lexSuperForm(schema, () => modal.submitModal());
   const { form: _form, errors, reset, message, enhance, formState, tainted } = superForm;
@@ -38,13 +36,11 @@
   export async function open(
     value: Partial<FormType> | undefined,  //eslint-disable-line @typescript-eslint/no-redundant-type-constituents
     onSubmit: SubmitCallback,
-    options?: FormModalOptions,
   ): Promise<FormModalResult<Schema>>;
   export async function open(onSubmit: SubmitCallback): Promise<FormModalResult<Schema>>;
   export async function open(
     valueOrOnSubmit: Partial<FormType> | SubmitCallback | undefined,  //eslint-disable-line @typescript-eslint/no-redundant-type-constituents
     _onSubmit?: SubmitCallback,
-    options?: FormModalOptions,
   ): Promise<FormModalResult<Schema>> {
     done = false;
     const onSubmit = _onSubmit ?? (valueOrOnSubmit as SubmitCallback);
@@ -57,15 +53,11 @@
 
     const response = await openModal(onSubmit);
     const _formState = $formState; // we need to read the form state before the modal closes or it will be reset
-    if (response !== DialogResponse.Submit || !options?.keepOpenOnSubmit)
+    if (response !== DialogResponse.Submit || !showDoneState)
       modal?.close();
     return { response, formState: _formState };
   }
 
-  export function close(): void {
-    modal?.close();
-  }
-
   export function form(): Readable<FormType> {
     return superForm.form;
   }
@@ -88,7 +80,7 @@
   }
 </script>
 
-<Modal bind:this={modal} on:close={() => reset()} bottom closeOnClickOutside={!$tainted}>
+<Modal bind:this={modal} on:close={() => reset()} bottom closeOnClickOutside={!$tainted} {hideActions}>
   <Form id="modalForm" {enhance}>
     <p class="mb-4 text-lg font-bold"><slot name="title" /></p>
     <slot errors={$errors} />
@@ -97,17 +89,15 @@
   <svelte:fragment slot="extraActions">
     <slot name="extraActions" />
   </svelte:fragment>
-  <svelte:fragment slot="actions" let:submitting>
-    {#if !hideActions}
-      {#if !done}
-        <SubmitButton form="modalForm" variant={submitVariant} loading={submitting}>
-          <slot name="submitText" />
-        </SubmitButton>
-      {:else}
-        <Button variant="btn-primary" on:click={close}>
-          <slot name="closeText" />
-        </Button>
-      {/if}
+  <svelte:fragment slot="actions" let:submitting let:close>
+    {#if !done}
+      <SubmitButton form="modalForm" variant={submitVariant} loading={submitting}>
+        <slot name="submitText" />
+      </SubmitButton>
+    {:else}
+      <Button variant="btn-primary" on:click={close}>
+        <slot name="doneText" />
+      </Button>
     {/if}
   </svelte:fragment>
 </Modal>
diff --git a/frontend/src/lib/components/modals/Modal.svelte b/frontend/src/lib/components/modals/Modal.svelte
index 95acf1995..8c3ab2647 100644
--- a/frontend/src/lib/components/modals/Modal.svelte
+++ b/frontend/src/lib/components/modals/Modal.svelte
@@ -25,6 +25,7 @@
   export let bottom = false;
   export let showCloseButton = true;
   export let closeOnClickOutside = true;
+  export let hideActions: boolean = false;
 
   export async function openModal(autoCloseOnCancel = true, autoCloseOnSubmit = false): Promise<DialogResponse> {
     $dialogResponse = null;
@@ -114,13 +115,13 @@
         </button>
       {/if}
       <slot {closing} {submitting} />
-      {#if $$slots.actions}
+      {#if $$slots.actions && !hideActions}
         <div class="modal-action">
           <div class="flex gap-4">
             <slot name="extraActions" />
           </div>
           <div class="flex gap-4">
-            <slot name="actions" {closing} {submitting} />
+            <slot name="actions" {closing} {submitting} {close} />
           </div>
         </div>
       {/if}
diff --git a/frontend/src/lib/error/utils.ts b/frontend/src/lib/error/utils.ts
new file mode 100644
index 000000000..1c8a754e1
--- /dev/null
+++ b/frontend/src/lib/error/utils.ts
@@ -0,0 +1,16 @@
+export function tryGetErrorMessage(error: unknown): string | undefined {
+  if (error === null || error === undefined) {
+    return undefined;
+  } else if (typeof error === 'string') {
+    return error;
+  }
+
+  const _error = (error ?? {}) as Record<string, string>;
+  return (
+      _error.message ??
+      _error.reason ??
+      _error.cause ??
+      _error.error ??
+      _error.code
+  );
+}
diff --git a/frontend/src/lib/forms/Button.svelte b/frontend/src/lib/forms/Button.svelte
index 595e951ba..f0a49227a 100644
--- a/frontend/src/lib/forms/Button.svelte
+++ b/frontend/src/lib/forms/Button.svelte
@@ -2,6 +2,7 @@
   import Loader from '$lib/components/Loader.svelte';
 
   export let loading = false;
+  export let active = false;
   export let variant: 'btn-primary' | 'btn-success' | 'btn-error' | 'btn-ghost' | 'btn-warning' | 'btn-accent' | undefined = undefined;
   export let outline = false;
   export let type: undefined | 'submit' = undefined;
@@ -13,6 +14,7 @@
 <!-- https://daisyui.com/components/button -->
 <button on:click {...$$restProps} class="btn whitespace-nowrap {variant ?? ''} {$$restProps.class ?? ''} {size ?? ''}" {type}
   class:btn-outline={outline}
+  class:btn-active={active}
   disabled={disabled && !loading}
   class:pointer-events-none={loading || $$restProps.class?.includes('pointer-events-none')}>
   {#if !customLoader}
diff --git a/frontend/src/lib/forms/FormError.svelte b/frontend/src/lib/forms/FormError.svelte
index f025ec02f..8613c6e8e 100644
--- a/frontend/src/lib/forms/FormError.svelte
+++ b/frontend/src/lib/forms/FormError.svelte
@@ -9,7 +9,7 @@
 </script>
 
 {#if error}
-  <span class="label text-lg text-error" class:justify-end={right}>
+  <span class="label text-lg text-error pl-0" class:justify-end={right}>
     {#if markdown}
       <Markdown md={error} plugins={[{ renderer: { a: NewTabLinkRenderer } }]} />
     {:else}
diff --git a/frontend/src/lib/forms/FormField.svelte b/frontend/src/lib/forms/FormField.svelte
index 2f3819ea0..bc83b1107 100644
--- a/frontend/src/lib/forms/FormField.svelte
+++ b/frontend/src/lib/forms/FormField.svelte
@@ -38,7 +38,7 @@
   </label>
   <slot />
   {#if description}
-    <label for={id} class="label pb-0">
+    <label for={id} class="label pb-0 underline-links">
       <span class="label-text-alt description">
         <Markdown md={description} plugins={[{ renderer: { a: NewTabLinkRenderer } }]} />
       </span>
@@ -46,9 +46,3 @@
   {/if}
   <FormFieldError {id} {error} />
 </div>
-
-<style lang="postcss">
-  :global(.form-control .label a) {
-    @apply link;
-  }
-</style>
diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json
index 61096d4b7..b6ff1a77f 100644
--- a/frontend/src/lib/i18n/locales/en.json
+++ b/frontend/src/lib/i18n/locales/en.json
@@ -262,7 +262,36 @@ Lexbox is free and [open source](https://github.com/sillsdev/languageforge-lexbo
         "project_now_not_confidential": "Project is now not confidential.",
       }
     },
-    "awaiting_approval": "Awaiting approval"
+    "awaiting_approval": "Awaiting approval",
+    "crdt": {
+      "sync_fwlite": "Sync FieldWorks Lite",
+      "sync_result": "{fwdataChanges} changes synced to FieldWorks. {crdtChanges} changes synced to FieldWorks Lite.",
+      "try_fw_lite": "Try FieldWorks Lite?",
+      "try_info": "This will make your project available in [FieldWorks Lite](https://lexbox.org/fw-lite). \
+This is still experimental and it will affect your FieldWorks project data. \
+However, **all your data is safe and backed up** here on Lexbox. \
+If you run into problems with your project please tell support that you are using FieldWorks Lite. \n\n\
+Are you ready to try FieldWorks Lite?",
+      "submit": "Yes, please!",
+      "cancel": "Not yet",
+      "finish": "Close",
+      "making_available": "Making your project available in FieldWorks Lite...",
+      "while_you_wait": "While you're waiting you can: \n\
+1. Install [FieldWorks Lite](https://lexbox.org/fw-lite). \n\
+1. Login to Lexbox from FieldWorks Lite. \n\
+",
+      "now_available": "Your project is now available in FieldWorks Lite!",
+      "to_start_using": "To get started: \n\
+1. Install [FieldWorks Lite](https://lexbox.org/fw-lite). \n\
+1. Login to Lexbox from FieldWorks Lite. \n\
+1. Download this project under the Lexbox server section (you may need to refresh the list of projects). \n\
+1. After working in FieldWorks Lite, come back here and click 'Sync FieldWorks Lite'. \n\
+1. Then you can download those changes into FieldWorks by doing a Send/Receive.",
+      "reach_out_for_help": "Please reach out to us at \
+[lexbox_support@groups.sil.org](mailto:lexbox_support@groups.sil.org?subject={subject}), \
+so we can get this fixed!",
+      "email_subject": "FieldWorks Lite setup error ({projectCode})",
+    },
   },
   "org_page": {
     "organization": "Organization",
@@ -552,6 +581,7 @@ If you don't see a dialog or already closed it, click the button below:",
       "leave_success": "You have left project {projectName}.",
       "leave_project": "Leave Project"
     },
+    "using_fw_lite": "Using FieldWorks Lite"
   },
   "org_role": {
     "label": "Role",
@@ -759,6 +789,7 @@ If you don't see a dialog or already closed it, click the button below:",
     "any": "Any",
     "yes_no": "{value, select, true {Yes} false {No} other {Unknown}}",
     "did_you_know": "Did you know?",
+    "close": "Close",
   },
   "viewer": {
     "about": "## What is this?\n\
diff --git a/frontend/src/lib/icons/Icon.svelte b/frontend/src/lib/icons/Icon.svelte
index 874b49d13..c86e10f0e 100644
--- a/frontend/src/lib/icons/Icon.svelte
+++ b/frontend/src/lib/icons/Icon.svelte
@@ -19,5 +19,5 @@
 </script>
 
 {#if icon}
-  <span class="{icon} {size} {color ?? ''} shrink-0 {spinReverse ? 'transform rotate-180' : ''}" class:pale style:transform class:animate-spin={spin} />
+  <span class="{icon} {size} {color ?? ''} shrink-0" class:rotate-180={spinReverse} class:pale style:transform class:animate-spin={spin}></span>
 {/if}
diff --git a/frontend/src/lib/layout/DetailItem.svelte b/frontend/src/lib/layout/DetailItem.svelte
index 185a29c22..2c86855fc 100644
--- a/frontend/src/lib/layout/DetailItem.svelte
+++ b/frontend/src/lib/layout/DetailItem.svelte
@@ -6,14 +6,15 @@
   export let text: string | null | undefined = undefined;
   export let copyToClipboard = false;
   export let loading = false;
+  export let wrap = false;
 </script>
 
-<div class="text-lg flex items-center gap-2">
+<div class="text-lg flex items-center gap-2 detail-item whitespace-nowrap" class:flex-wrap={wrap}>
   {title}:
   {#if loading}
     <Loader loading size="loading-xs" />
   {:else if text}
-    <span class="text-secondary">{text}</span>
+    <span class="text-secondary x-ellipsis">{text}</span>
   {:else}
     <slot/>
   {/if}
diff --git a/frontend/src/lib/layout/DetailsPage.svelte b/frontend/src/lib/layout/DetailsPage.svelte
index cab2eca6f..fd554c2fa 100644
--- a/frontend/src/lib/layout/DetailsPage.svelte
+++ b/frontend/src/lib/layout/DetailsPage.svelte
@@ -21,7 +21,7 @@
     <slot name="headerContent" />
   </svelte:fragment>
   {#if $$slots.details}
-    <div class="my-4 space-y-2">
+    <div class="my-4 space-y-2 details">
       <p class="text-2xl mb-4">{$t('project_page.summary')}</p>
       <slot name="details" />
     </div>
diff --git a/frontend/src/lib/layout/FeatureFlagContent.svelte b/frontend/src/lib/layout/FeatureFlagContent.svelte
index 468803d3e..18fa5a86e 100644
--- a/frontend/src/lib/layout/FeatureFlagContent.svelte
+++ b/frontend/src/lib/layout/FeatureFlagContent.svelte
@@ -3,7 +3,7 @@
   import type {FeatureFlag} from '$lib/gql/types';
   import {hasFeatureFlag} from '$lib/user';
 
-  export let flag: FeatureFlag;
+  export let flag: FeatureFlag | keyof typeof FeatureFlag;
 </script>
 <!-- eslint-disable-next-line @typescript-eslint/no-unsafe-argument -->
 {#if hasFeatureFlag($page.data.user, flag)}
diff --git a/frontend/src/lib/user.ts b/frontend/src/lib/user.ts
index 9dacafd44..676e90256 100644
--- a/frontend/src/lib/user.ts
+++ b/frontend/src/lib/user.ts
@@ -26,7 +26,7 @@ type RegisterResponseErrors = {
 
 type ApiLexboxAudience = 'LexboxApi' | 'Unknown';
 
-export const allPossibleFlags = Object.values(FeatureFlag) as FeatureFlag[];
+export const allPossibleFlags = Object.values(FeatureFlag);
 
 type JwtTokenUser = {
   sub: string
@@ -215,7 +215,7 @@ export function jwtToUser(user: JwtTokenUser): LexAuthUser {
   }
 }
 
-export function hasFeatureFlag(user: LexAuthUser, flag: FeatureFlag): boolean {
+export function hasFeatureFlag(user: LexAuthUser, flag: FeatureFlag | keyof typeof FeatureFlag): boolean {
   const searchTerm = flag.replaceAll('_', '').toLowerCase();
   return !!user.featureFlags.find(f => f.toLowerCase() === searchTerm);
 }
diff --git a/frontend/src/routes/(authenticated)/org/[org_id]/BulkAddOrgMembers.svelte b/frontend/src/routes/(authenticated)/org/[org_id]/BulkAddOrgMembers.svelte
index 2fb829259..76376d0f6 100644
--- a/frontend/src/routes/(authenticated)/org/[org_id]/BulkAddOrgMembers.svelte
+++ b/frontend/src/routes/(authenticated)/org/[org_id]/BulkAddOrgMembers.svelte
@@ -69,7 +69,7 @@
       notFoundMembers = data?.bulkAddOrgMembers.bulkAddOrgMembersResult?.notFoundMembers ?? [];
       existingMembers = data?.bulkAddOrgMembers.bulkAddOrgMembersResult?.existingMembers ?? [];
       return error?.message;
-    }, { keepOpenOnSubmit: true });
+    });
 
     if (response === DialogResponse.Submit) {
       await invalidate(`org:${orgId}`);
@@ -78,7 +78,7 @@
   }
 </script>
 
-<FormModal bind:this={formModal} {schema} let:errors>
+<FormModal bind:this={formModal} {schema} let:errors showDoneState>
     <span slot="title">
       {$t('org_page.bulk_add_members.modal_title')}
       <SupHelp helpLink={helpLinks.bulkAddCreate} />
@@ -142,7 +142,7 @@
       <p>Internal error: unknown step {currentStep}</p>
     {/if}
     <span slot="submitText">{$t('org_page.bulk_add_members.submit_button')}</span>
-    <span slot="closeText">{$t('org_page.bulk_add_members.finish_button')}</span>
+    <span slot="doneText">{$t('org_page.bulk_add_members.finish_button')}</span>
   </FormModal>
 
 <style lang="postcss">
diff --git a/frontend/src/routes/(authenticated)/project/[project_code]/+page.svelte b/frontend/src/routes/(authenticated)/project/[project_code]/+page.svelte
index f990c18ec..a2ef8c0a3 100644
--- a/frontend/src/routes/(authenticated)/project/[project_code]/+page.svelte
+++ b/frontend/src/routes/(authenticated)/project/[project_code]/+page.svelte
@@ -32,7 +32,7 @@
   import {_deleteProject} from '$lib/gql/mutations';
   import { goto } from '$app/navigation';
   import MoreSettings from '$lib/components/MoreSettings.svelte';
-  import { AdminContent, PageBreadcrumb } from '$lib/layout';
+  import {AdminContent, FeatureFlagContent, PageBreadcrumb} from '$lib/layout';
   import Markdown from 'svelte-exmarkdown';
   import { OrgRole, ProjectRole, ProjectType, ResetStatus, RetentionPolicy } from '$lib/gql/generated/graphql';
   import Icon from '$lib/icons/Icon.svelte';
@@ -332,8 +332,12 @@
           <span class="i-mdi-dictionary text-2xl"/>
         </a>
       {/if}
+      {#if project.type === ProjectType.FlEx}
+        <FeatureFlagContent flag="FwLiteBeta">
+          <CrdtSyncButton {project} hasHarmonyCommits={project.hasHarmonyCommits} />
+        </FeatureFlagContent>
+      {/if}
       {#if project.type === ProjectType.FlEx && $isDev}
-        <CrdtSyncButton projectId={project.id} />
         <OpenInFlexModal bind:this={openInFlexModal} {project}/>
         <OpenInFlexButton projectId={project.id} on:click={openInFlexModal.open}/>
       {:else if canAskToJoinProject}
@@ -440,6 +444,11 @@
             </Badge>
           </button>
         {/if}
+        {#if project.hasHarmonyCommits}
+          <Badge>
+            {$t('project_page.using_fw_lite')}
+          </Badge>
+        {/if}
       </BadgeList>
       <ProjectConfidentialityModal bind:this={projectConfidentialityModal} projectId={project.id} isConfidential={project.isConfidential ?? undefined} />
     </svelte:fragment>
@@ -478,32 +487,34 @@
         </DetailItem>
       {/if}
       {#if project.type === ProjectType.FlEx}
-        <DetailItem title={$t('project_page.vernacular_langs')}>
-          <WritingSystemList writingSystems={vernacularLangTags} />
-          <AdminContent>
-            <IconButton
-              loading={loadingLanguageList}
-              icon="i-mdi-refresh"
-              size="btn-sm"
-              variant="btn-ghost"
-              outline={false}
-              on:click={updateLanguageList}
-            />
-          </AdminContent>
-        </DetailItem>
-        <DetailItem title={$t('project_page.analysis_langs')}>
-          <WritingSystemList writingSystems={analysisLangTags} />
-          <AdminContent>
-            <IconButton
-              loading={loadingLanguageList}
-              icon="i-mdi-refresh"
-              size="btn-sm"
-              variant="btn-ghost"
-              outline={false}
-              on:click={updateLanguageList}
-            />
-          </AdminContent>
-        </DetailItem>
+        <div class="space-y-2">
+          <DetailItem title={$t('project_page.vernacular_langs')} wrap>
+            <AdminContent>
+              <IconButton
+                loading={loadingLanguageList}
+                icon="i-mdi-refresh"
+                size="btn-sm"
+                variant="btn-ghost"
+                outline={false}
+                on:click={updateLanguageList}
+              />
+            </AdminContent>
+            <WritingSystemList writingSystems={vernacularLangTags} />
+          </DetailItem>
+          <DetailItem title={$t('project_page.analysis_langs')} wrap>
+            <AdminContent>
+              <IconButton
+                loading={loadingLanguageList}
+                icon="i-mdi-refresh"
+                size="btn-sm"
+                variant="btn-ghost"
+                outline={false}
+                on:click={updateLanguageList}
+              />
+            </AdminContent>
+            <WritingSystemList writingSystems={analysisLangTags} />
+          </DetailItem>
+        </div>
       {/if}
       <div>
         <EditableDetailItem
diff --git a/frontend/src/routes/(authenticated)/project/[project_code]/+page.ts b/frontend/src/routes/(authenticated)/project/[project_code]/+page.ts
index 463ab4b2b..1fbb5a833 100644
--- a/frontend/src/routes/(authenticated)/project/[project_code]/+page.ts
+++ b/frontend/src/routes/(authenticated)/project/[project_code]/+page.ts
@@ -59,6 +59,7 @@ export async function load(event: PageLoadEvent) {
 						retentionPolicy
 						isConfidential
             isLanguageForgeProject
+            hasHarmonyCommits
 						organizations {
 							id
 						}
diff --git a/frontend/src/routes/(authenticated)/project/[project_code]/BulkAddProjectMembers.svelte b/frontend/src/routes/(authenticated)/project/[project_code]/BulkAddProjectMembers.svelte
index 3355eb3ac..c32fe3daa 100644
--- a/frontend/src/routes/(authenticated)/project/[project_code]/BulkAddProjectMembers.svelte
+++ b/frontend/src/routes/(authenticated)/project/[project_code]/BulkAddProjectMembers.svelte
@@ -80,7 +80,7 @@
       createdMembers = data?.bulkAddProjectMembers.bulkAddProjectMembersResult?.createdMembers ?? [];
       existingMembers = data?.bulkAddProjectMembers.bulkAddProjectMembersResult?.existingMembers ?? [];
       return error?.message;
-    }, { keepOpenOnSubmit: true });
+    });
 
     if (response === DialogResponse.Submit) {
       currentStep = BulkAddSteps.Results;
@@ -93,7 +93,7 @@
     {$t('project_page.bulk_add_members.add_button')}
   </BadgeButton>
 
-  <FormModal bind:this={formModal} {schema} let:errors>
+  <FormModal bind:this={formModal} {schema} let:errors showDoneState>
     <span slot="title">
       {$t('project_page.bulk_add_members.modal_title')}
       <SupHelp helpLink={helpLinks.bulkAddCreate} />
@@ -171,7 +171,7 @@
       <p>Internal error: unknown step {currentStep}</p>
     {/if}
     <span slot="submitText">{$t('project_page.bulk_add_members.submit_button')}</span>
-    <span slot="closeText">{$t('project_page.bulk_add_members.finish_button')}</span>
+    <span slot="doneText">{$t('project_page.bulk_add_members.finish_button')}</span>
   </FormModal>
 </AdminContent>
 
diff --git a/frontend/src/routes/(authenticated)/project/[project_code]/CrdtSyncButton.svelte b/frontend/src/routes/(authenticated)/project/[project_code]/CrdtSyncButton.svelte
index 324c40126..d69d31562 100644
--- a/frontend/src/routes/(authenticated)/project/[project_code]/CrdtSyncButton.svelte
+++ b/frontend/src/routes/(authenticated)/project/[project_code]/CrdtSyncButton.svelte
@@ -1,43 +1,157 @@
 <script lang="ts">
-  import {Button} from '$lib/forms';
+  import {NewTabLinkRenderer} from '$lib/components/Markdown';
+  import {tryGetErrorMessage} from '$lib/error/utils';
+  import {Button, FormError} from '$lib/forms';
+  import t from '$lib/i18n';
   import {Icon} from '$lib/icons';
   import {useNotifications} from '$lib/notify';
+  import Markdown from 'svelte-exmarkdown';
+  import {bounceIn} from 'svelte/easing';
+  import {scale} from 'svelte/transition';
+  import type {Project} from './+page';
+  import {Modal} from '$lib/components/modals';
 
-  export let projectId: string;
+  export let project: Project;
+  export let hasHarmonyCommits: boolean;
+  type SyncResult = {crdtChanges: number, fwdataChanges: number};
 
-  const {notifySuccess, notifyWarning} = useNotifications();
+  const { notifySuccess, notifyWarning } = useNotifications();
 
   let syncing = false;
+  let done = false;
+  $: state = done ? 'done' : syncing ? 'syncing' : 'idle';
+  let error: string | undefined = undefined;
 
-  async function triggerSync(): Promise<void> {
+  async function triggerSync(): Promise<string | undefined> {
     syncing = true;
     try {
-      const response = await fetch(`/api/crdt/sync/${projectId}`, {
+      const response = await fetch(`/api/fw-lite/sync/trigger/${project.id}`, {
         method: 'POST',
       });
 
       if (response.ok) {
-        const { crdtChanges, fwdataChanges } = await response.json();
-        notifySuccess(`Synced successfully (${fwdataChanges} FwData changes. ${crdtChanges} CRDT changes)`);
-      } else {
-        const error = `Failed to sync: ${response.statusText} (${response.status})`;
-        notifyWarning(error);
-        console.error(error, await response.text());
+        const syncResults = await awaitSyncFinished();
+        if (typeof syncResults === 'string') {
+          return syncResults;
+        }
+        notifySuccess($t('project.crdt.sync_result', { fwdataChanges: syncResults.fwdataChanges, crdtChanges: syncResults.crdtChanges }));
+        done = true;
+        return;
       }
+      const error = `Failed to sync: ${response.statusText} (${response.status})`;
+      notifyWarning(error);
+      console.error(error, await response.text());
+      return error;
+    } catch (error) {
+      return tryGetErrorMessage(error);
     } finally {
       syncing = false;
     }
   }
+
+  async function awaitSyncFinished(): Promise<SyncResult | string> {
+    // eslint-disable-next-line no-constant-condition
+    while (true) {
+      try {
+        const response = await fetch(`/api/fw-lite/sync/await-sync-finished/${project.id}`, {signal: AbortSignal.timeout(30_000)});
+        if (response.status === 500) {
+          return 'Sync failed, please contact support';
+        }
+        if (response.status === 200) {
+          const result = await response.json() as SyncResult;
+          return result;
+        }
+      } catch (error) {
+        if (error instanceof DOMException && (error.name === 'AbortError' || error.name === 'TimeoutError')) {
+          continue;
+        }
+        return tryGetErrorMessage(error) ?? 'Unknown error';
+      }
+
+    }
+  }
+
+  async function onSubmit(): Promise<void> {
+    error = await triggerSync();
+  }
+
+  async function syncProject(): Promise<void> {
+    let error = await triggerSync();
+    if (error) notifyWarning(error);
+  }
+
+  async function useInFwLite(): Promise<void> {
+    await modal.openModal();
+  }
+  let modal: Modal;
 </script>
 
-<Button
-  variant="btn-primary"
-  class="gap-1"
-  on:click={triggerSync}
-  loading={syncing}
-  customLoader
->
-  FwData
-  <Icon icon="i-mdi-sync" spin={syncing} spinReverse />
-  CRDT
-</Button>
+{#if hasHarmonyCommits}
+  <Button variant="btn-primary" class="gap-1 indicator" on:click={syncProject} loading={state === 'syncing'} active={state === 'syncing'} customLoader>
+    <span class="indicator-item badge badge-sm badge-accent translate-x-[calc(50%-16px)] shadow">Beta</span>
+    {$t('project.crdt.sync_fwlite')}
+    <span style="transform: rotateY(180deg)">
+      <Icon icon="i-mdi-sync" spin={state === 'syncing'} spinReverse/>
+    </span>
+  </Button>
+{:else}
+  <Button variant="btn-primary" class="indicator" on:click={useInFwLite}>
+    <span class="indicator-item badge badge-sm badge-accent translate-x-[calc(50%-16px)] shadow">Beta</span>
+    <span>
+      {$t('project.crdt.try_fw_lite')}
+    </span>
+  </Button>
+  <Modal bind:this={modal} showCloseButton={false} hideActions={state === 'syncing'} closeOnClickOutside={false}>
+    <h2 class="text-xl mb-6">
+      {#if state === 'syncing'}
+        {$t('project.crdt.making_available')}
+      {:else if state === 'done'}
+        {$t('project.crdt.now_available')}
+      {:else}
+        {$t('project.crdt.try_fw_lite')}
+      {/if}
+    </h2>
+    {#if state === 'syncing'}
+      <div class="mb-6 prose max-w-none underline-links">
+        <Markdown md={$t('project.crdt.while_you_wait')} plugins={[{ renderer: { a: NewTabLinkRenderer } }]} />
+      </div>
+      <p class="text-center">
+        <span class="loading loading-lg"></span>
+      </p>
+    {:else if state === 'done'}
+      <div class="prose max-w-none underline-links">
+        <Markdown md={$t('project.crdt.to_start_using')} plugins={[{ renderer: { a: NewTabLinkRenderer } }]} />
+        <p class="text-center">
+          <span
+            class="i-mdi-check-circle-outline text-7xl text-center text-success"
+            transition:scale={{ duration: 600, start: 0.7, easing: bounceIn }}
+          ></span>
+        </p>
+      </div>
+    {:else}
+      <div class="prose max-w-none underline-links">
+        <Markdown md={$t('project.crdt.try_info')} plugins={[{ renderer: { a: NewTabLinkRenderer } }]} />
+        {#if error}
+          <Markdown
+            md={`${$t('errors.apology')} ${$t('project.crdt.reach_out_for_help', { subject: encodeURIComponent($t('project.crdt.email_subject', { projectCode: project.code }))})}`}
+            plugins={[{ renderer: { a: NewTabLinkRenderer } }]} />
+        {/if}
+      </div>
+      <FormError {error} right/>
+    {/if}
+    <svelte:fragment slot="actions" let:close>
+      {#if state === 'idle'}
+        <Button variant="btn-primary" on:click={onSubmit}>
+          {$t('project.crdt.submit')}
+        </Button>
+        <Button on:click={close}>
+          {$t('project.crdt.cancel')}
+        </Button>
+      {:else if state === 'done'}
+        <Button variant="btn-primary" on:click={close}>
+          {$t('project.crdt.finish')}
+        </Button>
+      {/if}
+    </svelte:fragment>
+  </Modal>
+{/if}