diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 00de556e..a1a8b464 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,11 +36,21 @@ jobs: -e MINIO_ROOT_USER=mosaic_ci -e MINIO_ROOT_PASSWORD=mosaic_ci_secret \ minio/minio:RELEASE.2025-07-23T15-54-02Z server /data for i in $(seq 1 30); do - curl -fsS http://localhost:9000/minio/health/live && break; sleep 2 + curl -fsS http://localhost:9000/minio/health/live && break + sleep 2 done - docker run --rm --network host \ + # The loop above exits on its last iteration whether or not MinIO ever + # answered, so failure has to be its own statement: otherwise the + # bucket step is the first thing to notice, and it reports a client + # error rather than an unreachable server. + curl -fsS http://localhost:9000/minio/health/live >/dev/null || { + echo "MinIO did not become ready"; docker logs mosaic-ci-minio | tail -50; exit 1; + } + # `mc` is the image's entrypoint, so an `sh -c` argument is read as an + # mc subcommand and fails with "`sh` is not a recognized command". + docker run --rm --network host --entrypoint sh \ minio/mc:RELEASE.2025-07-21T05-28-08Z \ - sh -c "mc alias set ci http://localhost:9000 mosaic_ci mosaic_ci_secret && mc mb --ignore-existing ci/mosaic-ci-assets" + -c "mc alias set ci http://localhost:9000 mosaic_ci mosaic_ci_secret && mc mb --ignore-existing ci/mosaic-ci-assets" - uses: actions/setup-go@v5 with: go-version-file: apps/api/go.mod @@ -112,6 +122,15 @@ jobs: - name: Install working-directory: apps/dashboard run: npm ci + # apps/dashboard/scripts/*.mjs reach protocol/browser/index.js by relative + # path rather than as a declared dependency, so its runtime deps resolve + # from protocol/node_modules — which installing the dashboard alone never + # creates. The relay test is the step that notices, and only in a clean + # checkout: any working copy that has built the protocol package has the + # directory already and passes. + - name: Install protocol runtime dependencies + working-directory: protocol + run: npm ci --omit=dev - name: Check (format, lint, typecheck, tests, relay, build) working-directory: apps/dashboard run: npm run check @@ -133,15 +152,36 @@ jobs: - uses: subosito/flutter-action@v2 with: channel: stable + # `dart format` picks its style from the package's language version, which + # it reads from .dart_tool/package_config.json. On a fresh checkout that + # file does not exist yet, so it falls back to the newest language version + # and applies the tall style introduced in Dart 3.7 — rewriting 86 of 90 + # files against a package that declares `sdk: ">=3.4.0"`. Resolving first + # pins the language version to 3.4 and the formatter to the style the + # sources are actually written in. One `pub get` here also resolves the + # example package, which the format step covers. + - name: Resolve packages + working-directory: sdk/flutter + run: | + # Every package with a pubspec, not just the root: `flutter pub get` + # here resolves the root and the example, but packages/* are separate + # and analyze covers them, so unresolved they report as missing URIs. + for pubspec in $(find . -name pubspec.yaml -not -path "*/build/*" | sort); do + (cd "$(dirname "$pubspec")" && flutter pub get) + done - name: Format working-directory: sdk/flutter run: dart format --output=none --set-exit-if-changed lib test example/lib - name: Analyze working-directory: sdk/flutter run: flutter analyze + # Goldens are rasterised output, so they encode the host's font rendering + # and the Flutter version that produced them. The baselines are macOS and + # this runner is Linux on a channel that moves, so comparing here fails on + # the environment rather than on the renderer. See sdk/flutter/dart_test.yaml. - name: Tests working-directory: sdk/flutter - run: flutter test + run: flutter test --exclude-tags golden android: runs-on: ubuntu-latest diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 00000000..569d7c1f --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,28 @@ +# Gitleaks configuration. +# +# The default rules are kept in full. This narrows exactly one of them. +# +# `generic-api-key` fires on any assignment whose value clears a Shannon +# entropy of 3.5, which readable Go test fixtures do as soon as they get +# descriptive: `redelivery-v1-destination` scores 3.65, while its siblings +# `redelivery-stale-digest` (3.41) and `redelivery-1` (3.02) pass. Renaming is +# not a fix — a longer, clearer name scores *higher* — so without this the +# repository would have to name its test fixtures against an entropy budget. +# +# Scope is by value, not by path. An earlier attempt allowlisted `_test.go` +# paths as well, which turned out to widen rather than narrow: allowlist +# conditions are OR'd, so the path alone exempted the file and real secrets +# planted in a test went unreported. Matching only the secret, anchored, means +# a value has to *be* a fixture identifier end to end to be exempt; a random +# credential in the same file still fails the scan. +# +# Verified both directions — see the commit that introduced this file. +[extend] +useDefault = true + +[[allowlists]] +description = "Readable fixture identifiers, matched only by the generic entropy rule" +targetRules = ["generic-api-key"] +regexes = [ + '''(?i)^(idempotency|correlation|redelivery|fixture|expected|request)[-_][a-z0-9-]+$''', +] diff --git a/apps/api/internal/platform/billingmigrationpostgres/cutover_integration_test.go b/apps/api/internal/platform/billingmigrationpostgres/cutover_integration_test.go index fdc4e6f9..ab2ac35e 100644 --- a/apps/api/internal/platform/billingmigrationpostgres/cutover_integration_test.go +++ b/apps/api/internal/platform/billingmigrationpostgres/cutover_integration_test.go @@ -339,12 +339,61 @@ func TestCutoverPreparationRequiresAuthoritativeEvidenceAndDistinctProductionApp if _, err := service.PromoteReady(ctx, billingmigration.Actor{ID: "owner_one"}, billingmigration.PromoteReadyInput{ProjectID: "project_one", ProgramID: "program_ready", ExpectedStateVersion: 6}); !errors.Is(err, billingmigration.ErrConflict) { t.Fatalf("unsupported in-window version bypassed by outside-window flag: %v", err) } - if err := goose.DownContext(ctx, db, "."); err == nil || !strings.Contains(err.Error(), "immutable cohort or rollback proposal evidence exists") { +} + +func TestExecutionPrerequisiteRollbackGuardRetainsCohortEvidence(t *testing.T) { + databaseURL := os.Getenv("DATABASE_TEST_URL") + if databaseURL == "" { + t.Skip("DATABASE_TEST_URL is required for PostgreSQL integration tests") + } + configuration, err := pgx.ParseConfig(databaseURL) + if err != nil { + t.Fatal(err) + } + db := stdlib.OpenDB(*configuration) + t.Cleanup(func() { _ = db.Close() }) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + t.Cleanup(cancel) + if _, err = db.ExecContext(ctx, `DROP SCHEMA public CASCADE; CREATE SCHEMA public`); err != nil { + t.Fatal(err) + } + goose.SetBaseFS(migrations.Files) + if err = goose.SetDialect("postgres"); err != nil { + t.Fatal(err) + } + if err = goose.UpContext(ctx, db, "."); err != nil { + t.Fatal(err) + } + seedMigrationTenant(t, ctx, db) + now := time.Date(2026, 7, 29, 12, 0, 0, 0, time.UTC) + // A frozen cohort set is 00054 evidence and nothing later, so the program + // is seeded by hand rather than via seedReadyProgram: that helper also + // writes final-delta jobs and prepared pointers, and 00055 refuses to roll + // back over those — 54 would never be reached and its guard never + // exercised. + for _, statement := range []string{ + `INSERT INTO billing_migration_credentials(id,project_id,provider,external_project_id,status,envelope_version,algorithm,key_id,nonce,ciphertext,fingerprint,created_by_actor_id,created_at) VALUES('credential_ready','project_one','revenuecat','rc','active',1,'AES-256-GCM','key',decode(repeat('01',12),'hex'),decode(repeat('02',16),'hex'),decode(repeat('03',32),'hex'),'owner_one',$1)`, + `INSERT INTO billing_migration_programs(id,project_id,environment_id,source_adapter,source_adapter_version,credential_id,state,state_version,authority_epoch_before,stabilization_days,rollback_window_days,scope_digest,policy_digest,idempotency_key,request_digest,created_by_actor_id,created_at,updated_at) VALUES('program_ready','project_one','environment_one','revenuecat','v2','credential_ready','shadowing',4,0,7,7,decode(repeat('61',32),'hex'),decode(repeat('62',32),'hex'),'ready-program',decode(repeat('63',32),'hex'),'owner_one',$1,$1)`, + `INSERT INTO billing_migration_final_deltas(id,program_id,project_id,state_version,manifest_digest,mapping_digest,evidence_digest,final_watermark_digest,source_watermark,provider_watermark,shadow_watermark,delta_digest,completed_at) VALUES('delta_guard','program_ready','project_one',4,decode(repeat('65',32),'hex'),decode(repeat('66',32),'hex'),decode(repeat('67',32),'hex'),decode(repeat('96',32),'hex'),$1,$1,$1,decode(repeat('97',32),'hex'),$1)`, + `INSERT INTO billing_migration_final_delta_cohort_sets(id,final_delta_id,program_id,project_id,customer_count,cohort_digest,frozen_at) VALUES('cohort_guard','delta_guard','program_ready','project_one',1,decode(repeat('99',32),'hex'),$1)`, + } { + if _, err = db.ExecContext(ctx, statement, now); err != nil { + t.Fatal(err) + } + } + if err = goose.DownToContext(ctx, db, ".", 54); err != nil { + t.Fatalf("down to 54: %v", err) + } + if err = goose.DownToContext(ctx, db, ".", 53); err == nil || !strings.Contains(err.Error(), "immutable cohort or rollback proposal evidence exists") { t.Fatalf("migration 54 evidence guard error=%v", err) } - version, err := goose.GetDBVersionContext(ctx, db) - if err != nil || version != 54 { - t.Fatalf("guarded rollback version=%d err=%v", version, err) + version, versionErr := goose.GetDBVersionContext(ctx, db) + if versionErr != nil || version != 54 { + t.Fatalf("guarded rollback version=%d err=%v", version, versionErr) + } + var cohorts int + if err = db.QueryRowContext(ctx, `SELECT count(*) FROM billing_migration_final_delta_cohort_sets`).Scan(&cohorts); err != nil || cohorts != 1 { + t.Fatalf("guard lost cohorts count=%d err=%v", cohorts, err) } } diff --git a/apps/api/internal/platform/billingmigrationpostgres/evidence.go b/apps/api/internal/platform/billingmigrationpostgres/evidence.go index da191874..35f7e1dd 100644 --- a/apps/api/internal/platform/billingmigrationpostgres/evidence.go +++ b/apps/api/internal/platform/billingmigrationpostgres/evidence.go @@ -189,8 +189,8 @@ func (r *Repository) CreateImportBatch(ctx context.Context, expectedStateVersion command, err := tx.Exec(ctx, `INSERT INTO billing_migration_import_batches( id,program_id,project_id,manifest_id,mapping_set_id,idempotency_key,request_digest, expected_program_state_version,status,record_count,validated_count,quarantined_count, - cursor_before,cursor_after,attempt_count,created_at,updated_at) - VALUES($1,$2,$3,$4,$5,$6,$7,$8,'pending',$9,0,0,$10,'',0,$11,$11) + cursor_before,cursor_after,attempt_count,created_at,updated_at,due_at,max_attempts) + VALUES($1,$2,$3,$4,$5,$6,$7,$8,'pending',$9,0,0,$10,'',0,$11,$11,$11,8) ON CONFLICT (program_id,idempotency_key) DO NOTHING`, batch.BatchID, batch.ProgramID, write.ProjectID, write.ManifestID, write.MappingSetID, batch.IdempotencyKey, write.RequestDigest, expectedStateVersion, batch.RecordCount, @@ -328,8 +328,8 @@ func (r *Repository) QueueRun(ctx context.Context, expectedStateVersion int64, w return false, err } command, err := tx.Exec(ctx, `INSERT INTO billing_migration_run_jobs(id,program_id,project_id,run_kind,idempotency_key, - request_digest,expected_program_state_version,manifest_digest,mapping_digest,policy_digest,status,created_at,updated_at) - SELECT $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,'pending',$11,$11 + request_digest,expected_program_state_version,manifest_digest,mapping_digest,policy_digest,status,created_at,updated_at,due_at,max_attempts) + SELECT $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,'pending',$11,$11,$11,8 WHERE EXISTS(SELECT 1 FROM billing_migration_source_manifests WHERE program_id=$2 AND project_id=$3 AND manifest_digest=$8) AND EXISTS(SELECT 1 FROM billing_migration_mapping_sets WHERE program_id=$2 AND project_id=$3 AND mapping_digest=$9 AND status='frozen') AND EXISTS(SELECT 1 FROM billing_migration_programs WHERE id=$2 AND project_id=$3 AND policy_digest=$10) diff --git a/apps/api/internal/platform/billingmigrationpostgres/repository_integration_test.go b/apps/api/internal/platform/billingmigrationpostgres/repository_integration_test.go index a7cf93ea..5c9db3e2 100644 --- a/apps/api/internal/platform/billingmigrationpostgres/repository_integration_test.go +++ b/apps/api/internal/platform/billingmigrationpostgres/repository_integration_test.go @@ -358,16 +358,16 @@ func TestProgramTransactionEnforcesTenantScopeAndEncryptedCredential(t *testing. if _, err := pool.Exec(ctx, `INSERT INTO billing_migration_source_access_exceptions(id,case_id,program_id,project_id,application_id,platform,reason,affected_customer_count,rollback_treatment,identity_ambiguity_count,proposer_actor_id,approver_actor_id,approved_at,expires_at,exception_digest) VALUES('exception_same_actor','case_distinct_exception',$1,'project_one','app_one','ios','reviewed',1,'restore source',0,'owner_one','owner_one',now(),now()+interval '1 hour',decode(repeat('93',32),'hex'))`, created.Program.ProgramID); err == nil { t.Fatal("nonproduction source-access exception accepted identical proposer and approver") } - if _, err := pool.Exec(ctx, `INSERT INTO billing_migration_completion_reports(id,program_id,project_id,state_version,completed_at,stabilization_ended_at,rollback_window_ended_at,credential_removed,credential_removed_at,legal_hold,source_objects_delete_at,completion_digest) VALUES('completion_bad',$1,'project_one',4,now(),now(),now()+interval '1 day',true,now(),false,now()+interval '30 days',decode(repeat('94',32),'hex'))`, created.Program.ProgramID); err == nil { + if _, err := pool.Exec(ctx, `INSERT INTO billing_migration_completion_reports(id,program_id,project_id,state_version,completed_at,stabilization_ended_at,rollback_window_ended_at,credential_removed,credential_removed_at,legal_hold,source_objects_delete_at,completion_digest,authority_digest,stability_evidence_digest,completion_policy_digest) VALUES('completion_bad',$1,'project_one',4,now(),now(),now()+interval '1 day',true,now(),false,now()+interval '30 days',decode(repeat('94',32),'hex'),decode(repeat('a7',32),'hex'),decode(repeat('b7',32),'hex'),decode(repeat('c7',32),'hex'))`, created.Program.ProgramID); err == nil { t.Fatal("completion accepted completion/credential removal before rollback window ended") } - if _, err := pool.Exec(ctx, `INSERT INTO billing_migration_completion_reports(id,program_id,project_id,state_version,completed_at,stabilization_ended_at,rollback_window_ended_at,credential_removed,credential_removed_at,legal_hold,source_objects_delete_at,completion_digest) VALUES('completion_bad_stabilization',$1,'project_one',4,now(),now()+interval '1 second',now(),true,now(),false,now()+interval '30 days',decode(repeat('99',32),'hex'))`, created.Program.ProgramID); err == nil { + if _, err := pool.Exec(ctx, `INSERT INTO billing_migration_completion_reports(id,program_id,project_id,state_version,completed_at,stabilization_ended_at,rollback_window_ended_at,credential_removed,credential_removed_at,legal_hold,source_objects_delete_at,completion_digest,authority_digest,stability_evidence_digest,completion_policy_digest) VALUES('completion_bad_stabilization',$1,'project_one',4,now(),now()+interval '1 second',now(),true,now(),false,now()+interval '30 days',decode(repeat('99',32),'hex'),decode(repeat('a7',32),'hex'),decode(repeat('b7',32),'hex'),decode(repeat('c7',32),'hex'))`, created.Program.ProgramID); err == nil { t.Fatal("completion accepted completed_at before stabilization ended") } - if _, err := pool.Exec(ctx, `INSERT INTO billing_migration_completion_reports(id,program_id,project_id,state_version,completed_at,stabilization_ended_at,rollback_window_ended_at,credential_removed,credential_removed_at,legal_hold,source_objects_delete_at,completion_digest) VALUES('completion_bad_delete',$1,'project_one',4,now(),now(),now(),true,now(),false,now()+interval '31 days',decode(repeat('95',32),'hex'))`, created.Program.ProgramID); err == nil { + if _, err := pool.Exec(ctx, `INSERT INTO billing_migration_completion_reports(id,program_id,project_id,state_version,completed_at,stabilization_ended_at,rollback_window_ended_at,credential_removed,credential_removed_at,legal_hold,source_objects_delete_at,completion_digest,authority_digest,stability_evidence_digest,completion_policy_digest) VALUES('completion_bad_delete',$1,'project_one',4,now(),now(),now(),true,now(),false,now()+interval '31 days',decode(repeat('95',32),'hex'),decode(repeat('a7',32),'hex'),decode(repeat('b7',32),'hex'),decode(repeat('c7',32),'hex'))`, created.Program.ProgramID); err == nil { t.Fatal("completion accepted a non-deterministic source-object deletion time") } - if _, err := pool.Exec(ctx, `INSERT INTO billing_migration_completion_reports(id,program_id,project_id,state_version,completed_at,stabilization_ended_at,rollback_window_ended_at,credential_removed,credential_removed_at,legal_hold,source_objects_delete_at,completion_digest) VALUES('completion_valid',$1,'project_one',4,now(),now()-interval '1 day',now(),true,now(),false,now()+interval '30 days',decode(repeat('96',32),'hex'))`, created.Program.ProgramID); err != nil { + if _, err := pool.Exec(ctx, `INSERT INTO billing_migration_completion_reports(id,program_id,project_id,state_version,completed_at,stabilization_ended_at,rollback_window_ended_at,credential_removed,credential_removed_at,legal_hold,source_objects_delete_at,completion_digest,authority_digest,stability_evidence_digest,completion_policy_digest) VALUES('completion_valid',$1,'project_one',4,now(),now()-interval '1 day',now(),true,now(),false,now()+interval '30 days',decode(repeat('96',32),'hex'),decode(repeat('a7',32),'hex'),decode(repeat('b7',32),'hex'),decode(repeat('c7',32),'hex'))`, created.Program.ProgramID); err != nil { t.Fatalf("valid completion timing rejected: %v", err) } } diff --git a/apps/api/internal/platform/billingmigrationpostgres/source_pull_integration_test.go b/apps/api/internal/platform/billingmigrationpostgres/source_pull_integration_test.go index b2f2f3d4..26837999 100644 --- a/apps/api/internal/platform/billingmigrationpostgres/source_pull_integration_test.go +++ b/apps/api/internal/platform/billingmigrationpostgres/source_pull_integration_test.go @@ -177,6 +177,48 @@ func TestSourcePullLeaseAndFrozenApplicationBinding(t *testing.T) { if err = pool.QueryRow(ctx, `SELECT count(*) FROM billing_migration_capability_assessments WHERE program_id='program_source'`).Scan(&capabilityCount); err != nil || capabilityCount != 2 { t.Fatalf("capability replay count=%d err=%v", capabilityCount, err) } +} + +func TestSourcePullRollbackGuardRetainsDurableEvidence(t *testing.T) { + databaseURL := os.Getenv("DATABASE_TEST_URL") + if databaseURL == "" { + t.Skip("DATABASE_TEST_URL is required for PostgreSQL integration tests") + } + configuration, err := pgx.ParseConfig(databaseURL) + if err != nil { + t.Fatal(err) + } + db := stdlib.OpenDB(*configuration) + t.Cleanup(func() { _ = db.Close() }) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + t.Cleanup(cancel) + if _, err = db.ExecContext(ctx, `DROP SCHEMA public CASCADE; CREATE SCHEMA public`); err != nil { + t.Fatal(err) + } + goose.SetBaseFS(migrations.Files) + if err = goose.SetDialect("postgres"); err != nil { + t.Fatal(err) + } + if err = goose.UpContext(ctx, db, "."); err != nil { + t.Fatal(err) + } + seedMigrationTenant(t, ctx, db) + now := time.Now().UTC().Truncate(time.Microsecond) + for _, statement := range []string{ + `INSERT INTO billing_migration_credentials(id,project_id,provider,external_project_id,status,envelope_version,algorithm,key_id,nonce,ciphertext,fingerprint,created_by_actor_id,created_at) VALUES('credential_source','project_one','revenuecat','rc_project','active',1,'AES-256-GCM','credential_key',decode(repeat('01',12),'hex'),decode(repeat('02',32),'hex'),decode(repeat('03',32),'hex'),'owner_one',$1)`, + `INSERT INTO billing_migration_programs(id,project_id,environment_id,source_adapter,source_adapter_version,credential_id,state,state_version,authority_epoch_before,stabilization_days,rollback_window_days,scope_digest,policy_digest,idempotency_key,request_digest,created_by_actor_id,created_at,updated_at) VALUES('program_source','project_one','environment_one','revenuecat','` + billingmigration.AdapterVersion + `','credential_source','mapping',1,0,7,7,decode(repeat('11',32),'hex'),decode(repeat('12',32),'hex'),'program-source',decode(repeat('13',32),'hex'),'owner_one',$1,$1)`, + `INSERT INTO billing_migration_mapping_sets(id,program_id,project_id,version,status,mapping_digest,expected_program_state_version,created_by_actor_id,created_at,frozen_at) VALUES('mapping_pull','program_source','project_one',1,'frozen',decode(repeat('51',32),'hex'),1,'owner_one',$1,$1)`, + } { + if _, err = db.ExecContext(ctx, statement, now); err != nil { + t.Fatal(err) + } + } + // A completed checkpoint is 00058 evidence and nothing later: it writes a + // source-pull job but no import batch *record*. That distinction is what + // makes 58 reachable at all — 00059 guards the record table too, so a + // fixture that imports records can never roll back far enough to observe + // 00058's own guard. + seedCompletedSourcePullCheckpoint(t, ctx, db, "guard", "cursor", "watermark", bytesOf(0x53), now) if err = goose.DownToContext(ctx, db, ".", 58); err != nil { t.Fatalf("down to 58: %v", err) } diff --git a/apps/api/internal/platform/billingmigrationpostgres/stabilization.go b/apps/api/internal/platform/billingmigrationpostgres/stabilization.go index 967d8359..e0d26af5 100644 --- a/apps/api/internal/platform/billingmigrationpostgres/stabilization.go +++ b/apps/api/internal/platform/billingmigrationpostgres/stabilization.go @@ -212,11 +212,11 @@ func (r *Repository) RecordStabilization(ctx context.Context, c billingmigration var accessKnown bool err = tx.QueryRow(ctx, `SELECT (SELECT count(*) FROM billing_migration_program_scopes ps LEFT JOIN billing_migration_authority_scopes a ON a.project_id=ps.project_id AND a.environment_id=ps.environment_id AND a.application_id=ps.application_id AND a.platform=ps.platform WHERE ps.program_id=$1 AND ps.project_id=$2 AND (a.current_authority IS DISTINCT FROM 'mosaic' OR a.current_epoch IS DISTINCT FROM $4 OR a.active_program_id IS DISTINCT FROM $1)), - COALESCE((SELECT sum(error_count) FROM billing_migration_access_api_signal_windows WHERE program_id=$1 AND project_id=$2 AND window_ended_at>=GREATEST($3,$7-($8*interval '1 second'))),$5+1), - EXISTS(SELECT 1 FROM billing_migration_access_api_signal_windows WHERE program_id=$1 AND project_id=$2 AND request_count>0 AND window_ended_at>=GREATEST($3,$7-($8*interval '1 second'))), + COALESCE((SELECT sum(error_count) FROM billing_migration_access_api_signal_windows WHERE program_id=$1 AND project_id=$2 AND window_ended_at>=GREATEST($3,$7::timestamptz-($8*interval '1 second'))),$5+1), + EXISTS(SELECT 1 FROM billing_migration_access_api_signal_windows WHERE program_id=$1 AND project_id=$2 AND request_count>0 AND window_ended_at>=GREATEST($3,$7::timestamptz-($8*interval '1 second'))), COALESCE((SELECT sum(traffic_count) FROM billing_migration_v2_sync_observations WHERE program_id=$1 AND project_id=$2 AND observed_at>=$3 AND sync_result<>'accepted'),0), (SELECT count(*) FROM billing_migration_divergences d LEFT JOIN billing_migration_divergence_resolutions x ON x.divergence_id=d.id WHERE d.program_id=$1 AND d.project_id=$2 AND x.id IS NULL), - (SELECT count(*) FROM billing_migration_import_batch_records ir JOIN billing_migration_source_records sr ON sr.id=ir.source_record_id AND sr.program_id=ir.program_id AND sr.project_id=ir.project_id WHERE ir.program_id=$1 AND ir.project_id=$2 AND sr.current_access AND NOT EXISTS(SELECT 1 FROM billing_migration_validation_bindings vb WHERE vb.program_id=ir.program_id AND vb.project_id=ir.project_id AND vb.environment_id=ir.environment_id AND vb.expected_application_id=ir.application_id AND vb.provider=ir.provider AND vb.reference_kind=ir.reference_kind AND vb.reference_digest=CASE WHEN ir.provider='app_store' THEN sha256(convert_to('mosaic-billing-apple-transaction-v1'||chr(0)||'unclassified'||chr(0)||ir.provider_reference,'UTF8')) ELSE sha256(convert_to('mosaic-billing-google-order-v1'||chr(0)||ir.provider_reference,'UTF8')) END AND vb.expected_store_environment=ir.expected_store_environment AND (ir.expected_store_product_identifier IS NULL OR (vb.expected_store_product_identifier=ir.expected_store_product_identifier AND vb.expected_mosaic_product_id=ir.mosaic_product_id)) AND vb.status IN ('validated','quarantined')))+ + (SELECT count(*) FROM billing_migration_import_batch_records ir JOIN billing_migration_source_records sr ON sr.id=ir.source_record_id AND sr.program_id=ir.program_id AND sr.project_id=ir.project_id WHERE ir.program_id=$1 AND ir.project_id=$2 AND sr.current_access AND NOT EXISTS(SELECT 1 FROM billing_migration_validation_bindings vb WHERE vb.program_id=ir.program_id AND vb.project_id=ir.project_id AND vb.environment_id=ir.environment_id AND vb.expected_application_id=ir.application_id AND vb.provider=ir.provider AND vb.reference_kind=ir.reference_kind AND vb.reference_digest=CASE WHEN ir.provider='app_store' THEN sha256(convert_to('mosaic-billing-apple-transaction-v1','UTF8')||'\x00'::bytea||convert_to('unclassified','UTF8')||'\x00'::bytea||convert_to(ir.provider_reference,'UTF8')) ELSE sha256(convert_to('mosaic-billing-google-order-v1','UTF8')||'\x00'::bytea||convert_to(ir.provider_reference,'UTF8')) END AND vb.expected_store_environment=ir.expected_store_environment AND (ir.expected_store_product_identifier IS NULL OR (vb.expected_store_product_identifier=ir.expected_store_product_identifier AND vb.expected_mosaic_product_id=ir.mosaic_product_id)) AND vb.status IN ('validated','quarantined')))+ (SELECT count(*) FROM billing_migration_import_batches WHERE program_id=$1 AND project_id=$2 AND status IN ('pending','running')), COALESCE((SELECT source_watermark FROM billing_migration_final_deltas WHERE program_id=$1 AND project_id=$2 ORDER BY completed_at DESC,id DESC LIMIT 1),NULL), (SELECT count(*) FROM webhook_deliveries d JOIN webhook_events e ON e.id=d.webhook_event_id AND e.project_id=d.project_id JOIN billing_migration_authority_scopes a ON a.id=e.authority_scope_id AND a.project_id=e.project_id JOIN webhook_destinations destination ON destination.id=d.webhook_destination_id AND destination.project_id=d.project_id WHERE d.project_id=$2 AND d.environment_id=$6 AND a.active_program_id=$1 AND e.contract_version=2 AND e.event_type IN ('authority.cutover.completed','authority.rollback.completed','authority.stabilization.completed') AND destination.status='active' AND d.created_at>=$3 AND d.status IN ('failed','exhausted')), @@ -254,6 +254,12 @@ func (r *Repository) RecordStabilization(ctx context.Context, c billingmigration Breaches []string Source, Webhook, At time.Time }{c.Input.ProgramID, version, c.Input.ExpectedAuthorityEpoch, policyRaw, m, breaches, sourceAt.UTC(), webhookAt.UTC(), now.UTC()}) + if breaches == nil { + // A healthy observation breaches nothing, and a nil slice encodes as + // NULL against a text[] NOT NULL column — so the healthy path, the + // one that matters most, could never be persisted. + breaches = []string{} + } o := billingmigration.StabilizationObservation{ID: stabilizationID("mso", evidence), ProgramID: c.Input.ProgramID, ProjectID: c.Input.ProjectID, PolicyID: p.ID, PolicyDigest: p.PolicyDigest, EvidenceDigest: billingmigration.FormatDigest(evidence), StateVersion: version, AuthorityEpoch: c.Input.ExpectedAuthorityEpoch, Metrics: m, SourceWatermark: sourceAt.UTC(), WebhookLastSuccessAt: webhookAt.UTC(), ObservedAt: now.UTC(), BreachCodes: breaches, Healthy: len(breaches) == 0} _, err = tx.Exec(ctx, `INSERT INTO billing_migration_stabilization_observations(id,program_id,project_id,policy_id,state_version,authority_epoch,authority_mismatches,access_api_errors,sdk_sync_failures,divergences,validation_backlog,source_delta_lag_seconds,webhook_failures,webhook_age_seconds,quarantined_records,support_cases,old_app_versions,unhealthy_workers,source_watermark,webhook_last_success_at,breach_codes,healthy,evidence_digest,observed_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24)`, o.ID, o.ProgramID, o.ProjectID, o.PolicyID, o.StateVersion, o.AuthorityEpoch, m.AuthorityMismatches, m.AccessAPIErrors, m.SDKSyncFailures, m.Divergences, m.ValidationBacklog, m.SourceDeltaLagSeconds, m.WebhookFailures, m.WebhookAgeSeconds, m.QuarantinedRecords, m.SupportCases, m.OldAppVersions, m.UnhealthyWorkers, o.SourceWatermark, o.WebhookLastSuccessAt, o.BreachCodes, o.Healthy, evidence, o.ObservedAt) if err != nil { diff --git a/apps/api/internal/platform/billingmigrationpostgres/stabilization_integration_test.go b/apps/api/internal/platform/billingmigrationpostgres/stabilization_integration_test.go index 4c0ad585..7ffe745d 100644 --- a/apps/api/internal/platform/billingmigrationpostgres/stabilization_integration_test.go +++ b/apps/api/internal/platform/billingmigrationpostgres/stabilization_integration_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/jackc/pgx/v5" "github.com/pressly/goose/v3" "github.com/Mujhtech/mosaic/apps/api/internal/billingmigration" @@ -18,7 +19,7 @@ func TestStabilizationAndRollbackReadinessUsePersistedEvidence(t *testing.T) { ctx, pool, db := executionDatabase(t) now := time.Now().UTC().Truncate(time.Microsecond) seedReadyProgram(t, ctx, db, now) - _, err := pool.Exec(ctx, `UPDATE billing_migration_programs SET state='stabilizing',state_version=7,updated_at=$1 WHERE id='program_ready'; UPDATE billing_migration_authority_scopes SET current_authority='mosaic',current_epoch=1,authority_digest=decode(repeat('a1',32),'hex'),updated_at=$1 WHERE active_program_id='program_ready'; INSERT INTO billing_migration_capability_assessments(id,program_id,project_id,state_version,provider_api_version,capabilities,assessment_digest,assessed_at) VALUES('capability_rollback','program_ready','project_one',7,'v2',ARRAY['read_customers','read_subscriptions','read_aliases','incremental_delta'],decode(repeat('af',32),'hex'),$1-interval '1 minute'); INSERT INTO billing_migration_v2_sync_observations(id,program_id,project_id,application_id,platform,app_version,sdk_version,supported_contract_versions,authority_capabilities,traffic_count,authority_epoch,sync_result,observation_digest,observed_at) VALUES('stable_ios','program_ready','project_one','app_one','ios','2.10.0+ios.1','2.1.0',ARRAY['2'],ARRAY['authority_epoch','authority_scope','urgent_authority_sync','mosaic_authoritative_targeting'],1,1,'accepted',decode(repeat('a2',32),'hex'),$1),('stable_android_incomplete','program_ready','project_one','app_two','android','2.10.0+android.1','2.1.0',ARRAY['2'],ARRAY['authority_epoch','authority_scope','mosaic_authoritative_targeting'],1,1,'accepted',decode(repeat('a3',32),'hex'),$1)`, now) + _, err := pool.Exec(ctx, `UPDATE billing_migration_programs SET state='stabilizing',state_version=7,updated_at=$1 WHERE id='program_ready'; UPDATE billing_migration_authority_scopes SET current_authority='mosaic',current_epoch=1,authority_digest=decode(repeat('a1',32),'hex'),updated_at=$1 WHERE active_program_id='program_ready'; INSERT INTO billing_migration_capability_assessments(id,program_id,project_id,state_version,provider_api_version,capabilities,assessment_digest,assessed_at) VALUES('capability_rollback','program_ready','project_one',7,'v2',ARRAY['read_customers','read_subscriptions','read_aliases','incremental_delta'],decode(repeat('af',32),'hex'),$1::timestamptz-interval '1 minute'); INSERT INTO billing_migration_v2_sync_observations(id,program_id,project_id,application_id,platform,app_version,sdk_version,supported_contract_versions,authority_capabilities,traffic_count,authority_epoch,sync_result,observation_digest,observed_at) VALUES('stable_ios','program_ready','project_one','app_one','ios','2.10.0+ios.1','2.1.0',ARRAY['2'],ARRAY['authority_epoch','authority_scope','urgent_authority_sync','mosaic_authoritative_targeting'],1,1,'accepted',decode(repeat('a2',32),'hex'),$1),('stable_android_incomplete','program_ready','project_one','app_two','android','2.10.0+android.1','2.1.0',ARRAY['2'],ARRAY['authority_epoch','authority_scope','mosaic_authoritative_targeting'],1,1,'accepted',decode(repeat('a3',32),'hex'),$1)`, pgx.QueryExecModeSimpleProtocol, now) if err != nil { t.Fatal(err) } @@ -75,15 +76,15 @@ func TestStabilizationAndRollbackReadinessUsePersistedEvidence(t *testing.T) { INSERT INTO billing_migration_source_records(id,program_id,project_id,manifest_id,source_kind,source_identifier,source_revision,record_digest,current_access,normalization_schema_version,evidence_kind,observed_at,created_at) VALUES('record_unvalidated','program_ready','project_one','manifest_ready','subscription','subscription-unvalidated','1',decode(repeat('de',32),'hex'),true,'v1','trusted_source_export',$1,$1); INSERT INTO billing_migration_import_batch_records(import_batch_id,program_id,project_id,source_record_id,ordinal,provider,environment_id,application_id,provider_reference,reference_kind,expected_store_environment) VALUES('batch_stable','program_ready','project_one','record_ready',0,'app_store','environment_one','app_one','transaction-stable','app_store_transaction_id','production'); INSERT INTO billing_migration_import_batch_records(import_batch_id,program_id,project_id,source_record_id,ordinal,provider,environment_id,application_id,provider_reference,reference_kind,expected_store_environment) VALUES('batch_stable','program_ready','project_one','record_unvalidated',1,'app_store','environment_one','app_one','transaction-unvalidated','app_store_transaction_id','production'); - INSERT INTO billing_raw_inputs(id,project_id,organization_id,environment_id,environment_mode,application_id,provider,source,source_authority,idempotency_key,content_digest,transaction_reference_digest,body_state,authentication_result,store_environment,ingestion_status,correlation_id,received_at,expires_at) VALUES('raw_stable','project_one','org_one','environment_one','production','app_one','app_store','migration_known_reference','store_reconciliation',decode(repeat('da',32),'hex'),decode(repeat('db',32),'hex'),sha256(convert_to('mosaic-billing-apple-transaction-v1'||chr(0)||'unclassified'||chr(0)||'transaction-stable','UTF8')),'not_retained','verified_transport','unclassified','accepted','stable',$1,$1+interval '1 hour'); + INSERT INTO billing_raw_inputs(id,project_id,organization_id,environment_id,environment_mode,application_id,provider,source,source_authority,idempotency_key,content_digest,transaction_reference_digest,body_state,authentication_result,store_environment,ingestion_status,correlation_id,received_at,expires_at) VALUES('raw_stable','project_one','org_one','environment_one','production','app_one','app_store','migration_known_reference','store_reconciliation',decode(repeat('da',32),'hex'),decode(repeat('db',32),'hex'),sha256(convert_to('mosaic-billing-apple-transaction-v1','UTF8')||'\x00'::bytea||convert_to('unclassified','UTF8')||'\x00'::bytea||convert_to('transaction-stable','UTF8')),'not_retained','verified_transport','unclassified','accepted','stable',$1,$1::timestamptz+interval '1 hour'); INSERT INTO billing_validation_attempts(id,project_id,environment_id,raw_input_id,attempt_number,validator_version,started_at,completed_at,outcome,retryable,store_environment,latency_ms,correlation_id) VALUES('attempt_stable','project_one','environment_one','raw_stable',1,2,$1,$1,'validated',false,'production',1,'stable'); - INSERT INTO billing_migration_validation_bindings(id,program_id,project_id,environment_id,raw_input_id,provider,reference_kind,reference_digest,expected_application_id,expected_store_product_identifier,expected_store_environment,expected_mosaic_product_id,status,validation_attempt_id,evidence_digest,provider_watermark,accepted_at,completed_at) VALUES('binding_stable','program_ready','project_one','environment_one','raw_stable','app_store','app_store_transaction_id',sha256(convert_to('mosaic-billing-apple-transaction-v1'||chr(0)||'unclassified'||chr(0)||'transaction-stable','UTF8')),'app_one','store.product','production','customer_two','validated','attempt_stable',decode(repeat('dd',32),'hex'),$1,$1,$1); + INSERT INTO billing_migration_validation_bindings(id,program_id,project_id,environment_id,raw_input_id,provider,reference_kind,reference_digest,expected_application_id,expected_store_product_identifier,expected_store_environment,expected_mosaic_product_id,status,validation_attempt_id,evidence_digest,provider_watermark,accepted_at,completed_at) VALUES('binding_stable','program_ready','project_one','environment_one','raw_stable','app_store','app_store_transaction_id',sha256(convert_to('mosaic-billing-apple-transaction-v1','UTF8')||'\x00'::bytea||convert_to('unclassified','UTF8')||'\x00'::bytea||convert_to('transaction-stable','UTF8')),'app_one','store.product','production','customer_two','validated','attempt_stable',decode(repeat('dd',32),'hex'),$1,$1,$1); INSERT INTO billing_migration_source_pull_jobs(id,program_id,project_id,intent,idempotency_key,request_digest,expected_program_state_version,starting_cursor,starting_watermark,starting_watermark_digest,mapping_set_id,status,result_source_object_id,result_manifest_id,result_import_batch_id,resume_cursor,final_watermark,evidence_digest,record_count,current_access_count,import_record_count,due_at,lease_generation,attempt_count,max_attempts,created_by_actor_id,started_at,completed_at,created_at,updated_at) VALUES('pull_completed_predecessor','program_ready','project_one','snapshot','completed-predecessor',decode(repeat('c7',32),'hex'),7,'','',NULL,'mapping_ready','completed','object_predecessor','manifest_predecessor','batch_predecessor','cursor','watermark',decode(repeat('c9',32),'hex'),0,0,0,$1,1,1,8,'owner_one',$1,$1,$1,$1); INSERT INTO billing_migration_source_pull_jobs(id,program_id,project_id,intent,idempotency_key,request_digest,expected_program_state_version,starting_cursor,starting_watermark,starting_watermark_digest,predecessor_pull_job_id,mapping_set_id,status,result_source_object_id,result_manifest_id,result_import_batch_id,result_final_delta_job_id,resume_cursor,final_watermark,evidence_digest,record_count,current_access_count,import_record_count,due_at,lease_generation,attempt_count,max_attempts,created_by_actor_id,started_at,completed_at,created_at,updated_at) VALUES('pull_final_stable','program_ready','project_one','final_delta','final-stable',decode(repeat('c8',32),'hex'),7,'cursor','watermark',decode(repeat('c9',32),'hex'),'pull_completed_predecessor','mapping_ready','completed','object_stable','manifest_ready','batch_stable','delta_job_ready','','final-watermark',decode(repeat('ca',32),'hex'),2,2,1,$1,1,1,8,'owner_one',$1,$1,$1,$1); INSERT INTO webhook_destinations(id,project_id,environment_id,url,status,event_types,description,contract_version,last_successful_test_at,created_at,updated_at) VALUES('stable_webhook','project_one','environment_one','https://stable.example.test','active',ARRAY['authority.cutover.completed'],'stable',2,$1,$1,$1),('stable_webhook_missing','project_one','environment_one','https://missing.example.test','active',ARRAY['authority.cutover.completed'],'missing coverage',2,$1,$1,$1); INSERT INTO webhook_events(id,project_id,environment_id,event_type,billing_customer_id,snapshot_version,payload,occurred_at,created_at,contract_version,authority_scope_id,authority_epoch,authority_kind,transition_state,correlation_id,snapshot_authority_digest) VALUES('stable_event','project_one','environment_one','authority.cutover.completed','customer_one',0,'{}',$1,$1,2,'authority_ready_ios',1,'mosaic','stabilizing','stable-correlation',decode(repeat('a1',32),'hex')); INSERT INTO webhook_events(id,project_id,environment_id,event_type,billing_customer_id,snapshot_version,payload,occurred_at,created_at,contract_version,authority_scope_id,authority_epoch,authority_kind,transition_state,correlation_id,snapshot_authority_digest) VALUES('stable_event_android','project_one','environment_one','authority.cutover.completed','customer_one',0,'{}',$1,$1,2,'authority_ready_android',1,'mosaic','stabilizing','stable-correlation-android',decode(repeat('a1',32),'hex')); - INSERT INTO webhook_deliveries(id,project_id,environment_id,webhook_event_id,webhook_destination_id,status,attempt_count,max_attempts,created_at,updated_at,completed_at) VALUES('stable_delivery','project_one','environment_one','stable_event','stable_webhook','succeeded',1,8,$1,$1,$1),('stable_delivery_android','project_one','environment_one','stable_event_android','stable_webhook','succeeded',1,8,$1,$1,$1)`, now) + INSERT INTO webhook_deliveries(id,project_id,environment_id,webhook_event_id,webhook_destination_id,status,attempt_count,max_attempts,created_at,updated_at,completed_at) VALUES('stable_delivery','project_one','environment_one','stable_event','stable_webhook','succeeded',1,8,$1,$1,$1),('stable_delivery_android','project_one','environment_one','stable_event_android','stable_webhook','succeeded',1,8,$1,$1,$1)`, pgx.QueryExecModeSimpleProtocol, now) if err != nil { t.Fatal(err) } @@ -108,11 +109,11 @@ func TestStabilizationAndRollbackReadinessUsePersistedEvidence(t *testing.T) { if _, err = pool.Exec(ctx, `INSERT INTO billing_migration_rollback_readiness_checkpoints(id,program_id,project_id,assessment_id,assessment_ready,state_version,authority_epoch,authority_digest,policy_digest,evidence_digest,readiness_digest,checkpoint_digest,created_by_actor_id,created_at) VALUES('forged_not_ready','program_ready','project_one',$1,true,7,1,decode(repeat('f1',32),'hex'),decode(repeat('f2',32),'hex'),decode(repeat('f3',32),'hex'),decode(repeat('f4',32),'hex'),decode(repeat('f5',32),'hex'),'owner_one',$2)`, negativeAssessment.ID, now); err == nil { t.Fatal("database accepted checkpoint referencing a not-ready assessment") } - _, err = pool.Exec(ctx, `INSERT INTO billing_raw_inputs(id,project_id,organization_id,environment_id,environment_mode,application_id,provider,source,source_authority,idempotency_key,content_digest,transaction_reference_digest,body_state,authentication_result,store_environment,ingestion_status,correlation_id,received_at,expires_at) VALUES('raw_unvalidated','project_one','org_one','environment_one','production','app_one','app_store','migration_known_reference','store_reconciliation',decode(repeat('e1',32),'hex'),decode(repeat('e2',32),'hex'),sha256(convert_to('mosaic-billing-apple-transaction-v1'||chr(0)||'unclassified'||chr(0)||'transaction-unvalidated','UTF8')),'not_retained','verified_transport','unclassified','accepted','unvalidated',$1,$1+interval '1 hour'); + _, err = pool.Exec(ctx, `INSERT INTO billing_raw_inputs(id,project_id,organization_id,environment_id,environment_mode,application_id,provider,source,source_authority,idempotency_key,content_digest,transaction_reference_digest,body_state,authentication_result,store_environment,ingestion_status,correlation_id,received_at,expires_at) VALUES('raw_unvalidated','project_one','org_one','environment_one','production','app_one','app_store','migration_known_reference','store_reconciliation',decode(repeat('e1',32),'hex'),decode(repeat('e2',32),'hex'),sha256(convert_to('mosaic-billing-apple-transaction-v1','UTF8')||'\x00'::bytea||convert_to('unclassified','UTF8')||'\x00'::bytea||convert_to('transaction-unvalidated','UTF8')),'not_retained','verified_transport','unclassified','accepted','unvalidated',$1,$1::timestamptz+interval '1 hour'); INSERT INTO billing_validation_attempts(id,project_id,environment_id,raw_input_id,attempt_number,validator_version,started_at,completed_at,outcome,retryable,store_environment,latency_ms,correlation_id) VALUES('attempt_unvalidated','project_one','environment_one','raw_unvalidated',1,2,$1,$1,'validated',false,'production',1,'unvalidated'); - INSERT INTO billing_migration_validation_bindings(id,program_id,project_id,environment_id,raw_input_id,provider,reference_kind,reference_digest,expected_application_id,expected_store_product_identifier,expected_store_environment,expected_mosaic_product_id,status,validation_attempt_id,evidence_digest,provider_watermark,accepted_at,completed_at) VALUES('binding_unvalidated','program_ready','project_one','environment_one','raw_unvalidated','app_store','app_store_transaction_id',sha256(convert_to('mosaic-billing-apple-transaction-v1'||chr(0)||'unclassified'||chr(0)||'transaction-unvalidated','UTF8')),'app_one','store.product','production','customer_two','validated','attempt_unvalidated',decode(repeat('e4',32),'hex'),$1,$1,$1); + INSERT INTO billing_migration_validation_bindings(id,program_id,project_id,environment_id,raw_input_id,provider,reference_kind,reference_digest,expected_application_id,expected_store_product_identifier,expected_store_environment,expected_mosaic_product_id,status,validation_attempt_id,evidence_digest,provider_watermark,accepted_at,completed_at) VALUES('binding_unvalidated','program_ready','project_one','environment_one','raw_unvalidated','app_store','app_store_transaction_id',sha256(convert_to('mosaic-billing-apple-transaction-v1','UTF8')||'\x00'::bytea||convert_to('unclassified','UTF8')||'\x00'::bytea||convert_to('transaction-unvalidated','UTF8')),'app_one','store.product','production','customer_two','validated','attempt_unvalidated',decode(repeat('e4',32),'hex'),$1,$1,$1); INSERT INTO billing_migration_v2_sync_observations(id,program_id,project_id,application_id,platform,app_version,sdk_version,supported_contract_versions,authority_capabilities,traffic_count,authority_epoch,sync_result,observation_digest,observed_at) VALUES('stable_android_complete','program_ready','project_one','app_two','android','2.10.0+android.1','2.1.0',ARRAY['2'],ARRAY['authority_epoch','authority_scope','urgent_authority_sync','mosaic_authoritative_targeting'],1,1,'accepted',decode(repeat('e5',32),'hex'),$1); - INSERT INTO webhook_deliveries(id,project_id,environment_id,webhook_event_id,webhook_destination_id,status,attempt_count,max_attempts,created_at,updated_at,completed_at) VALUES('stable_delivery_missing_ios','project_one','environment_one','stable_event','stable_webhook_missing','succeeded',1,8,$1,$1,$1),('stable_delivery_missing_android','project_one','environment_one','stable_event_android','stable_webhook_missing','succeeded',1,8,$1,$1,$1)`, now) + INSERT INTO webhook_deliveries(id,project_id,environment_id,webhook_event_id,webhook_destination_id,status,attempt_count,max_attempts,created_at,updated_at,completed_at) VALUES('stable_delivery_missing_ios','project_one','environment_one','stable_event','stable_webhook_missing','succeeded',1,8,$1,$1,$1),('stable_delivery_missing_android','project_one','environment_one','stable_event_android','stable_webhook_missing','succeeded',1,8,$1,$1,$1)`, pgx.QueryExecModeSimpleProtocol, now) if err != nil { t.Fatal(err) } diff --git a/apps/dashboard/.prettierignore b/apps/dashboard/.prettierignore deleted file mode 100644 index 212789e5..00000000 --- a/apps/dashboard/.prettierignore +++ /dev/null @@ -1,7 +0,0 @@ -node_modules -dist -.output -coverage -src/routeTree.gen.ts -src/generated/ -package-lock.json diff --git a/apps/dashboard/README.md b/apps/dashboard/README.md index d43f9ef5..375d0634 100644 --- a/apps/dashboard/README.md +++ b/apps/dashboard/README.md @@ -274,9 +274,8 @@ npm run preview:relay # start only the loopback preview relay npm run generate:api # regenerate the REST client from docs/backend/openapi.yaml npm run build # create the production client and server bundles npm run start # serve the production build -npm run format # format local files -npm run format:check # verify formatting -npm run lint # run ESLint +npm run lint # lint and check formatting with Ultracite (Biome) +npm run lint:fix # apply Ultracite formatting and safe lint fixes npm run typecheck # run TypeScript without emitting files npm run test # run Vitest and relay integration tests once npm run test:relay # run relay protocol/routing integration tests diff --git a/apps/dashboard/biome.jsonc b/apps/dashboard/biome.jsonc new file mode 100644 index 00000000..cbb52016 --- /dev/null +++ b/apps/dashboard/biome.jsonc @@ -0,0 +1,91 @@ +{ + "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", + "extends": [ + "ultracite/biome/core", + "ultracite/biome/react", + "ultracite/biome/tanstack", + "ultracite/biome/vitest" + ], + "javascript": { + // Vite `define` constants, typed in src/build-info.d.ts. + "globals": [ + "__MOSAIC_BUILD_TIME__", + "__MOSAIC_COMMIT__", + "__MOSAIC_VERSION__" + ] + }, + "linter": { + "rules": { + "performance": { + // `delete` removes an optional field from a protocol document; the + // suggested `= undefined` leaves the key in place, which changes + // Object.keys, `in`, and deep-equality against a fixture. The two are + // not interchangeable here. + "noDelete": "off", + + // Most of what remains is a handler inside a .map that closes over the + // row it renders, where useCallback is not available and the fix is a + // child component per list. The handlers that could be lifted have + // been: see the useCallback pass across the feature components. + "noJsxPropsBind": "off" + }, + "complexity": { + // The five worst offenders were decomposed: the canvas preview + // renderer, the document validator, the editor keydown handler, the + // preview socket message handler and the relay frame handler, together + // 102/97/93/92/89 down to clean/24/31/38/43. What is left tops out at + // 82 and is mostly React render callbacks, where splitting means + // extracting components rather than moving logic. 90 holds that line + // and still fails anything worse than today's worst. + "noExcessiveCognitiveComplexity": { + "level": "error", + "options": { "maxAllowedComplexity": 90 } + } + }, + "suspicious": { + // Type-aware, and its inference disagrees with tsc. It reads + // `data?.proposals ?? []` as a redundant coalesce where `data` is + // `LifecycleData | undefined`; removing it, as the rule asks, spreads + // undefined and fails to compile (TS2488). It also resolves + // `Exclude<...>` to never, so reachable switch cases read as dead, and + // misses a `let` reassigned inside a closure. A rule that is wrong + // about types is worse than no rule, because acting on it breaks code. + "noUnnecessaryConditions": "off" + } + } + }, + "assist": { + "actions": { + "source": { + // Object key order is observable here (locale precedence via + // Object.keys, protocol document field order), so leave it alone. + "useSortedKeys": "off" + } + } + }, + "overrides": [ + { + "includes": ["**/*.test.ts", "**/*.test.tsx", "**/*.test.mjs"], + "linter": { + "rules": { + "performance": { + // Testing Library name matchers are regex literals by design. The + // rule guards against recompiling in a hot path, which a matcher + // evaluated once per assertion is not. + "useTopLevelRegex": "off" + } + } + } + } + ], + "files": { + "includes": [ + "!dist", + "!.output", + "!coverage", + "!src/generated", + "!src/routeTree.gen.ts", + "!package-lock.json" + ] + } +} diff --git a/apps/dashboard/eslint.config.mjs b/apps/dashboard/eslint.config.mjs deleted file mode 100644 index 39ea4ddf..00000000 --- a/apps/dashboard/eslint.config.mjs +++ /dev/null @@ -1,58 +0,0 @@ -import eslint from "@eslint/js" -import pluginQuery from "@tanstack/eslint-plugin-query" -import reactHooks from "eslint-plugin-react-hooks" -import reactRefresh from "eslint-plugin-react-refresh" -import globals from "globals" -import tseslint from "typescript-eslint" - -export default tseslint.config( - { - ignores: ["dist/**", ".output/**", "coverage/**", "src/generated/**", "src/routeTree.gen.ts"], - }, - eslint.configs.recommended, - ...tseslint.configs.recommended, - ...pluginQuery.configs["flat/recommended"], - reactHooks.configs.flat.recommended, - { - files: ["scripts/**/*.mjs"], - languageOptions: { - ecmaVersion: "latest", - globals: globals.node, - sourceType: "module", - }, - }, - { - files: ["**/*.{ts,tsx}"], - languageOptions: { - ecmaVersion: "latest", - globals: { - ...globals.browser, - ...globals.node, - }, - parserOptions: { - ecmaFeatures: { jsx: true }, - }, - sourceType: "module", - }, - plugins: { - "react-refresh": reactRefresh, - }, - rules: { - ...reactRefresh.configs.vite.rules, - }, - }, - { - files: ["src/routes/**/*.tsx"], - rules: { - // File-route modules must export TanStack's generated Route constant. - "react-refresh/only-export-components": "off", - }, - }, - { - files: ["src/features/paywall-editor/stores/editor-store-context.tsx"], - rules: { - // This feature context intentionally colocates its provider and typed hooks. - "react-refresh/only-export-components": "off", - }, - }, -) diff --git a/apps/dashboard/package-lock.json b/apps/dashboard/package-lock.json index e2c93708..37da35d3 100644 --- a/apps/dashboard/package-lock.json +++ b/apps/dashboard/package-lock.json @@ -30,10 +30,9 @@ "ws": "^8.21.1" }, "devDependencies": { - "@eslint/js": "^10.0.1", + "@biomejs/biome": "^2.5.6", "@hey-api/openapi-ts": "0.99.0", "@tailwindcss/vite": "^4.3.3", - "@tanstack/eslint-plugin-query": "^5.101.2", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", @@ -42,16 +41,10 @@ "@types/react-dom": "^19.2.3", "@types/ws": "^8.18.1", "@vitejs/plugin-react": "^6.0.3", - "eslint": "^10.7.0", - "eslint-plugin-react-hooks": "^7.1.1", - "eslint-plugin-react-refresh": "^0.5.3", - "globals": "^17.7.0", "jsdom": "^29.1.1", - "prettier": "^3.9.5", - "prettier-plugin-tailwindcss": "^0.8.1", "tailwindcss": "^4.3.3", "typescript": "^6.0.3", - "typescript-eslint": "^8.64.0", + "ultracite": "^7.9.4", "vite": "^8.1.5", "vitest": "^4.1.10" }, @@ -464,6 +457,169 @@ } } }, + "node_modules/@biomejs/biome": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.6.tgz", + "integrity": "sha512-lxVNjv7UF6KfhMJfL9gaUHbWdJdHbsAj6OSmwSYNdhRuG67NxNQ4Xdvh3TUxsSK9sBzJBQhEJj3AopmmNJ5pSA==", + "dev": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "2.5.6", + "@biomejs/cli-darwin-x64": "2.5.6", + "@biomejs/cli-linux-arm64": "2.5.6", + "@biomejs/cli-linux-arm64-musl": "2.5.6", + "@biomejs/cli-linux-x64": "2.5.6", + "@biomejs/cli-linux-x64-musl": "2.5.6", + "@biomejs/cli-win32-arm64": "2.5.6", + "@biomejs/cli-win32-x64": "2.5.6" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.6.tgz", + "integrity": "sha512-zMOLZP4oMrjh6m1zcSj1ud2awUPgTuMVbmQhYYWL7J8HwCnbHHBvTm7VBTRuY7epT5bez76IpKYQ11ZAqHFlnw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.6.tgz", + "integrity": "sha512-JAC1VqzvO7Th5ZplU0G2uGfkZbxEe9uDDektPAhF0JLusoz1w+T4okp2bkykI0bbaO2vslKiRfj4gU43JaGreA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.6.tgz", + "integrity": "sha512-6XsYwCFkp5sMxl85ffhgeGpGgs6A7dRYFnkceZ7WVxvycuTnGdD5xa534Z3xfrBQ0JCMK/mujT6ZNPJoghedwg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.6.tgz", + "integrity": "sha512-eUa3jeeYvfMt19LBeh6E5PUZpxnTC4JqNWo+EDjTtQjAr2xLGnWaxACtVU1DQqmHYbvThlJzLX+ZsYgrqh2qVw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.6.tgz", + "integrity": "sha512-Pop9VXCFUhFTMfFefZ39S+u2rOPyNp5iHlxbZRwXGACHLy2r0jjiRgJHmaEKJzL3SyxlVeGShXhvvElvWowonA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.6.tgz", + "integrity": "sha512-2Vp13QdKysH3HIWLaYLhUUwbK+jbZonJD1K+Lr0d0RO4wH7mkYd43vJixEDm8cUWrowoRz4UUHF1nm9Ae7ym8A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.6.tgz", + "integrity": "sha512-tDGshcm6BdkZOCGnTDX0Y8/U4IfBSlnUU7T56nNDuPEfed+aHg+u8G36NB43fJVl0Os6+QURXIE1yuD7AaEofA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.6.tgz", + "integrity": "sha512-WN05KwXnTO/2J45RQPvzZMXf7tZUIofHoR35xIPfCo7pQ2RFidxI8sfb5mGsaTxdMmEOzHzOPRCdA5/fCpc7xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, "node_modules/@bramus/specificity": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", @@ -477,6 +633,36 @@ "specificity": "bin/cli.js" } }, + "node_modules/@clack/core": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.4.3.tgz", + "integrity": "sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, + "node_modules/@clack/prompts": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.7.0.tgz", + "integrity": "sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@clack/core": "1.4.3", + "fast-string-width": "^3.0.2", + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, "node_modules/@csstools/color-helpers": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", @@ -676,6 +862,7 @@ "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } @@ -686,6 +873,7 @@ "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", @@ -701,6 +889,7 @@ "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@eslint/core": "^1.2.1" }, @@ -714,6 +903,7 @@ "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@types/json-schema": "^7.0.15" }, @@ -721,33 +911,13 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@eslint/js": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", - "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "eslint": "^10.0.0" - }, - "peerDependenciesMeta": { - "eslint": { - "optional": true - } - } - }, "node_modules/@eslint/object-schema": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", "dev": true, "license": "Apache-2.0", + "peer": true, "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" } @@ -758,6 +928,7 @@ "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" @@ -975,6 +1146,7 @@ "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@humanfs/types": "^0.15.0" }, @@ -988,6 +1160,7 @@ "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", @@ -1003,6 +1176,7 @@ "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", "dev": true, "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=18.18.0" } @@ -1013,6 +1187,7 @@ "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "dev": true, "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=12.22" }, @@ -1027,6 +1202,7 @@ "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=18.18" }, @@ -1754,29 +1930,6 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, - "node_modules/@tanstack/eslint-plugin-query": { - "version": "5.101.2", - "resolved": "https://registry.npmjs.org/@tanstack/eslint-plugin-query/-/eslint-plugin-query-5.101.2.tgz", - "integrity": "sha512-cPE99s3XZwlObfn8lCezT4j4JLj2CVzpIEywx0H4hzfPsX/o9QhdwaOwcDXxrQAqx2ds7TbvTinxhB8B/ywb6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/utils": "^8.58.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": "^5.4.0 || ^6.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, "node_modules/@tanstack/form-core": { "version": "1.33.2", "resolved": "https://registry.npmjs.org/@tanstack/form-core/-/form-core-1.33.2.tgz", @@ -2520,7 +2673,8 @@ "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/estree": { "version": "1.0.9", @@ -2576,70 +2730,6 @@ "@types/node": "*" } }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.64.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.64.0.tgz", - "integrity": "sha512-CGvQPBxN3wZLu6Rz2kFUpZeoCm78xUic92ck39KPePkO1NPOwjCqdQnm5Q87tpWw9vcBvW8XLrDXjH9PWYtJ3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.64.0", - "@typescript-eslint/type-utils": "8.64.0", - "@typescript-eslint/utils": "8.64.0", - "@typescript-eslint/visitor-keys": "8.64.0", - "ignore": "^7.0.5", - "natural-compare": "^1.4.0", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^8.64.0", - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", - "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/@typescript-eslint/parser": { - "version": "8.64.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.64.0.tgz", - "integrity": "sha512-KA0OshtlcCCXmbfqyZkM5pV3/WNraJf7DkJRLpyrmwPtud57H5BDX7C3k0LPSPxpprfRL+cJDGabF10mvNCoCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/scope-manager": "8.64.0", - "@typescript-eslint/types": "8.64.0", - "@typescript-eslint/typescript-estree": "8.64.0", - "@typescript-eslint/visitor-keys": "8.64.0", - "debug": "^4.4.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, "node_modules/@typescript-eslint/project-service": { "version": "8.64.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.64.0.tgz", @@ -2697,31 +2787,6 @@ "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/type-utils": { - "version": "8.64.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.64.0.tgz", - "integrity": "sha512-XWG4Fmmv/6SvyS9nH8jWrKs6terwJvE8cyRt1CzYYqzp9OrPhCT4cMc/f7C6RZCwG+qMmiffJS1/qJP8G1URtg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.64.0", - "@typescript-eslint/typescript-estree": "8.64.0", - "@typescript-eslint/utils": "8.64.0", - "debug": "^4.4.3", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, "node_modules/@typescript-eslint/types": { "version": "8.64.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.64.0.tgz", @@ -3019,6 +3084,7 @@ "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3032,6 +3098,7 @@ "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } @@ -3300,6 +3367,13 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/citty": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz", + "integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==", + "dev": true, + "license": "MIT" + }, "node_modules/class-variance-authority": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", @@ -3557,7 +3631,18 @@ "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } }, "node_modules/default-browser": { "version": "5.5.0", @@ -3719,6 +3804,7 @@ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -3732,6 +3818,7 @@ "integrity": "sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ==", "dev": true, "license": "MIT", + "peer": true, "workspaces": [ "packages/*" ], @@ -3785,42 +3872,13 @@ } } }, - "node_modules/eslint-plugin-react-hooks": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", - "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.24.4", - "@babel/parser": "^7.24.4", - "hermes-parser": "^0.25.1", - "zod": "^3.25.0 || ^4.0.0", - "zod-validation-error": "^3.5.0 || ^4.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" - } - }, - "node_modules/eslint-plugin-react-refresh": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.3.tgz", - "integrity": "sha512-5EMmLCV98Pi4o/f/3DP/v/tNqLHMIc9I8LKClNDWhZ9JTho89/kQcitCXQBMG7sAfVRK0Ie3T2EDOzp1YXYiVA==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "eslint": "^9 || ^10" - } - }, "node_modules/eslint-scope": { "version": "9.1.2", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", @@ -3853,6 +3911,7 @@ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -3870,6 +3929,7 @@ "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, "license": "Apache-2.0", + "peer": true, "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" }, @@ -3882,7 +3942,8 @@ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/espree": { "version": "11.2.0", @@ -3890,6 +3951,7 @@ "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", @@ -3908,6 +3970,7 @@ "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, "license": "Apache-2.0", + "peer": true, "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" }, @@ -3921,6 +3984,7 @@ "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, "license": "BSD-3-Clause", + "peer": true, "dependencies": { "estraverse": "^5.1.0" }, @@ -3934,6 +3998,7 @@ "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "estraverse": "^5.2.0" }, @@ -3947,6 +4012,7 @@ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "engines": { "node": ">=4.0" } @@ -3967,6 +4033,7 @@ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -3998,15 +4065,34 @@ "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/fast-levenshtein": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "dev": true, "license": "MIT" }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, "node_modules/fast-uri": { "version": "3.1.4", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", @@ -4023,6 +4109,16 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -4052,6 +4148,7 @@ "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "flat-cache": "^4.0.0" }, @@ -4065,6 +4162,7 @@ "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" @@ -4082,6 +4180,7 @@ "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" @@ -4095,7 +4194,8 @@ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/fsevents": { "version": "2.3.3", @@ -4144,12 +4244,31 @@ "giget": "dist/cli.mjs" } }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "is-glob": "^4.0.3" }, @@ -4157,19 +4276,6 @@ "node": ">=10.13.0" } }, - "node_modules/globals": { - "version": "17.7.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz", - "integrity": "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -4202,23 +4308,6 @@ } } }, - "node_modules/hermes-estree": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", - "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", - "dev": true, - "license": "MIT" - }, - "node_modules/hermes-parser": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", - "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "hermes-estree": "0.25.1" - } - }, "node_modules/html-encoding-sniffer": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", @@ -4238,6 +4327,7 @@ "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 4" } @@ -4248,6 +4338,7 @@ "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.8.19" } @@ -4284,6 +4375,7 @@ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -4294,6 +4386,7 @@ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "is-extglob": "^2.1.1" }, @@ -4477,7 +4570,8 @@ "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/json-schema-traverse": { "version": "1.0.0", @@ -4490,7 +4584,8 @@ "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/json5": { "version": "2.2.3", @@ -4504,12 +4599,20 @@ "node": ">=6" } }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "json-buffer": "3.0.1" } @@ -4520,6 +4623,7 @@ "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" @@ -4783,6 +4887,7 @@ "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "p-locate": "^5.0.0" }, @@ -4854,6 +4959,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -4884,7 +4999,8 @@ "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/node-releases": { "version": "2.0.51", @@ -4895,6 +5011,24 @@ "node": ">=18" } }, + "node_modules/nypm": { + "version": "0.6.9", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.9.tgz", + "integrity": "sha512-zxlE2yvSWZWmHcNdT3+5zV2lrCogeE9YOklHrR3dFjqutq5wO7GFDYLFDRXLsYnJzwvy/im9fYoxePvS0VTW0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "citty": "^0.2.2", + "pathe": "^2.0.3", + "tinyexec": "^1.2.4" + }, + "bin": { + "nypm": "dist/cli.mjs" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/obug": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", @@ -4943,6 +5077,7 @@ "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", @@ -4961,6 +5096,7 @@ "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "yocto-queue": "^0.1.0" }, @@ -4977,6 +5113,7 @@ "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "p-limit": "^3.0.2" }, @@ -5006,6 +5143,7 @@ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -5020,6 +5158,33 @@ "node": ">=8" } }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -5111,6 +5276,7 @@ "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 0.8.0" } @@ -5130,85 +5296,6 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, - "node_modules/prettier-plugin-tailwindcss": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.8.1.tgz", - "integrity": "sha512-iaFMYqDsE4ffdDkn5qup0j5f2aCEBFZrdrZnvu9QKTlWx/iGPeQ4HHu7b7fCPMxeo9nwQBiOAh2nSypdFYWJkw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20.19" - }, - "peerDependencies": { - "@ianvs/prettier-plugin-sort-imports": "*", - "@prettier/plugin-hermes": "*", - "@prettier/plugin-oxc": "*", - "@prettier/plugin-pug": "*", - "@shopify/prettier-plugin-liquid": "*", - "@trivago/prettier-plugin-sort-imports": "*", - "@zackad/prettier-plugin-twig": "*", - "prettier": "^3.0", - "prettier-plugin-astro": "*", - "prettier-plugin-css-order": "*", - "prettier-plugin-jsdoc": "*", - "prettier-plugin-marko": "*", - "prettier-plugin-multiline-arrays": "*", - "prettier-plugin-organize-attributes": "*", - "prettier-plugin-organize-imports": "*", - "prettier-plugin-sort-imports": "*", - "prettier-plugin-svelte": "*" - }, - "peerDependenciesMeta": { - "@ianvs/prettier-plugin-sort-imports": { - "optional": true - }, - "@prettier/plugin-hermes": { - "optional": true - }, - "@prettier/plugin-oxc": { - "optional": true - }, - "@prettier/plugin-pug": { - "optional": true - }, - "@shopify/prettier-plugin-liquid": { - "optional": true - }, - "@trivago/prettier-plugin-sort-imports": { - "optional": true - }, - "@zackad/prettier-plugin-twig": { - "optional": true - }, - "prettier-plugin-astro": { - "optional": true - }, - "prettier-plugin-css-order": { - "optional": true - }, - "prettier-plugin-jsdoc": { - "optional": true - }, - "prettier-plugin-marko": { - "optional": true - }, - "prettier-plugin-multiline-arrays": { - "optional": true - }, - "prettier-plugin-organize-attributes": { - "optional": true - }, - "prettier-plugin-organize-imports": { - "optional": true - }, - "prettier-plugin-sort-imports": { - "optional": true - }, - "prettier-plugin-svelte": { - "optional": true - } - } - }, "node_modules/pretty-format": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", @@ -5467,6 +5554,13 @@ "dev": true, "license": "ISC" }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, "node_modules/source-map": { "version": "0.7.6", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", @@ -5688,6 +5782,7 @@ "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "prelude-ls": "^1.2.1" }, @@ -5709,36 +5804,46 @@ "node": ">=14.17" } }, - "node_modules/typescript-eslint": { - "version": "8.64.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.64.0.tgz", - "integrity": "sha512-0qg+pDNMnqYzqH9AnNK+39tejHvsShUOUUoRUgtnTGE7QuMZhiFDnozq8nHJVq+Wae6NMLKNWLg5WmkcC/ndyQ==", + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "license": "MIT" + }, + "node_modules/ultracite": { + "version": "7.9.4", + "resolved": "https://registry.npmjs.org/ultracite/-/ultracite-7.9.4.tgz", + "integrity": "sha512-efAEbQJoii3wH0aZ+jwbcGXQYBwC1E6TvC4//EIV3ei84RDGHDo9fSB6h/TBTNk/+/psQVGm56trVvA+2VwRbg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.64.0", - "@typescript-eslint/parser": "8.64.0", - "@typescript-eslint/typescript-estree": "8.64.0", - "@typescript-eslint/utils": "8.64.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "@clack/prompts": "^1.5.1", + "@typescript-eslint/utils": "^8.62.1", + "commander": "^15.0.0", + "cross-spawn": "^7.0.6", + "deepmerge": "^4.3.1", + "glob": "^13.0.6", + "jsonc-parser": "^3.3.1", + "nypm": "^0.6.6", + "yaml": "^2.9.0", + "zod": "^4.4.3" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "bin": { + "ultracite": "dist/index.js" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" + "oxfmt": ">=0.1.0", + "oxlint": "^1.0.0" + }, + "peerDependenciesMeta": { + "oxfmt": { + "optional": true + }, + "oxlint": { + "optional": true + } } }, - "node_modules/ufo": { - "version": "1.6.4", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", - "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", - "license": "MIT" - }, "node_modules/undici": { "version": "7.28.0", "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", @@ -5846,6 +5951,7 @@ "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "punycode": "^2.1.0" } @@ -6139,6 +6245,7 @@ "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -6219,12 +6326,29 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "license": "ISC" }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -6241,19 +6365,6 @@ "url": "https://github.com/sponsors/colinhacks" } }, - "node_modules/zod-validation-error": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", - "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "zod": "^3.25.0 || ^4.0.0" - } - }, "node_modules/zustand": { "version": "4.5.7", "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 507ef793..de9787eb 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -24,14 +24,13 @@ "preview:relay": "node scripts/local-preview-relay.mjs", "build": "vite build", "start": "srvx --prod -s ../client dist/server/server.js", - "format": "prettier --write .", - "format:check": "prettier --check .", - "lint": "eslint . --max-warnings=0", + "lint": "ultracite check", + "lint:fix": "ultracite fix", "typecheck": "tsc --noEmit", "test": "vitest run", "test:relay": "node --test scripts/local-preview-relay.test.mjs", "test:watch": "vitest", - "check": "npm run format:check && npm run lint && npm run typecheck && npm run test && npm run test:relay && npm run build" + "check": "npm run lint && npm run typecheck && npm run test && npm run test:relay && npm run build" }, "dependencies": { "@base-ui/react": "^1.6.0", @@ -56,10 +55,9 @@ "ws": "^8.21.1" }, "devDependencies": { - "@eslint/js": "^10.0.1", + "@biomejs/biome": "^2.5.6", "@hey-api/openapi-ts": "0.99.0", "@tailwindcss/vite": "^4.3.3", - "@tanstack/eslint-plugin-query": "^5.101.2", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", @@ -68,16 +66,10 @@ "@types/react-dom": "^19.2.3", "@types/ws": "^8.18.1", "@vitejs/plugin-react": "^6.0.3", - "eslint": "^10.7.0", - "eslint-plugin-react-hooks": "^7.1.1", - "eslint-plugin-react-refresh": "^0.5.3", - "globals": "^17.7.0", "jsdom": "^29.1.1", - "prettier": "^3.9.5", - "prettier-plugin-tailwindcss": "^0.8.1", "tailwindcss": "^4.3.3", "typescript": "^6.0.3", - "typescript-eslint": "^8.64.0", + "ultracite": "^7.9.4", "vite": "^8.1.5", "vitest": "^4.1.10" } diff --git a/apps/dashboard/prettier.config.mjs b/apps/dashboard/prettier.config.mjs deleted file mode 100644 index de123508..00000000 --- a/apps/dashboard/prettier.config.mjs +++ /dev/null @@ -1,7 +0,0 @@ -/** @type {import("prettier").Config} */ -export default { - plugins: ["prettier-plugin-tailwindcss"], - printWidth: 100, - semi: false, - trailingComma: "all", -} diff --git a/apps/dashboard/scripts/dev-studio.mjs b/apps/dashboard/scripts/dev-studio.mjs index 59070d60..99bc255a 100644 --- a/apps/dashboard/scripts/dev-studio.mjs +++ b/apps/dashboard/scripts/dev-studio.mjs @@ -1,27 +1,33 @@ -import { spawn } from "node:child_process" +import { spawn } from "node:child_process"; -const npm = process.platform === "win32" ? "npm.cmd" : "npm" +const npm = process.platform === "win32" ? "npm.cmd" : "npm"; const processes = [ spawn(npm, ["run", "preview:relay"], { stdio: "inherit" }), spawn(npm, ["run", "dev"], { stdio: "inherit" }), -] +]; -let stopping = false +let stopping = false; function stop(exitCode = 0) { - if (stopping) return - stopping = true + if (stopping) { + return; + } + stopping = true; for (const child of processes) { - if (!child.killed) child.kill("SIGTERM") + if (!child.killed) { + child.kill("SIGTERM"); + } } - process.exitCode = exitCode + process.exitCode = exitCode; } for (const child of processes) { child.on("exit", (code, signal) => { - if (!stopping) stop(signal ? 1 : (code ?? 1)) - }) - child.on("error", () => stop(1)) + if (!stopping) { + stop(signal ? 1 : (code ?? 1)); + } + }); + child.on("error", () => stop(1)); } -process.once("SIGINT", () => stop(0)) -process.once("SIGTERM", () => stop(0)) +process.once("SIGINT", () => stop(0)); +process.once("SIGTERM", () => stop(0)); diff --git a/apps/dashboard/scripts/live-preview-demo.mjs b/apps/dashboard/scripts/live-preview-demo.mjs index b9754306..c67aebbf 100644 --- a/apps/dashboard/scripts/live-preview-demo.mjs +++ b/apps/dashboard/scripts/live-preview-demo.mjs @@ -1,138 +1,167 @@ -import { readFile } from "node:fs/promises" +import { readFile } from "node:fs/promises"; -import WebSocket from "ws" +import WebSocket from "ws"; import { localPreviewWebSocketProtocols, validatePreviewMessage, -} from "../../../protocol/browser/index.js" - -const endpoint = process.env.MOSAIC_PREVIEW_ENDPOINT ?? "ws://127.0.0.1:4317/preview" -const sessionId = process.env.MOSAIC_PREVIEW_SESSION_ID ?? "session_local_01" -const revisionSequence = Number.parseInt(process.env.MOSAIC_DEMO_SEQUENCE ?? "2", 10) +} from "../../../protocol/browser/index.js"; + +const endpoint = + process.env.MOSAIC_PREVIEW_ENDPOINT ?? "ws://127.0.0.1:4317/preview"; +const sessionId = process.env.MOSAIC_PREVIEW_SESSION_ID ?? "session_local_01"; +const revisionSequence = Number.parseInt( + process.env.MOSAIC_DEMO_SEQUENCE ?? "2", + 10 +); if (!Number.isSafeInteger(revisionSequence) || revisionSequence < 2) { - throw new Error("MOSAIC_DEMO_SEQUENCE must be an integer of at least 2.") + throw new Error("MOSAIC_DEMO_SEQUENCE must be an integer of at least 2."); } -const revisionSuffix = String(revisionSequence).padStart(6, "0") -const revisionId = `revision_phase2_live_demo_${revisionSuffix}` -const holdMilliseconds = Number.parseInt(process.env.MOSAIC_DEMO_HOLD_MS ?? "0", 10) +const revisionSuffix = String(revisionSequence).padStart(6, "0"); +const revisionId = `revision_phase2_live_demo_${revisionSuffix}`; +const holdMilliseconds = Number.parseInt( + process.env.MOSAIC_DEMO_HOLD_MS ?? "0", + 10 +); if (!Number.isSafeInteger(holdMilliseconds) || holdMilliseconds < 0) { - throw new Error("MOSAIC_DEMO_HOLD_MS must be a non-negative integer.") + throw new Error("MOSAIC_DEMO_HOLD_MS must be a non-negative integer."); } -const expectedRenderers = new Set(["mosaic.flutter", "mosaic.ios", "mosaic.android"]) -const headline = "Ship one native paywall in under a minute" -const deadline = Date.now() + 20_000 +const expectedRenderers = new Set([ + "mosaic.flutter", + "mosaic.ios", + "mosaic.android", +]); +const headline = "Ship one native paywall in under a minute"; +const deadline = Date.now() + 20_000; const fixtures = JSON.parse( await readFile( new URL( "../../../protocol/fixtures/local-preview/v0.2/session-flow.messages.json", - import.meta.url, + import.meta.url ), - "utf8", - ), -) + "utf8" + ) +); -const clients = new Map() -const capabilities = new Set() -const acknowledgements = new Set() -const diagnostics = [] +const clients = new Map(); +const capabilities = new Set(); +const acknowledgements = new Set(); +const diagnostics = []; -const studioUrl = new URL(endpoint) -studioUrl.searchParams.set("role", "studio") -studioUrl.searchParams.set("sessionId", sessionId) -const socket = new WebSocket(studioUrl, localPreviewWebSocketProtocols["0.2"]) +const studioUrl = new URL(endpoint); +studioUrl.searchParams.set("role", "studio"); +studioUrl.searchParams.set("sessionId", sessionId); +const socket = new WebSocket(studioUrl, localPreviewWebSocketProtocols["0.2"]); socket.on("message", (frame) => { - const message = JSON.parse(frame.toString()) + const message = JSON.parse(frame.toString()); if (!validatePreviewMessage(message).ok) { - diagnostics.push({ type: "invalidMessage", messageId: message.messageId }) - return + diagnostics.push({ type: "invalidMessage", messageId: message.messageId }); + return; } if (message.type === "previewClientConnected") { - clients.set(message.payload.client.clientId, message.payload.client) + clients.set(message.payload.client.clientId, message.payload.client); } else if (message.type === "capabilityReport") { - capabilities.add(message.payload.clientId) + capabilities.add(message.payload.clientId); } else if ( message.type === "draftAccepted" && message.payload.editableDocumentId === "document_phase2_live_demo" && message.payload.revision.revisionId === revisionId && message.payload.revision.sequence === revisionSequence ) { - acknowledgements.add(message.payload.clientId) + acknowledgements.add(message.payload.clientId); } else if ( - ["draftRejected", "validationError", "renderWarning", "renderFailure"].includes(message.type) + [ + "draftRejected", + "validationError", + "renderWarning", + "renderFailure", + ].includes(message.type) ) { diagnostics.push({ type: message.type, clientId: message.payload.clientId, - codes: (message.payload.warnings ?? message.payload.errors ?? []).map((entry) => entry.code), - }) + codes: (message.payload.warnings ?? message.payload.errors ?? []).map( + (entry) => entry.code + ), + }); } -}) +}); await new Promise((resolve, reject) => { - socket.once("open", resolve) - socket.once("error", reject) -}) + socket.once("open", resolve); + socket.once("error", reject); +}); await waitUntil(() => { const readyRenderers = new Set( [...clients.entries()] .filter(([clientId]) => capabilities.has(clientId)) - .map(([, client]) => client.renderer.id), - ) - return [...expectedRenderers].every((renderer) => readyRenderers.has(renderer)) -}) + .map(([, client]) => client.renderer.id) + ); + return [...expectedRenderers].every((renderer) => + readyRenderers.has(renderer) + ); +}); const commerce = structuredClone( - fixtures.find((message) => message.type === "mockCommerceStateChanged"), -) -commerce.messageId = `msg_phase2_live_demo_commerce_${revisionSuffix}` -commerce.sessionId = sessionId -commerce.sentAt = new Date().toISOString() -commerce.payload.editableDocumentId = "document_phase2_live_demo" + fixtures.find((message) => message.type === "mockCommerceStateChanged") +); +commerce.messageId = `msg_phase2_live_demo_commerce_${revisionSuffix}`; +commerce.sessionId = sessionId; +commerce.sentAt = new Date().toISOString(); +commerce.payload.editableDocumentId = "document_phase2_live_demo"; commerce.payload.stateRevision = { revisionId: `revision_phase2_live_demo_commerce_${String(revisionSequence - 1).padStart(6, "0")}`, sequence: revisionSequence - 1, -} - -const draft = structuredClone(fixtures.find((message) => message.type === "draftUpdated")) -draft.messageId = `msg_phase2_live_demo_draft_${revisionSuffix}` -draft.sessionId = sessionId -draft.sentAt = new Date().toISOString() -draft.payload.editableDocumentId = "document_phase2_live_demo" +}; + +const draft = structuredClone( + fixtures.find((message) => message.type === "draftUpdated") +); +draft.messageId = `msg_phase2_live_demo_draft_${revisionSuffix}`; +draft.sessionId = sessionId; +draft.sentAt = new Date().toISOString(); +draft.payload.editableDocumentId = "document_phase2_live_demo"; draft.payload.revision = { revisionId, sequence: revisionSequence, -} -draft.payload.document.revision = revisionSequence -draft.payload.document.localization.locales.en.strings["paywall.headline"] = headline -const children = draft.payload.document.layout.content.children -const headlineNode = children.find((node) => node.id === "headline") -headlineNode.value.default = headline -const productSelector = children.find((node) => node.id === "plans") +}; +draft.payload.document.revision = revisionSequence; +draft.payload.document.localization.locales.en.strings["paywall.headline"] = + headline; +const { children } = draft.payload.document.layout.content; +const headlineNode = children.find((node) => node.id === "headline"); +headlineNode.value.default = headline; +const productSelector = children.find((node) => node.id === "plans"); if (!productSelector.productReferenceIds.includes("yearly-plan")) { - throw new Error("The demo document does not bind the yearly product.") + throw new Error("The demo document does not bind the yearly product."); } -productSelector.initiallySelectedProductReferenceId = "yearly-plan" -const featureIndex = children.findIndex((node) => node.id === "features") -const subtitleIndex = children.findIndex((node) => node.id === "subtitle") -const [features] = children.splice(featureIndex, 1) -children.splice(subtitleIndex, 0, features) +productSelector.initiallySelectedProductReferenceId = "yearly-plan"; +const featureIndex = children.findIndex((node) => node.id === "features"); +const subtitleIndex = children.findIndex((node) => node.id === "subtitle"); +const [features] = children.splice(featureIndex, 1); +children.splice(subtitleIndex, 0, features); for (const message of [commerce, draft]) { - const result = validatePreviewMessage(message) - if (!result.ok) throw new Error(`Demo message ${message.type} is not protocol-valid.`) - socket.send(JSON.stringify(message)) + const result = validatePreviewMessage(message); + if (!result.ok) { + throw new Error(`Demo message ${message.type} is not protocol-valid.`); + } + socket.send(JSON.stringify(message)); } await waitUntil(() => { const acknowledgedRenderers = new Set( - [...acknowledgements].map((clientId) => clients.get(clientId)?.renderer.id).filter(Boolean), - ) - return [...expectedRenderers].every((renderer) => acknowledgedRenderers.has(renderer)) -}) + [...acknowledgements] + .map((clientId) => clients.get(clientId)?.renderer.id) + .filter(Boolean) + ); + return [...expectedRenderers].every((renderer) => + acknowledgedRenderers.has(renderer) + ); +}); const result = { sessionId, @@ -150,14 +179,14 @@ const result = { })) .sort((left, right) => left.renderer.localeCompare(right.renderer)), diagnostics, -} +}; -console.log(JSON.stringify(result, null, 2)) +console.log(JSON.stringify(result, null, 2)); -const holdDeadline = Date.now() + holdMilliseconds -let heartbeatSequence = 0 +const holdDeadline = Date.now() + holdMilliseconds; +let heartbeatSequence = 0; while (Date.now() < holdDeadline) { - heartbeatSequence += 1 + heartbeatSequence += 1; for (const client of result.clients) { const heartbeat = { previewProtocolVersion: "0.2", @@ -165,14 +194,23 @@ while (Date.now() < holdDeadline) { sessionId, sentAt: new Date().toISOString(), type: "previewHeartbeat", - payload: { clientId: client.clientId, kind: "ping", sequence: heartbeatSequence }, + payload: { + clientId: client.clientId, + kind: "ping", + sequence: heartbeatSequence, + }, + }; + if (!validatePreviewMessage(heartbeat).ok) { + throw new Error("Demo heartbeat is not valid."); } - if (!validatePreviewMessage(heartbeat).ok) throw new Error("Demo heartbeat is not valid.") - socket.send(JSON.stringify(heartbeat)) + socket.send(JSON.stringify(heartbeat)); } - await new Promise((resolve) => setTimeout(resolve, Math.min(2_000, holdDeadline - Date.now()))) + // biome-ignore lint/performance/noAwaitInLoops: paces the demo; the next frame is meant to follow the delay + await new Promise((resolve) => + setTimeout(resolve, Math.min(2000, holdDeadline - Date.now())) + ); } -socket.close() +socket.close(); async function waitUntil(predicate) { while (!predicate()) { @@ -180,9 +218,10 @@ async function waitUntil(predicate) { throw new Error( `Timed out waiting for Flutter, SwiftUI, and Compose in ${sessionId}. ` + `Connected: ${[...clients.values()].map((client) => client.renderer.id).join(", ") || "none"}. ` + - `Acknowledged: ${[...acknowledgements].join(", ") || "none"}.`, - ) + `Acknowledged: ${[...acknowledgements].join(", ") || "none"}.` + ); } - await new Promise((resolve) => setTimeout(resolve, 25)) + // biome-ignore lint/performance/noAwaitInLoops: paces the demo; the next frame is meant to follow the delay + await new Promise((resolve) => setTimeout(resolve, 25)); } } diff --git a/apps/dashboard/scripts/local-preview-relay.mjs b/apps/dashboard/scripts/local-preview-relay.mjs index f55c7e73..2e20919e 100644 --- a/apps/dashboard/scripts/local-preview-relay.mjs +++ b/apps/dashboard/scripts/local-preview-relay.mjs @@ -1,27 +1,32 @@ -import { pathToFileURL } from "node:url" +import { pathToFileURL } from "node:url"; -import { WebSocket, WebSocketServer } from "ws" +import { WebSocket, WebSocketServer } from "ws"; import { decideLocalPreviewDraftDelivery, localPreviewVersionPreference, localPreviewWebSocketProtocols, validatePreviewMessage, -} from "../../../protocol/browser/index.js" +} from "../../../protocol/browser/index.js"; export const PREVIEW_SUBPROTOCOLS = Object.freeze( - localPreviewVersionPreference.map((version) => localPreviewWebSocketProtocols[version]), -) -export const PREVIEW_SUBPROTOCOL = localPreviewWebSocketProtocols["0.2"] -const HEARTBEAT_TIMEOUT_MS = 15_000 + localPreviewVersionPreference.map( + (version) => localPreviewWebSocketProtocols[version] + ) +); +export const PREVIEW_SUBPROTOCOL = localPreviewWebSocketProtocols["0.2"]; +const HEARTBEAT_TIMEOUT_MS = 15_000; const STUDIO_MESSAGE_TYPES = new Set([ "draftUpdated", "mockCommerceStateChanged", "previewHeartbeat", -]) +]); const VERSION_BY_SUBPROTOCOL = new Map( - Object.entries(localPreviewWebSocketProtocols).map(([version, protocol]) => [protocol, version]), -) + Object.entries(localPreviewWebSocketProtocols).map(([version, protocol]) => [ + protocol, + version, + ]) +); const CLIENT_MESSAGE_TYPES = new Set([ "previewClientDisconnected", "draftAccepted", @@ -30,21 +35,34 @@ const CLIENT_MESSAGE_TYPES = new Set([ "renderWarning", "renderFailure", "previewHeartbeat", -]) +]); function isLoopbackOrigin(origin) { - if (!origin) return true + if (!origin) { + return true; + } try { - const hostname = new URL(origin).hostname - return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "[::1]" + const { hostname } = new URL(origin); + return ( + hostname === "127.0.0.1" || + hostname === "localhost" || + hostname === "[::1]" + ); } catch { - return false + return false; } } function messageForVersion(message, targetVersion) { - if (message.previewProtocolVersion === targetVersion) return message - if (message.type === "draftUpdated" || message.type === "mockCommerceStateChanged") return null + if (message.previewProtocolVersion === targetVersion) { + return message; + } + if ( + message.type === "draftUpdated" || + message.type === "mockCommerceStateChanged" + ) { + return null; + } const translated = { ...message, previewProtocolVersion: targetVersion, @@ -52,25 +70,29 @@ function messageForVersion(message, targetVersion) { message.type === "capabilityReport" ? { ...message.payload, - previewCapabilities: message.payload.previewCapabilities.map((capability) => ({ - ...capability, - version: targetVersion, - })), + previewCapabilities: message.payload.previewCapabilities.map( + (capability) => ({ + ...capability, + version: targetVersion, + }) + ), } : message.payload, - } - return validatePreviewMessage(translated).ok ? translated : null + }; + return validatePreviewMessage(translated).ok ? translated : null; } function sendCanonical(socket, meta, message) { - const translated = messageForVersion(message, meta.protocolVersion) - if (!translated) return false - socket.send(JSON.stringify(translated)) - return true + const translated = messageForVersion(message, meta.protocolVersion); + if (!translated) { + return false; + } + socket.send(JSON.stringify(translated)); + return true; } function incompatibleDraftDecision(message, peerMeta) { - const document = message.payload.document + const { document } = message.payload; if (document.schemaVersion === "0.2") { return decideLocalPreviewDraftDelivery({ capabilityReport: peerMeta.capabilityReport, @@ -80,9 +102,11 @@ function incompatibleDraftDecision(message, peerMeta) { selectedVersion: peerMeta.protocolVersion, selectedWebSocketSubprotocol: peerMeta.protocol, }, - }) + }); + } + if (document.schemaVersion === peerMeta.protocolVersion) { + return { delivery: "send" }; } - if (document.schemaVersion === peerMeta.protocolVersion) return { delivery: "send" } return { delivery: "withhold", diagnostic: { @@ -91,17 +115,24 @@ function incompatibleDraftDecision(message, peerMeta) { fallback: "keepLastAcceptedDraft", recovery: { action: "updatePreviewClient", - message: "Update the preview client to a version that supports this paywall format.", + message: + "Update the preview client to a version that supports this paywall format.", }, }, - } + }; } function rejectionReason(code) { - if (code === "preview.incompatibleSchemaVersion") return "unsupportedSchemaVersion" - if (code === "preview.unsupportedCapability") return "unsupportedCapability" - if (code === "preview.documentTooLarge") return "documentTooLarge" - return "validationFailed" + if (code === "preview.incompatibleSchemaVersion") { + return "unsupportedSchemaVersion"; + } + if (code === "preview.unsupportedCapability") { + return "unsupportedCapability"; + } + if (code === "preview.documentTooLarge") { + return "documentTooLarge"; + } + return "validationFailed"; } function draftRejection(message, clientId, diagnostic, protocolVersion) { @@ -120,12 +151,15 @@ function draftRejection(message, clientId, diagnostic, protocolVersion) { { code: diagnostic.code, message: diagnostic.message, - location: { documentPath: "/schemaVersion", property: "schemaVersion" }, + location: { + documentPath: "/schemaVersion", + property: "schemaVersion", + }, recovery: diagnostic.recovery, }, ], }, - } + }; } function relayMessage( @@ -135,11 +169,11 @@ function relayMessage( sender, message, targetRole, - targetClientId = null, + targetClientId = null ) { - const senderMeta = metadata.get(sender) + const senderMeta = metadata.get(sender); for (const peer of server.clients) { - const peerMeta = metadata.get(peer) + const peerMeta = metadata.get(peer); if ( peer !== sender && peer.readyState === WebSocket.OPEN && @@ -149,7 +183,7 @@ function relayMessage( (targetRole !== "client" || peerMeta.phase === "ready") ) { if (targetRole === "client" && message.type === "draftUpdated") { - const decision = incompatibleDraftDecision(message, peerMeta) + const decision = incompatibleDraftDecision(message, peerMeta); if (decision.delivery === "withhold") { sendCanonical( sender, @@ -158,10 +192,10 @@ function relayMessage( message, peerMeta.clientId, decision.diagnostic, - senderMeta.protocolVersion, - ), - ) - continue + senderMeta.protocolVersion + ) + ); + continue; } } if ( @@ -169,9 +203,9 @@ function relayMessage( message.type === "mockCommerceStateChanged" && message.previewProtocolVersion !== peerMeta.protocolVersion ) { - continue + continue; } - sendCanonical(peer, peerMeta, message) + sendCanonical(peer, peerMeta, message); } } } @@ -184,71 +218,98 @@ function relayEnvelope(protocolVersion, sessionId, type, payload) { sentAt: new Date().toISOString(), type, payload, - } + }; } -export function createPreviewRelay({ host = "127.0.0.1", port = 4317, path = "/preview" } = {}) { +export function createPreviewRelay({ + host = "127.0.0.1", + port = 4317, + path = "/preview", +} = {}) { if (host !== "127.0.0.1" && host !== "::1" && host !== "localhost") { - throw new Error("The Phase 2 preview relay may only bind to a loopback interface.") + throw new Error( + "The Phase 2 preview relay may only bind to a loopback interface." + ); } - const metadata = new WeakMap() - const cachedClients = new Map() + const metadata = new WeakMap(); + const cachedClients = new Map(); const server = new WebSocketServer({ host, port, path, maxPayload: 2_097_152, handleProtocols(protocols) { - return PREVIEW_SUBPROTOCOLS.find((protocol) => protocols.has(protocol)) ?? false + return ( + PREVIEW_SUBPROTOCOLS.find((protocol) => protocols.has(protocol)) ?? + false + ); }, verifyClient(info, done) { if (!isLoopbackOrigin(info.origin)) { - done(false, 403, "Loopback origins only") - return + done(false, 403, "Loopback origins only"); + return; } - const requestedProtocols = String(info.req.headers["sec-websocket-protocol"] ?? "") + const requestedProtocols = String( + info.req.headers["sec-websocket-protocol"] ?? "" + ) .split(",") - .map((value) => value.trim()) - if (!PREVIEW_SUBPROTOCOLS.some((protocol) => requestedProtocols.includes(protocol))) { - done(false, 426, "Required WebSocket subprotocol missing") - return + .map((value) => value.trim()); + if ( + !PREVIEW_SUBPROTOCOLS.some((protocol) => + requestedProtocols.includes(protocol) + ) + ) { + done(false, 426, "Required WebSocket subprotocol missing"); + return; } - done(true) + done(true); }, - }) + }); const ready = new Promise((resolve, reject) => { - server.once("listening", resolve) - server.once("error", reject) - }) + server.once("listening", resolve); + server.once("error", reject); + }); function sessionCache(sessionId) { - let cache = cachedClients.get(sessionId) + let cache = cachedClients.get(sessionId); if (!cache) { - cache = new Map() - cachedClients.set(sessionId, cache) + cache = new Map(); + cachedClients.set(sessionId, cache); } - return cache + return cache; } function replayClients(socket, sessionId) { - const cache = cachedClients.get(sessionId) - if (!cache) return - const meta = metadata.get(socket) + const cache = cachedClients.get(sessionId); + if (!cache) { + return; + } + const meta = metadata.get(socket); for (const client of cache.values()) { - if (client.connected) sendCanonical(socket, meta, client.connected) - if (client.capability) sendCanonical(socket, meta, client.capability) + if (client.connected) { + sendCanonical(socket, meta, client.connected); + } + if (client.capability) { + sendCanonical(socket, meta, client.capability); + } } } function disconnectClient(socket, reason) { - const meta = metadata.get(socket) - if (!meta?.clientId || !meta.sessionId || meta.disconnected) return - meta.disconnected = true - const cache = cachedClients.get(meta.sessionId) - if (cache?.get(meta.clientId)?.socket !== socket) return - cache?.delete(meta.clientId) - if (cache?.size === 0) cachedClients.delete(meta.sessionId) + const meta = metadata.get(socket); + if (!(meta?.clientId && meta.sessionId) || meta.disconnected) { + return; + } + meta.disconnected = true; + const cache = cachedClients.get(meta.sessionId); + if (cache?.get(meta.clientId)?.socket !== socket) { + return; + } + cache?.delete(meta.clientId); + if (cache?.size === 0) { + cachedClients.delete(meta.sessionId); + } const message = relayEnvelope( meta.protocolVersion, meta.sessionId, @@ -256,21 +317,22 @@ export function createPreviewRelay({ host = "127.0.0.1", port = 4317, path = "/p { clientId: meta.clientId, reason, - }, - ) - relayMessage(server, metadata, meta.sessionId, socket, message, "studio") + } + ); + relayMessage(server, metadata, meta.sessionId, socket, message, "studio"); } server.on("connection", (socket, request) => { - const requestUrl = new URL(request.url ?? path, `http://${host}`) - const requestedRole = requestUrl.searchParams.get("role") - const isStudio = requestedRole === "studio" - const querySession = requestUrl.searchParams.get("sessionId") - const protocol = socket.protocol - const protocolVersion = VERSION_BY_SUBPROTOCOL.get(protocol) + const requestUrl = new URL(request.url ?? path, `http://${host}`); + const requestedRole = requestUrl.searchParams.get("role"); + const isStudio = requestedRole === "studio"; + const querySession = requestUrl.searchParams.get("sessionId"); + const { protocol } = socket; + const protocolVersion = VERSION_BY_SUBPROTOCOL.get(protocol); const meta = { role: isStudio ? "studio" : "client", - sessionId: isStudio && querySession?.startsWith("session_") ? querySession : null, + sessionId: + isStudio && querySession?.startsWith("session_") ? querySession : null, clientId: null, phase: isStudio ? "ready" : "awaitingConnected", lastActivityAt: Date.now(), @@ -278,122 +340,188 @@ export function createPreviewRelay({ host = "127.0.0.1", port = 4317, path = "/p protocol, protocolVersion, capabilityReport: null, - } - metadata.set(socket, meta) + }; + metadata.set(socket, meta); if ( !protocolVersion || - (requestedRole && requestedRole !== "studio" && requestedRole !== "client") || + (requestedRole && + requestedRole !== "studio" && + requestedRole !== "client") || (isStudio && !meta.sessionId) ) { - socket.close(1008, "A Studio connection requires a valid role and sessionId") - return + socket.close( + 1008, + "A Studio connection requires a valid role and sessionId" + ); + return; + } + if (meta.role === "studio" && meta.sessionId) { + replayClients(socket, meta.sessionId); } - if (meta.role === "studio" && meta.sessionId) replayClients(socket, meta.sessionId) socket.on("message", (data, isBinary) => { - if (isBinary) { - socket.close(1003, "JSON text messages only") - return - } - - let message - try { - message = JSON.parse(data.toString()) - } catch { - socket.close(1007, "Invalid JSON") - return - } - if (!validatePreviewMessage(message).ok) { - socket.close(1008, "Invalid preview message") - return - } - if (message.previewProtocolVersion !== meta.protocolVersion) { - socket.close(1008, "Message version does not match the negotiated subprotocol") - return - } - - if (meta.sessionId && meta.sessionId !== message.sessionId) { - socket.close(1008, "Session cannot change") - return - } - - if (meta.role === "studio") { - if (!STUDIO_MESSAGE_TYPES.has(message.type)) { - socket.close(1008, "Message direction is not allowed for Studio") - return + /** Frame-level checks every message has to pass. */ + const rejectInvalidFrame = () => { + if (!validatePreviewMessage(message).ok) { + socket.close(1008, "Invalid preview message"); + return true; } - } else if (meta.phase === "awaitingConnected") { - if (message.type !== "previewClientConnected") { - socket.close(1008, "The first client message must identify the preview client") - return + if (message.previewProtocolVersion !== meta.protocolVersion) { + socket.close( + 1008, + "Message version does not match the negotiated subprotocol" + ); + return true; } - } else if (meta.phase === "awaitingCapability") { - if (message.type !== "capabilityReport") { - socket.close(1008, "The capability report must follow client identity") - return + if (meta.sessionId && meta.sessionId !== message.sessionId) { + socket.close(1008, "Session cannot change"); + return true; } - } else if (meta.phase !== "ready" || !CLIENT_MESSAGE_TYPES.has(message.type)) { - socket.close(1008, "Message direction is not allowed for a preview client") - return - } - - if ( - meta.role === "client" && - meta.phase === "ready" && - cachedClients.get(message.sessionId)?.get(meta.clientId)?.socket !== socket - ) { - socket.close(1008, "This preview client connection has been replaced") - return - } + return false; + }; - meta.sessionId = message.sessionId - - if (message.type === "previewClientConnected") { - meta.clientId = message.payload.client.clientId - meta.phase = "awaitingCapability" - meta.disconnected = false - meta.connected = message - const cache = sessionCache(message.sessionId) - if (!cache.has(meta.clientId)) { - cache.set(meta.clientId, { connected: message, capability: null, socket }) - } - } else if (message.type === "capabilityReport") { - if (message.payload.clientId !== meta.clientId) { - socket.close(1008, "Capability identity does not match the connected client") - return - } - meta.phase = "ready" - meta.capabilityReport = message.payload - const cache = sessionCache(message.sessionId) - const previousSocket = cache.get(meta.clientId)?.socket - cache.set(meta.clientId, { - connected: meta.connected, - capability: message, - socket, - }) - if (previousSocket && previousSocket !== socket) { - queueMicrotask(() => previousSocket.close(1000, "Replaced by reconnect")) + /** What a studio-role socket is allowed to send. */ + const rejectStudioFrame = () => { + if (meta.role === "studio") { + if (!STUDIO_MESSAGE_TYPES.has(message.type)) { + socket.close(1008, "Message direction is not allowed for Studio"); + return true; + } + } else if (meta.phase === "awaitingConnected") { + if (message.type !== "previewClientConnected") { + socket.close( + 1008, + "The first client message must identify the preview client" + ); + return true; + } + } else if (meta.phase === "awaitingCapability") { + if (message.type !== "capabilityReport") { + socket.close( + 1008, + "The capability report must follow client identity" + ); + return true; + } + } else if ( + meta.phase !== "ready" || + !CLIENT_MESSAGE_TYPES.has(message.type) + ) { + socket.close( + 1008, + "Message direction is not allowed for a preview client" + ); + return true; } - } else if (message.type === "previewClientDisconnected") { - if (message.payload.clientId !== meta.clientId) { - socket.close(1008, "Disconnect identity does not match the connected client") - return + if ( + meta.role === "client" && + meta.phase === "ready" && + cachedClients.get(message.sessionId)?.get(meta.clientId)?.socket !== + socket + ) { + socket.close( + 1008, + "This preview client connection has been replaced" + ); + return true; } - if (sessionCache(message.sessionId).get(meta.clientId)?.socket !== socket) { - meta.disconnected = true - meta.phase = "disconnected" - queueMicrotask(() => socket.close(1000, "Preview client connection replaced")) - return + return false; + }; + + /** The connect frame carries the renderer's identity and capabilities. */ + const handleClientConnected = () => { + if (message.type === "previewClientConnected") { + meta.clientId = message.payload.client.clientId; + meta.phase = "awaitingCapability"; + meta.disconnected = false; + meta.connected = message; + const cache = sessionCache(message.sessionId); + if (!cache.has(meta.clientId)) { + cache.set(meta.clientId, { + connected: message, + capability: null, + socket, + }); + } + } else if (message.type === "capabilityReport") { + if (message.payload.clientId !== meta.clientId) { + socket.close( + 1008, + "Capability identity does not match the connected client" + ); + return true; + } + meta.phase = "ready"; + meta.capabilityReport = message.payload; + const cache = sessionCache(message.sessionId); + const previousSocket = cache.get(meta.clientId)?.socket; + cache.set(meta.clientId, { + connected: meta.connected, + capability: message, + socket, + }); + if (previousSocket && previousSocket !== socket) { + queueMicrotask(() => + previousSocket.close(1000, "Replaced by reconnect") + ); + } + } else if (message.type === "previewClientDisconnected") { + if (message.payload.clientId !== meta.clientId) { + socket.close( + 1008, + "Disconnect identity does not match the connected client" + ); + return true; + } + if ( + sessionCache(message.sessionId).get(meta.clientId)?.socket !== + socket + ) { + meta.disconnected = true; + meta.phase = "disconnected"; + queueMicrotask(() => + socket.close(1000, "Preview client connection replaced") + ); + return true; + } + meta.disconnected = true; + meta.phase = "disconnected"; + sessionCache(message.sessionId).delete(meta.clientId); + } else if ( + meta.role === "client" && + message.payload.clientId !== meta.clientId + ) { + socket.close( + 1008, + "Message identity does not match the connected client" + ); + return true; } - meta.disconnected = true - meta.phase = "disconnected" - sessionCache(message.sessionId).delete(meta.clientId) - } else if (meta.role === "client" && message.payload.clientId !== meta.clientId) { - socket.close(1008, "Message identity does not match the connected client") - return - } + return false; + }; - meta.lastActivityAt = Date.now() + if (isBinary) { + socket.close(1003, "JSON text messages only"); + return; + } + let message; + try { + message = JSON.parse(data.toString()); + } catch { + socket.close(1007, "Invalid JSON"); + return; + } + if (rejectInvalidFrame()) { + return; + } + if (rejectStudioFrame()) { + return; + } + meta.sessionId = message.sessionId; + if (handleClientConnected()) { + return; + } + meta.lastActivityAt = Date.now(); relayMessage( server, metadata, @@ -403,63 +531,68 @@ export function createPreviewRelay({ host = "127.0.0.1", port = 4317, path = "/p meta.role === "studio" ? "client" : "studio", meta.role === "studio" && message.type === "previewHeartbeat" ? message.payload.clientId - : null, - ) + : null + ); if (message.type === "previewClientDisconnected") { - queueMicrotask(() => socket.close(1000, "Preview client disconnected")) + queueMicrotask(() => socket.close(1000, "Preview client disconnected")); } - }) + }); - socket.on("close", () => disconnectClient(socket, "closed")) - }) + socket.on("close", () => disconnectClient(socket, "closed")); + }); const heartbeatTimer = setInterval(() => { - const now = Date.now() + const now = Date.now(); for (const socket of server.clients) { - const meta = metadata.get(socket) + const meta = metadata.get(socket); if ( meta?.role === "client" && meta.clientId && now - meta.lastActivityAt > HEARTBEAT_TIMEOUT_MS ) { - disconnectClient(socket, "timeout") - socket.terminate() + disconnectClient(socket, "timeout"); + socket.terminate(); } } - }, 1_000) - heartbeatTimer.unref() + }, 1000); + heartbeatTimer.unref(); return { server, ready, address() { - const address = server.address() - if (!address || typeof address === "string") return null - const hostname = address.address === "::1" ? "[::1]" : address.address - return `ws://${hostname}:${address.port}${path}` + const address = server.address(); + if (!address || typeof address === "string") { + return null; + } + const hostname = address.address === "::1" ? "[::1]" : address.address; + return `ws://${hostname}:${address.port}${path}`; }, close() { - clearInterval(heartbeatTimer) - for (const client of server.clients) client.terminate() + clearInterval(heartbeatTimer); + for (const client of server.clients) { + client.terminate(); + } return new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())) - }) + server.close((error) => (error ? reject(error) : resolve())); + }); }, - } + }; } -const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href +const isMain = + process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; if (isMain) { - const relay = createPreviewRelay() - await relay.ready - console.log(`Mosaic local preview relay listening at ${relay.address()}`) - console.log(`WebSocket subprotocols: ${PREVIEW_SUBPROTOCOLS.join(", ")}`) + const relay = createPreviewRelay(); + await relay.ready; + console.log(`Mosaic local preview relay listening at ${relay.address()}`); + console.log(`WebSocket subprotocols: ${PREVIEW_SUBPROTOCOLS.join(", ")}`); async function shutdown() { - await relay.close() - process.exit(0) + await relay.close(); + process.exit(0); } - process.once("SIGINT", shutdown) - process.once("SIGTERM", shutdown) + process.once("SIGINT", shutdown); + process.once("SIGTERM", shutdown); } diff --git a/apps/dashboard/scripts/local-preview-relay.test.mjs b/apps/dashboard/scripts/local-preview-relay.test.mjs index 9d20a5b9..91c77f4a 100644 --- a/apps/dashboard/scripts/local-preview-relay.test.mjs +++ b/apps/dashboard/scripts/local-preview-relay.test.mjs @@ -1,11 +1,15 @@ -import assert from "node:assert/strict" -import { once } from "node:events" -import { readFile } from "node:fs/promises" -import test from "node:test" +// biome-ignore-all lint/suspicious/noMisplacedAssertion: both assertions sit in connect/waitForMessages helpers that the tests call; the rule cannot see through the call to the test() that owns them +import assert from "node:assert/strict"; +import { once } from "node:events"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; -import WebSocket from "ws" +import WebSocket from "ws"; -import { createPreviewRelay, PREVIEW_SUBPROTOCOL } from "./local-preview-relay.mjs" +import { + createPreviewRelay, + PREVIEW_SUBPROTOCOL, +} from "./local-preview-relay.mjs"; function heartbeat(sessionId, sequence = 1, clientId = "client_relay_test") { return { @@ -15,309 +19,370 @@ function heartbeat(sessionId, sequence = 1, clientId = "client_relay_test") { sentAt: new Date().toISOString(), type: "previewHeartbeat", payload: { clientId, kind: "ping", sequence }, - } + }; } -async function connect(url, sessionId, receivedMessages, protocols = PREVIEW_SUBPROTOCOL) { - const target = sessionId ? `${url}?role=studio&sessionId=${encodeURIComponent(sessionId)}` : url - const socket = new WebSocket(target, protocols) +async function connect( + url, + sessionId, + receivedMessages, + protocols = PREVIEW_SUBPROTOCOL +) { + const target = sessionId + ? `${url}?role=studio&sessionId=${encodeURIComponent(sessionId)}` + : url; + const socket = new WebSocket(target, protocols); if (receivedMessages) { - socket.on("message", (payload) => receivedMessages.push(JSON.parse(payload.toString()))) + socket.on("message", (payload) => + receivedMessages.push(JSON.parse(payload.toString())) + ); } - await once(socket, "open") + await once(socket, "open"); assert.equal( socket.protocol, Array.isArray(protocols) && protocols.includes(PREVIEW_SUBPROTOCOL) ? PREVIEW_SUBPROTOCOL - : protocols, - ) - return socket + : protocols + ); + return socket; } async function waitForMessages(messages, count) { - const deadline = Date.now() + 2_000 + const deadline = Date.now() + 2000; while (messages.length < count && Date.now() < deadline) { - await new Promise((resolve) => setTimeout(resolve, 10)) + // biome-ignore lint/performance/noAwaitInLoops: polls until the relay settles, which is inherently sequential + await new Promise((resolve) => setTimeout(resolve, 10)); } - assert.equal(messages.length, count) + assert.equal(messages.length, count); } test("negotiates Local Preview 0.2 and relays a canonical Protocol 0.2 draft", async () => { - const relay = createPreviewRelay({ port: 0 }) - await relay.ready - const url = relay.address() - assert.ok(url) + const relay = createPreviewRelay({ port: 0 }); + await relay.ready; + const url = relay.address(); + assert.ok(url); const fixture = JSON.parse( await readFile( new URL( "../../../protocol/fixtures/local-preview/v0.2/session-flow.messages.json", - import.meta.url, + import.meta.url ), - "utf8", - ), - ) - const connected = fixture.find((message) => message.type === "previewClientConnected") - const capability = fixture.find((message) => message.type === "capabilityReport") - const draft = fixture.find((message) => message.type === "draftUpdated") - assert.ok(connected) - assert.ok(capability) - assert.ok(draft) - - const studioMessages = [] - const clientMessages = [] - const studio = await connect(url, connected.sessionId, studioMessages, PREVIEW_SUBPROTOCOL) - const nativeClient = await connect(url, null, clientMessages, [PREVIEW_SUBPROTOCOL]) - nativeClient.send(JSON.stringify(connected)) - nativeClient.send(JSON.stringify(capability)) - await waitForMessages(studioMessages, 2) - assert.ok(studioMessages.every((message) => message.previewProtocolVersion === "0.2")) - - studio.send(JSON.stringify(draft)) - await waitForMessages(clientMessages, 1) - assert.equal(clientMessages[0].type, "draftUpdated") - assert.equal(clientMessages[0].payload.document.schemaVersion, "0.2") - - studio.close() - nativeClient.close() - await relay.close() -}) + "utf8" + ) + ); + const connected = fixture.find( + (message) => message.type === "previewClientConnected" + ); + const capability = fixture.find( + (message) => message.type === "capabilityReport" + ); + const draft = fixture.find((message) => message.type === "draftUpdated"); + assert.ok(connected); + assert.ok(capability); + assert.ok(draft); + + const studioMessages = []; + const clientMessages = []; + const studio = await connect( + url, + connected.sessionId, + studioMessages, + PREVIEW_SUBPROTOCOL + ); + const nativeClient = await connect(url, null, clientMessages, [ + PREVIEW_SUBPROTOCOL, + ]); + nativeClient.send(JSON.stringify(connected)); + nativeClient.send(JSON.stringify(capability)); + await waitForMessages(studioMessages, 2); + assert.ok( + studioMessages.every((message) => message.previewProtocolVersion === "0.2") + ); + + studio.send(JSON.stringify(draft)); + await waitForMessages(clientMessages, 1); + assert.equal(clientMessages[0].type, "draftUpdated"); + assert.equal(clientMessages[0].payload.document.schemaVersion, "0.2"); + + studio.close(); + nativeClient.close(); + await relay.close(); +}); test("relays canonical messages only within the same local preview session", async () => { - const relay = createPreviewRelay({ port: 0 }) - await relay.ready - const url = relay.address() - assert.ok(url) + const relay = createPreviewRelay({ port: 0 }); + await relay.ready; + const url = relay.address(); + assert.ok(url); const fixture = JSON.parse( await readFile( new URL( "../../../protocol/fixtures/local-preview/v0.2/session-flow.messages.json", - import.meta.url, + import.meta.url ), - "utf8", - ), - ) - const connected = fixture.find((message) => message.type === "previewClientConnected") - const capability = fixture.find((message) => message.type === "capabilityReport") - assert.ok(connected) - assert.ok(capability) - const studioMessages = [] - const studio = await connect(url, connected.sessionId, studioMessages) - const nativeClient = await connect(url) - const otherStudio = await connect(url, "session_other_01") - const otherMessages = [] - otherStudio.on("message", (payload) => otherMessages.push(payload.toString())) - - nativeClient.send(JSON.stringify(connected)) - nativeClient.send(JSON.stringify(capability)) - await waitForMessages(studioMessages, 2) + "utf8" + ) + ); + const connected = fixture.find( + (message) => message.type === "previewClientConnected" + ); + const capability = fixture.find( + (message) => message.type === "capabilityReport" + ); + assert.ok(connected); + assert.ok(capability); + const studioMessages = []; + const studio = await connect(url, connected.sessionId, studioMessages); + const nativeClient = await connect(url); + const otherStudio = await connect(url, "session_other_01"); + const otherMessages = []; + otherStudio.on("message", (payload) => + otherMessages.push(payload.toString()) + ); + + nativeClient.send(JSON.stringify(connected)); + nativeClient.send(JSON.stringify(capability)); + await waitForMessages(studioMessages, 2); nativeClient.send( - JSON.stringify(heartbeat(connected.sessionId, 1, connected.payload.client.clientId)), - ) - await waitForMessages(studioMessages, 3) - assert.equal(studioMessages[2].sessionId, connected.sessionId) - await new Promise((resolve) => setTimeout(resolve, 20)) - assert.equal(otherMessages.length, 0) - - studio.close() - nativeClient.close() - otherStudio.close() - await relay.close() -}) + JSON.stringify( + heartbeat(connected.sessionId, 1, connected.payload.client.clientId) + ) + ); + await waitForMessages(studioMessages, 3); + assert.equal(studioMessages[2].sessionId, connected.sessionId); + await new Promise((resolve) => setTimeout(resolve, 20)); + assert.equal(otherMessages.length, 0); + + studio.close(); + nativeClient.close(); + otherStudio.close(); + await relay.close(); +}); test("replays cached identity then capabilities when Studio joins after a native client", async () => { - const relay = createPreviewRelay({ port: 0 }) - await relay.ready - const url = relay.address() - assert.ok(url) + const relay = createPreviewRelay({ port: 0 }); + await relay.ready; + const url = relay.address(); + assert.ok(url); const fixture = JSON.parse( await readFile( new URL( "../../../protocol/fixtures/local-preview/v0.2/session-flow.messages.json", - import.meta.url, + import.meta.url ), - "utf8", - ), - ) - const connected = fixture.find((message) => message.type === "previewClientConnected") - const capability = fixture.find((message) => message.type === "capabilityReport") - assert.ok(connected) - assert.ok(capability) - - const nativeClient = await connect(url) - nativeClient.send(JSON.stringify(connected)) - nativeClient.send(JSON.stringify(capability)) - await new Promise((resolve) => setTimeout(resolve, 20)) - - const received = [] - const studio = await connect(url, connected.sessionId, received) - await waitForMessages(received, 2) + "utf8" + ) + ); + const connected = fixture.find( + (message) => message.type === "previewClientConnected" + ); + const capability = fixture.find( + (message) => message.type === "capabilityReport" + ); + assert.ok(connected); + assert.ok(capability); + + const nativeClient = await connect(url); + nativeClient.send(JSON.stringify(connected)); + nativeClient.send(JSON.stringify(capability)); + await new Promise((resolve) => setTimeout(resolve, 20)); + + const received = []; + const studio = await connect(url, connected.sessionId, received); + await waitForMessages(received, 2); assert.deepEqual( received.map((message) => message.type), - ["previewClientConnected", "capabilityReport"], - ) + ["previewClientConnected", "capabilityReport"] + ); - studio.close() - nativeClient.close() - await relay.close() -}) + studio.close(); + nativeClient.close(); + await relay.close(); +}); test("notifies Studio when a connected native client closes", async () => { - const relay = createPreviewRelay({ port: 0 }) - await relay.ready - const url = relay.address() - assert.ok(url) + const relay = createPreviewRelay({ port: 0 }); + await relay.ready; + const url = relay.address(); + assert.ok(url); const fixture = JSON.parse( await readFile( new URL( "../../../protocol/fixtures/local-preview/v0.2/session-flow.messages.json", - import.meta.url, + import.meta.url ), - "utf8", - ), - ) - const connected = fixture.find((message) => message.type === "previewClientConnected") - assert.ok(connected) - - const studio = await connect(url, connected.sessionId) - const nativeClient = await connect(url) - nativeClient.send(JSON.stringify(connected)) - const [connectedPayload] = await once(studio, "message") - assert.equal(JSON.parse(connectedPayload.toString()).type, "previewClientConnected") - - nativeClient.close() - const [disconnectedPayload] = await once(studio, "message") - const disconnected = JSON.parse(disconnectedPayload.toString()) - assert.equal(disconnected.type, "previewClientDisconnected") - assert.equal(disconnected.payload.clientId, connected.payload.client.clientId) - assert.equal(disconnected.payload.reason, "closed") - - studio.close() - await relay.close() -}) + "utf8" + ) + ); + const connected = fixture.find( + (message) => message.type === "previewClientConnected" + ); + assert.ok(connected); + + const studio = await connect(url, connected.sessionId); + const nativeClient = await connect(url); + nativeClient.send(JSON.stringify(connected)); + const [connectedPayload] = await once(studio, "message"); + assert.equal( + JSON.parse(connectedPayload.toString()).type, + "previewClientConnected" + ); + + nativeClient.close(); + const [disconnectedPayload] = await once(studio, "message"); + const disconnected = JSON.parse(disconnectedPayload.toString()); + assert.equal(disconnected.type, "previewClientDisconnected"); + assert.equal( + disconnected.payload.clientId, + connected.payload.client.clientId + ); + assert.equal(disconnected.payload.reason, "closed"); + + studio.close(); + await relay.close(); +}); test("rejects messages with unknown fields and requires the exact subprotocol", async () => { - const relay = createPreviewRelay({ port: 0 }) - await relay.ready - const url = relay.address() - assert.ok(url) + const relay = createPreviewRelay({ port: 0 }); + await relay.ready; + const url = relay.address(); + assert.ok(url); - const invalid = await connect(url) - invalid.send(JSON.stringify({ ...heartbeat("session_local_01"), unexpected: true })) - const [code] = await once(invalid, "close") - assert.equal(code, 1008) + const invalid = await connect(url); + invalid.send( + JSON.stringify({ ...heartbeat("session_local_01"), unexpected: true }) + ); + const [code] = await once(invalid, "close"); + assert.equal(code, 1008); - const noProtocol = new WebSocket(url) - const [error] = await once(noProtocol, "error") - assert.ok(error) + const noProtocol = new WebSocket(url); + const [error] = await once(noProtocol, "error"); + assert.ok(error); - const retiredProtocol = new WebSocket(url, "mosaic.local-preview.v0.1") - const [retiredError] = await once(retiredProtocol, "error") - assert.ok(retiredError) + const retiredProtocol = new WebSocket(url, "mosaic.local-preview.v0.1"); + const [retiredError] = await once(retiredProtocol, "error"); + assert.ok(retiredError); - await relay.close() -}) + await relay.close(); +}); test("enforces client handshake order, message direction, and connected identity", async () => { - const relay = createPreviewRelay({ port: 0 }) - await relay.ready - const url = relay.address() - assert.ok(url) + const relay = createPreviewRelay({ port: 0 }); + await relay.ready; + const url = relay.address(); + assert.ok(url); const fixture = JSON.parse( await readFile( new URL( "../../../protocol/fixtures/local-preview/v0.2/session-flow.messages.json", - import.meta.url, + import.meta.url ), - "utf8", - ), - ) - const connected = fixture.find((message) => message.type === "previewClientConnected") - const capability = fixture.find((message) => message.type === "capabilityReport") - const accepted = fixture.find((message) => message.type === "draftAccepted") - const draft = fixture.find((message) => message.type === "draftUpdated") - assert.ok(connected) - assert.ok(capability) - assert.ok(accepted) - assert.ok(draft) - - const outOfOrderClient = await connect(url) - outOfOrderClient.send(JSON.stringify(capability)) - const [outOfOrderCode] = await once(outOfOrderClient, "close") - assert.equal(outOfOrderCode, 1008) - - const studio = await connect(url, connected.sessionId) - studio.send(JSON.stringify(accepted)) - const [studioCode] = await once(studio, "close") - assert.equal(studioCode, 1008) - - const mismatchedClient = await connect(url) - mismatchedClient.send(JSON.stringify(connected)) + "utf8" + ) + ); + const connected = fixture.find( + (message) => message.type === "previewClientConnected" + ); + const capability = fixture.find( + (message) => message.type === "capabilityReport" + ); + const accepted = fixture.find((message) => message.type === "draftAccepted"); + const draft = fixture.find((message) => message.type === "draftUpdated"); + assert.ok(connected); + assert.ok(capability); + assert.ok(accepted); + assert.ok(draft); + + const outOfOrderClient = await connect(url); + outOfOrderClient.send(JSON.stringify(capability)); + const [outOfOrderCode] = await once(outOfOrderClient, "close"); + assert.equal(outOfOrderCode, 1008); + + const studio = await connect(url, connected.sessionId); + studio.send(JSON.stringify(accepted)); + const [studioCode] = await once(studio, "close"); + assert.equal(studioCode, 1008); + + const mismatchedClient = await connect(url); + mismatchedClient.send(JSON.stringify(connected)); mismatchedClient.send( JSON.stringify({ ...capability, messageId: "msg_identity_mismatch", payload: { ...capability.payload, clientId: "client_different_identity" }, - }), - ) - const [mismatchCode] = await once(mismatchedClient, "close") - assert.equal(mismatchCode, 1008) + }) + ); + const [mismatchCode] = await once(mismatchedClient, "close"); + assert.equal(mismatchCode, 1008); - const wrongDirectionClient = await connect(url) - wrongDirectionClient.send(JSON.stringify(connected)) - wrongDirectionClient.send(JSON.stringify(capability)) - wrongDirectionClient.send(JSON.stringify(draft)) - const [directionCode] = await once(wrongDirectionClient, "close") - assert.equal(directionCode, 1008) + const wrongDirectionClient = await connect(url); + wrongDirectionClient.send(JSON.stringify(connected)); + wrongDirectionClient.send(JSON.stringify(capability)); + wrongDirectionClient.send(JSON.stringify(draft)); + const [directionCode] = await once(wrongDirectionClient, "close"); + assert.equal(directionCode, 1008); - await relay.close() -}) + await relay.close(); +}); test("keeps the replacement socket active when a stable client identity reconnects", async () => { - const relay = createPreviewRelay({ port: 0 }) - await relay.ready - const url = relay.address() - assert.ok(url) + const relay = createPreviewRelay({ port: 0 }); + await relay.ready; + const url = relay.address(); + assert.ok(url); const fixture = JSON.parse( await readFile( new URL( "../../../protocol/fixtures/local-preview/v0.2/session-flow.messages.json", - import.meta.url, + import.meta.url ), - "utf8", - ), - ) - const connected = fixture.find((message) => message.type === "previewClientConnected") - const capability = fixture.find((message) => message.type === "capabilityReport") - assert.ok(connected) - assert.ok(capability) - - const studioMessages = [] - const studio = await connect(url, connected.sessionId, studioMessages) - const original = await connect(url) - original.send(JSON.stringify(connected)) - original.send(JSON.stringify(capability)) - await waitForMessages(studioMessages, 2) - - const replacement = await connect(url) - const originalClosed = once(original, "close") - replacement.send(JSON.stringify({ ...connected, messageId: "msg_replacement_connected" })) - replacement.send(JSON.stringify({ ...capability, messageId: "msg_replacement_capability" })) - await waitForMessages(studioMessages, 4) - await originalClosed - await new Promise((resolve) => setTimeout(resolve, 20)) + "utf8" + ) + ); + const connected = fixture.find( + (message) => message.type === "previewClientConnected" + ); + const capability = fixture.find( + (message) => message.type === "capabilityReport" + ); + assert.ok(connected); + assert.ok(capability); + + const studioMessages = []; + const studio = await connect(url, connected.sessionId, studioMessages); + const original = await connect(url); + original.send(JSON.stringify(connected)); + original.send(JSON.stringify(capability)); + await waitForMessages(studioMessages, 2); + + const replacement = await connect(url); + const originalClosed = once(original, "close"); + replacement.send( + JSON.stringify({ ...connected, messageId: "msg_replacement_connected" }) + ); + replacement.send( + JSON.stringify({ ...capability, messageId: "msg_replacement_capability" }) + ); + await waitForMessages(studioMessages, 4); + await originalClosed; + await new Promise((resolve) => setTimeout(resolve, 20)); assert.equal( - studioMessages.filter((message) => message.type === "previewClientDisconnected").length, - 0, - ) + studioMessages.filter( + (message) => message.type === "previewClientDisconnected" + ).length, + 0 + ); replacement.send( - JSON.stringify(heartbeat(connected.sessionId, 9, connected.payload.client.clientId)), - ) - await waitForMessages(studioMessages, 5) - assert.equal(studioMessages[4].type, "previewHeartbeat") - - studio.close() - replacement.close() - await relay.close() -}) + JSON.stringify( + heartbeat(connected.sessionId, 9, connected.payload.client.clientId) + ) + ); + await waitForMessages(studioMessages, 5); + assert.equal(studioMessages[4].type, "previewHeartbeat"); + + studio.close(); + replacement.close(); + await relay.close(); +}); diff --git a/apps/dashboard/scripts/studio-browser-demo.mjs b/apps/dashboard/scripts/studio-browser-demo.mjs index 6717c5a5..1116223c 100644 --- a/apps/dashboard/scripts/studio-browser-demo.mjs +++ b/apps/dashboard/scripts/studio-browser-demo.mjs @@ -1,163 +1,195 @@ -import { mkdtemp, readFile, readdir, writeFile } from "node:fs/promises" -import { tmpdir } from "node:os" -import { join } from "node:path" - -import WebSocket from "ws" - -import { validatePaywallDocument } from "../../../protocol/browser/index.js" - -const debuggingEndpoint = process.env.MOSAIC_CHROME_DEBUG_URL ?? "http://127.0.0.1:9223" -const startedAt = Date.now() -const finalHeadline = "Edit once, preview natively everywhere" -const artifactsDirectory = await mkdtemp(join(tmpdir(), "mosaic-phase2c-browser-demo-")) -const validationScreenshot = join(artifactsDirectory, "studio-validation-error.png") -const finalScreenshot = join(artifactsDirectory, "studio-final.png") - -const targets = await fetch(`${debuggingEndpoint}/json/list`).then((response) => response.json()) -const target = targets.find((entry) => entry.type === "page" && entry.url.includes("/studio")) +import { mkdtemp, readdir, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import WebSocket from "ws"; + +import { validatePaywallDocument } from "../../../protocol/browser/index.js"; + +const debuggingEndpoint = + process.env.MOSAIC_CHROME_DEBUG_URL ?? "http://127.0.0.1:9223"; +const startedAt = Date.now(); +const finalHeadline = "Edit once, preview natively everywhere"; +const artifactsDirectory = await mkdtemp( + join(tmpdir(), "mosaic-phase2c-browser-demo-") +); +const validationScreenshot = join( + artifactsDirectory, + "studio-validation-error.png" +); +const finalScreenshot = join(artifactsDirectory, "studio-final.png"); + +const targets = await fetch(`${debuggingEndpoint}/json/list`).then((response) => + response.json() +); +const target = targets.find( + (entry) => entry.type === "page" && entry.url.includes("/studio") +); if (!target?.webSocketDebuggerUrl) { - throw new Error(`No /studio Chrome target is available at ${debuggingEndpoint}.`) + throw new Error( + `No /studio Chrome target is available at ${debuggingEndpoint}.` + ); } -const cdp = await connectCdp(target.webSocketDebuggerUrl) -await cdp.send("Page.enable") -await cdp.send("Runtime.enable") +const cdp = await connectCdp(target.webSocketDebuggerUrl); +await cdp.send("Page.enable"); +await cdp.send("Runtime.enable"); await cdp.send("Page.setDownloadBehavior", { behavior: "allow", downloadPath: artifactsDirectory, -}) +}); -await cdp.evaluate("localStorage.clear()") -await cdp.send("Page.reload", { ignoreCache: true }) +await cdp.evaluate("localStorage.clear()"); +await cdp.send("Page.reload", { ignoreCache: true }); await waitFor("template selection", async () => - (await bodyText()).includes("Start from intent, not JSON"), -) + (await bodyText()).includes("Start from intent, not JSON") +); await waitFor("Studio hydration", async () => cdp.evaluate(`(() => { const button = [...document.querySelectorAll("button")].find((candidate) => candidate.textContent?.includes("Focused offer") ) return Boolean(button && Object.keys(button).some((key) => key.startsWith("__reactProps"))) - })()`), -) + })()`) +); -await clickButtonContaining("Focused offer") -await waitFor("editor shell", async () => (await bodyText()).includes("Paywall order")) +await clickButtonContaining("Focused offer"); +await waitFor("editor shell", async () => + (await bodyText()).includes("Paywall order") +); -await clickCanvasComponent("headline") +await clickCanvasComponent("headline"); await waitFor("headline inspector", async () => - Boolean(await elementValue("#property-headline-value")), -) -await setElementValue("#property-headline-value", finalHeadline) -await moveSelectedLayerDown() + Boolean(await elementValue("#property-headline-value")) +); +await setElementValue("#property-headline-value", finalHeadline); +await moveSelectedLayerDown(); -await clickCanvasComponent("monthly-card") +await clickCanvasComponent("monthly-card"); await waitFor( "monthly Product Card inspector", - async () => (await elementChecked("#property-monthly-card-initialProductCardId")) !== null, -) -await clickElement("#property-monthly-card-initialProductCardId") + async () => + (await elementChecked("#property-monthly-card-initialProductCardId")) !== + null +); +await clickElement("#property-monthly-card-initialProductCardId"); await waitFor("monthly Product Card selection", async () => - Boolean(await elementChecked("#property-monthly-card-initialProductCardId")), -) -await clickCanvasComponent("yearly-card") + Boolean(await elementChecked("#property-monthly-card-initialProductCardId")) +); +await clickCanvasComponent("yearly-card"); await waitFor( "yearly Product Card inspector", - async () => (await elementChecked("#property-yearly-card-initialProductCardId")) === false, -) -await clickElement("#property-yearly-card-initialProductCardId") + async () => + (await elementChecked("#property-yearly-card-initialProductCardId")) === + false +); +await clickElement("#property-yearly-card-initialProductCardId"); await waitFor("yearly Product Card selection", async () => - Boolean(await elementChecked("#property-yearly-card-initialProductCardId")), -) -await setElementValue("#mock-outcome", "purchaseSuccess") + Boolean(await elementChecked("#property-yearly-card-initialProductCardId")) +); +await setElementValue("#mock-outcome", "purchaseSuccess"); -await clickButtonContaining("Layers") +await clickButtonContaining("Layers"); await waitFor("Layers panel", async () => - (await bodyText()).includes("Drag anywhere on a layer row to reorder."), -) -await clickButtonByLabel("Add screen or sheet") -await clickRoleContaining("menuitem", "Add sheet") -await waitFor("Sheet destination", async () => (await bodyText()).includes("Sheet · screen-2")) - -await clickCanvasComponent("headline") + (await bodyText()).includes("Drag anywhere on a layer row to reorder.") +); +await clickButtonByLabel("Add screen or sheet"); +await clickRoleContaining("menuitem", "Add sheet"); +await waitFor("Sheet destination", async () => + (await bodyText()).includes("Sheet · screen-2") +); + +await clickCanvasComponent("headline"); await waitFor( "headline re-selection", - async () => (await elementValue("#property-headline-value")) !== null, -) -await setElementValue("#property-headline-value", "") + async () => (await elementValue("#property-headline-value")) !== null +); +await setElementValue("#property-headline-value", ""); await waitFor("local validation error", async () => - (await bodyText()).includes("Visible text cannot be empty."), -) -await scrollTo("#validation-title") -await captureScreenshot(validationScreenshot) + (await bodyText()).includes("Visible text cannot be empty.") +); +await scrollTo("#validation-title"); +await captureScreenshot(validationScreenshot); -await setElementValue("#property-headline-value", finalHeadline) +await setElementValue("#property-headline-value", finalHeadline); await waitFor("validation recovery", async () => (await bodyText()).includes( - "This paywall is valid and ready to send to native previews or export.", - ), -) + "This paywall is valid and ready to send to native previews or export." + ) +); await waitFor( "three native acknowledgements", async () => (await exactTextCount("Updated")) >= 3, - 25_000, -) + 25_000 +); -const finalBody = await bodyText() +const finalBody = await bodyText(); for (const clientLabel of [ "Flutter example preview", "Mosaic iOS local preview", "Android example preview", ]) { if (!finalBody.includes(clientLabel)) { - throw new Error(`Studio did not display the connected client: ${clientLabel}.`) + throw new Error( + `Studio did not display the connected client: ${clientLabel}.` + ); } } -await clickButtonContaining("Export") -const exportedFile = await waitForExport() -const exported = JSON.parse(await readFile(exportedFile, "utf8")) -const validation = validatePaywallDocument(exported) -if (!validation.ok) throw new Error("The browser-exported document is not Protocol 0.2 valid.") +await clickButtonContaining("Export"); +const exportedFile = await waitForExport(); +const exported = JSON.parse(await readFile(exportedFile, "utf8")); +const validation = validatePaywallDocument(exported); +if (!validation.ok) { + throw new Error("The browser-exported document is not Protocol 0.2 valid."); +} -const initialScreen = exported.screens.find((screen) => screen.id === exported.initialScreenId) -if (!initialScreen || initialScreen.presentation?.type !== "screen") { - throw new Error("The browser export does not preserve its initial Screen.") +const initialScreen = exported.screens.find( + (screen) => screen.id === exported.initialScreenId +); +if (initialScreen?.presentation?.type !== "screen") { + throw new Error("The browser export does not preserve its initial Screen."); } -const children = initialScreen.layout.content.children -const headlineIndex = children.findIndex((node) => node.id === "headline") -const subtitleIndex = children.findIndex((node) => node.id === "subtitle") -const headlineNode = children.find((node) => node.id === "headline") -const productSelector = children.find((node) => node.id === "plans") +const { children } = initialScreen.layout.content; +const headlineIndex = children.findIndex((node) => node.id === "headline"); +const subtitleIndex = children.findIndex((node) => node.id === "subtitle"); +const headlineNode = children.find((node) => node.id === "headline"); +const productSelector = children.find((node) => node.id === "plans"); const selectedProductCard = productSelector?.cards?.find( - (card) => card.id === productSelector.initialProductCardId, -) -const sheet = exported.screens.find((screen) => screen.id === "screen-2") + (card) => card.id === productSelector.initialProductCardId +); +const sheet = exported.screens.find((screen) => screen.id === "screen-2"); const sheetNavigation = children.find( (node) => node.type === "button" && node.action?.type === "navigateTo" && - node.action.screenId === sheet?.id, -) + node.action.screenId === sheet?.id +); if (headlineNode?.value?.default !== finalHeadline) { - throw new Error("The exported headline does not match the browser edit.") + throw new Error("The exported headline does not match the browser edit."); } if (subtitleIndex < 0 || headlineIndex !== subtitleIndex + 1) { - throw new Error("The exported document does not preserve the browser reorder.") + throw new Error( + "The exported document does not preserve the browser reorder." + ); } if ( productSelector?.type !== "productSelector" || productSelector.initialProductCardId !== "yearly-card" || selectedProductCard?.productReferenceId !== "yearly-plan" ) { - throw new Error("The exported Product Selector does not select the authored yearly Product Card.") + throw new Error( + "The exported Product Selector does not select the authored yearly Product Card." + ); } if (sheet?.presentation?.type !== "sheet" || !sheetNavigation) { - throw new Error("The exported document does not preserve the new Sheet and its navigation edge.") + throw new Error( + "The exported document does not preserve the new Sheet and its navigation edge." + ); } -await scrollTo("#native-preview-title") -await captureScreenshot(finalScreenshot) +await scrollTo("#native-preview-title"); +await captureScreenshot(finalScreenshot); const result = { template: "Focused offer", @@ -182,32 +214,32 @@ const result = { validationError: validationScreenshot, finalStudio: finalScreenshot, }, - elapsedSeconds: Number(((Date.now() - startedAt) / 1_000).toFixed(2)), -} + elapsedSeconds: Number(((Date.now() - startedAt) / 1000).toFixed(2)), +}; -console.log(JSON.stringify(result, null, 2)) -cdp.close() +console.log(JSON.stringify(result, null, 2)); +cdp.close(); -async function bodyText() { - return cdp.evaluate("document.body?.innerText ?? ''") +function bodyText() { + return cdp.evaluate("document.body?.innerText ?? ''"); } -async function elementValue(selector) { +function elementValue(selector) { return cdp.evaluate( - `(() => { const element = document.querySelector(${JSON.stringify(selector)}); return element ? element.value : null })()`, - ) + `(() => { const element = document.querySelector(${JSON.stringify(selector)}); return element ? element.value : null })()` + ); } -async function elementChecked(selector) { +function elementChecked(selector) { return cdp.evaluate( - `(() => { const element = document.querySelector(${JSON.stringify(selector)}); return element instanceof HTMLInputElement ? element.checked : null })()`, - ) + `(() => { const element = document.querySelector(${JSON.stringify(selector)}); return element instanceof HTMLInputElement ? element.checked : null })()` + ); } -async function exactTextCount(text) { +function exactTextCount(text) { return cdp.evaluate( - `([...document.querySelectorAll("body *")].filter((element) => element.children.length === 0 && element.textContent?.trim() === ${JSON.stringify(text)})).length`, - ) + `([...document.querySelectorAll("body *")].filter((element) => element.children.length === 0 && element.textContent?.trim() === ${JSON.stringify(text)})).length` + ); } async function clickButtonContaining(text) { @@ -218,8 +250,10 @@ async function clickButtonContaining(text) { if (!button) return false button.click() return true - })()`) - if (!clicked) throw new Error(`Could not find a button containing: ${text}.`) + })()`); + if (!clicked) { + throw new Error(`Could not find a button containing: ${text}.`); + } } async function clickButtonByLabel(label) { @@ -230,12 +264,14 @@ async function clickButtonByLabel(label) { if (!button) return false button.click() return true - })()`) - if (!clicked) throw new Error(`Could not find a button labelled: ${label}.`) + })()`); + if (!clicked) { + throw new Error(`Could not find a button labelled: ${label}.`); + } } async function clickRoleContaining(role, text) { - const selector = `[role="${role}"]` + const selector = `[role="${role}"]`; const clicked = await cdp.evaluate(`(() => { const element = [...document.querySelectorAll(${JSON.stringify(selector)})].find((candidate) => candidate.textContent?.includes(${JSON.stringify(text)}) @@ -243,8 +279,10 @@ async function clickRoleContaining(role, text) { if (!element) return false element.click() return true - })()`) - if (!clicked) throw new Error(`Could not find ${role} containing: ${text}.`) + })()`); + if (!clicked) { + throw new Error(`Could not find ${role} containing: ${text}.`); + } } async function clickElement(selector) { @@ -253,19 +291,23 @@ async function clickElement(selector) { if (!(element instanceof HTMLElement)) return false element.click() return true - })()`) - if (!clicked) throw new Error(`Could not click element: ${selector}.`) + })()`); + if (!clicked) { + throw new Error(`Could not click element: ${selector}.`); + } } async function clickCanvasComponent(componentId) { - const selector = `[data-component-id="${componentId}"][data-preview-node-type]` + const selector = `[data-component-id="${componentId}"][data-preview-node-type]`; const clicked = await cdp.evaluate(`(() => { const component = document.querySelector(${JSON.stringify(selector)}) if (!component) return false component.click() return true - })()`) - if (!clicked) throw new Error(`Could not select canvas component: ${componentId}.`) + })()`); + if (!clicked) { + throw new Error(`Could not select canvas component: ${componentId}.`); + } } async function moveSelectedLayerDown() { @@ -279,8 +321,10 @@ async function moveSelectedLayerDown() { key: "ArrowDown", })) return true - })()`) - if (!moved) throw new Error("Could not move the selected layer down.") + })()`); + if (!moved) { + throw new Error("Could not move the selected layer down."); + } } async function setElementValue(selector, value) { @@ -299,16 +343,18 @@ async function setElementValue(selector, value) { element.dispatchEvent(new Event("change", { bubbles: true })) element.blur() return true - })()`) - if (!changed) throw new Error(`Could not set element value: ${selector}.`) - await new Promise((resolve) => setTimeout(resolve, 100)) + })()`); + if (!changed) { + throw new Error(`Could not set element value: ${selector}.`); + } + await new Promise((resolve) => setTimeout(resolve, 100)); } async function scrollTo(selector) { await cdp.evaluate( - `document.querySelector(${JSON.stringify(selector)})?.scrollIntoView({ block: "start" })`, - ) - await new Promise((resolve) => setTimeout(resolve, 150)) + `document.querySelector(${JSON.stringify(selector)})?.scrollIntoView({ block: "start" })` + ); + await new Promise((resolve) => setTimeout(resolve, 150)); } async function captureScreenshot(path) { @@ -316,59 +362,73 @@ async function captureScreenshot(path) { format: "png", fromSurface: true, captureBeyondViewport: false, - }) - await writeFile(path, screenshot.data, "base64") + }); + await writeFile(path, screenshot.data, "base64"); } async function waitForExport() { - const exportDeadline = Date.now() + 10_000 + const exportDeadline = Date.now() + 10_000; while (Date.now() < exportDeadline) { + // biome-ignore lint/performance/noAwaitInLoops: polls the page until the condition holds, which is inherently sequential const files = (await readdir(artifactsDirectory)).filter((file) => - file.endsWith(".mosaic.json"), - ) - if (files.length === 1) return join(artifactsDirectory, files[0]) - await new Promise((resolve) => setTimeout(resolve, 50)) + file.endsWith(".mosaic.json") + ); + if (files.length === 1) { + return join(artifactsDirectory, files[0]); + } + await new Promise((resolve) => setTimeout(resolve, 50)); } - throw new Error("Studio did not download one exported Mosaic document.") + throw new Error("Studio did not download one exported Mosaic document."); } async function waitFor(label, predicate, timeout = 15_000) { - const waitDeadline = Date.now() + timeout + const waitDeadline = Date.now() + timeout; while (Date.now() < waitDeadline) { try { - if (await predicate()) return + // biome-ignore lint/performance/noAwaitInLoops: polls the page until the condition holds, which is inherently sequential + if (await predicate()) { + return; + } } catch { // Navigation can briefly invalidate the execution context. } - await new Promise((resolve) => setTimeout(resolve, 50)) + await new Promise((resolve) => setTimeout(resolve, 50)); } - throw new Error(`Timed out waiting for ${label}.`) + throw new Error(`Timed out waiting for ${label}.`); } async function connectCdp(url) { - const socket = new WebSocket(url) + const socket = new WebSocket(url); await new Promise((resolve, reject) => { - socket.once("open", resolve) - socket.once("error", reject) - }) - let sequence = 0 - const pending = new Map() + socket.once("open", resolve); + socket.once("error", reject); + }); + let sequence = 0; + const pending = new Map(); socket.on("message", (source) => { - const message = JSON.parse(source.toString()) - if (!message.id) return - const request = pending.get(message.id) - if (!request) return - pending.delete(message.id) - if (message.error) request.reject(new Error(message.error.message)) - else request.resolve(message.result) - }) + const message = JSON.parse(source.toString()); + if (!message.id) { + return; + } + const request = pending.get(message.id); + if (!request) { + return; + } + pending.delete(message.id); + if (message.error) { + request.reject(new Error(message.error.message)); + } else { + request.resolve(message.result); + } + }); function send(method, params = {}) { - const id = ++sequence + sequence += 1; + const id = sequence; return new Promise((resolve, reject) => { - pending.set(id, { resolve, reject }) - socket.send(JSON.stringify({ id, method, params })) - }) + pending.set(id, { resolve, reject }); + socket.send(JSON.stringify({ id, method, params })); + }); } return { @@ -378,14 +438,16 @@ async function connectCdp(url) { expression, awaitPromise: true, returnByValue: true, - }) + }); if (response.exceptionDetails) { - throw new Error(response.exceptionDetails.text ?? "Chrome evaluation failed.") + throw new Error( + response.exceptionDetails.text ?? "Chrome evaluation failed." + ); } - return response.result.value + return response.result.value; }, close() { - socket.close() + socket.close(); }, - } + }; } diff --git a/apps/dashboard/src/build-info.d.ts b/apps/dashboard/src/build-info.d.ts index 2ebc5de2..8a0d2826 100644 --- a/apps/dashboard/src/build-info.d.ts +++ b/apps/dashboard/src/build-info.d.ts @@ -3,6 +3,6 @@ * running bundle self-identifying, which is what a deployment diagnosis needs * when several dashboard versions may be in circulation. */ -declare const __MOSAIC_VERSION__: string -declare const __MOSAIC_COMMIT__: string -declare const __MOSAIC_BUILD_TIME__: string +declare const __MOSAIC_VERSION__: string; +declare const __MOSAIC_COMMIT__: string; +declare const __MOSAIC_BUILD_TIME__: string; diff --git a/apps/dashboard/src/components/feedback/app-error-boundary.tsx b/apps/dashboard/src/components/feedback/app-error-boundary.tsx index 8e2687d6..343885ea 100644 --- a/apps/dashboard/src/components/feedback/app-error-boundary.tsx +++ b/apps/dashboard/src/components/feedback/app-error-boundary.tsx @@ -1,10 +1,11 @@ -import * as React from "react" +import type { ReactNode } from "react"; +import { Component } from "react"; -import { ErrorState } from "@/components/feedback/error-state" -import { describeApiError } from "@/lib/api/errors" +import { ErrorState } from "@/components/feedback/error-state"; +import { describeApiError } from "@/lib/api/errors"; interface AppErrorBoundaryState { - error: unknown + error: unknown; } /** @@ -16,40 +17,43 @@ interface AppErrorBoundaryState { * anywhere: the operator gets recovery actions and, when available, the * correlation identifier to quote to whoever runs the API. */ -export class AppErrorBoundary extends React.Component< - { children: React.ReactNode }, +// biome-ignore lint/style/useReactFunctionComponents: React implements error boundaries only via componentDidCatch on a class +export class AppErrorBoundary extends Component< + { children: ReactNode }, AppErrorBoundaryState > { - state: AppErrorBoundaryState = { error: null } + state: AppErrorBoundaryState = { error: null }; static getDerivedStateFromError(error: unknown): AppErrorBoundaryState { - return { error } + return { error }; } render() { - if (!this.state.error) return this.props.children + if (!this.state.error) { + return this.props.children; + } - const described = describeApiError(this.state.error) + const described = describeApiError(this.state.error); return (
+
Request ID:{" "}
-
+
{described.correlationId}
{title}
-{description}
+{title}
++ {description} +
{action ?{title}
-{description}
+{title}
++ {description} +
{onRetry ? ( -{eyebrow}
-+ {eyebrow} +
+{description}
+{description}
+
{secret}
- void copySecret()} variant="outline">
+
- {copied ? "Copied" : copyLabel}
+ {copyButtonLabel}
-
- {copyFailed ? "Copying failed. Select the value above to copy it manually." : ""}
+
+ {copyFailed
+ ? "Copying failed. Select the value above to copy it manually."
+ : ""}
+
Request ID:{" "}
-
+
{described.correlationId}