From 74400fda702a71cfdce818ae8f31d2aa501d5d92 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 10 May 2026 04:57:09 +0000 Subject: [PATCH] feat: migrate web to Gitea registry, add /api/devops/info, fix role drift Backend: - Fix role drift: /api/me/profile now returns JWT role authoritatively (was reading drifting role from trading_users). PATCH strips client-supplied role. - Add /api/devops/info endpoint backed by @bytelyst/devops/server. - Dockerfile: bake BYTELYST_COMMIT_SHA / BYTELYST_BUILT_AT / etc. as build args. Web: - Migrate from vendor/ + .pnpmfile.cjs to Gitea npm registry (consistency with backend). - Replace file: refs in web/package.json with semver ranges resolved from Gitea. - Drop vendor/bytelyst/* tree and .pnpmfile.cjs. - Add DevOpsTab in Settings using @bytelyst/devops/ui (tabbed: Build/Runtime/Config/Deps/Raw). - Vite alias: restrict @bytelyst/* catch-all to single-segment names so subpath imports (@bytelyst/devops/ui) resolve via package exports map. - Bake BYTELYST_* metadata into the bundle as VITE_BYTELYST_* env. Generated with [Devin](https://cli.devin.ai/docs) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .dockerignore | 8 + .npmrc.docker | 2 +- backend/Dockerfile | 37 +- backend/package.json | 4 +- backend/src/services/apiServer.ts | 28 +- vendor/bytelyst/accessibility/package.json | 20 - vendor/bytelyst/accessibility/src/index.ts | 204 ----- vendor/bytelyst/accessibility/tsconfig.json | 18 - vendor/bytelyst/api-client/package.json | 23 - .../src/__tests__/api-client.test.ts | 133 --- vendor/bytelyst/api-client/src/client.ts | 151 ---- vendor/bytelyst/api-client/src/index.ts | 2 - vendor/bytelyst/api-client/src/types.ts | 28 - vendor/bytelyst/api-client/tsconfig.json | 10 - vendor/bytelyst/auth-client/package.json | 24 - .../src/__tests__/auth-client.test.ts | 346 -------- .../src/__tests__/smartauth.test.ts | 571 ------------- vendor/bytelyst/auth-client/src/client.ts | 527 ------------ vendor/bytelyst/auth-client/src/index.ts | 16 - vendor/bytelyst/auth-client/src/types.ts | 190 ----- vendor/bytelyst/auth-client/tsconfig.json | 10 - vendor/bytelyst/auth-ui/package.json | 36 - .../bytelyst/auth-ui/src/AuthPageLayout.tsx | 101 --- .../auth-ui/src/ForgotPasswordForm.tsx | 111 --- vendor/bytelyst/auth-ui/src/LoginForm.tsx | 116 --- vendor/bytelyst/auth-ui/src/MfaChallenge.tsx | 114 --- .../bytelyst/auth-ui/src/OnboardingShell.tsx | 148 ---- .../auth-ui/src/PasswordStrengthBar.tsx | 67 -- vendor/bytelyst/auth-ui/src/RegisterForm.tsx | 226 ----- .../auth-ui/src/ResetPasswordForm.tsx | 131 --- vendor/bytelyst/auth-ui/src/SocialButtons.tsx | 48 -- .../bytelyst/auth-ui/src/VerifyEmailForm.tsx | 117 --- .../auth-ui/src/__tests__/auth-ui.test.tsx | 155 ---- .../src/__tests__/new-components.test.tsx | 402 --------- vendor/bytelyst/auth-ui/src/index.ts | 24 - vendor/bytelyst/auth-ui/src/types.ts | 147 ---- vendor/bytelyst/auth-ui/tsconfig.json | 11 - vendor/bytelyst/auth-ui/vitest.config.ts | 8 - vendor/bytelyst/auth/package.json | 30 - .../bytelyst/auth/src/__tests__/auth.test.ts | 137 ---- .../auth/src/__tests__/e2e-auth-flow.test.ts | 101 --- .../auth/src/__tests__/middleware.test.ts | 117 --- .../bytelyst/auth/src/__tests__/rs256.test.ts | 133 --- vendor/bytelyst/auth/src/index.ts | 5 - vendor/bytelyst/auth/src/jwt.ts | 176 ---- vendor/bytelyst/auth/src/middleware.ts | 54 -- vendor/bytelyst/auth/src/password.ts | 15 - vendor/bytelyst/auth/src/server-auth.ts | 26 - vendor/bytelyst/auth/src/types.ts | 41 - vendor/bytelyst/auth/tsconfig.json | 9 - vendor/bytelyst/auth/vitest.config.ts | 8 - vendor/bytelyst/backend-config/package.json | 33 - .../bytelyst/backend-config/src/index.test.ts | 82 -- vendor/bytelyst/backend-config/src/index.ts | 39 - vendor/bytelyst/backend-config/tsconfig.json | 9 - vendor/bytelyst/backend-flags/package.json | 30 - .../bytelyst/backend-flags/src/index.test.ts | 43 - vendor/bytelyst/backend-flags/src/index.ts | 38 - vendor/bytelyst/backend-flags/tsconfig.json | 9 - .../bytelyst/backend-telemetry/package.json | 30 - .../backend-telemetry/src/index.test.ts | 46 -- .../bytelyst/backend-telemetry/src/index.ts | 50 -- .../bytelyst/backend-telemetry/tsconfig.json | 9 - vendor/bytelyst/billing-client/.gitignore | 1 - vendor/bytelyst/billing-client/package.json | 28 - .../bytelyst/billing-client/src/index.test.ts | 119 --- vendor/bytelyst/billing-client/src/index.ts | 214 ----- vendor/bytelyst/billing-client/tsconfig.json | 8 - vendor/bytelyst/blob-client/package.json | 24 - vendor/bytelyst/blob-client/src/index.test.ts | 276 ------- vendor/bytelyst/blob-client/src/index.ts | 289 ------- vendor/bytelyst/blob-client/tsconfig.json | 10 - vendor/bytelyst/blob/package.json | 27 - .../bytelyst/blob/src/__tests__/blob.test.ts | 108 --- vendor/bytelyst/blob/src/blob.ts | 94 --- vendor/bytelyst/blob/src/index.ts | 1 - vendor/bytelyst/blob/tsconfig.json | 9 - vendor/bytelyst/broadcast-client/README.md | 227 ----- vendor/bytelyst/broadcast-client/package.json | 24 - .../broadcast-client/src/deep-link.ts | 165 ---- .../broadcast-client/src/index.test.ts | 219 ----- vendor/bytelyst/broadcast-client/src/index.ts | 185 ----- .../bytelyst/broadcast-client/tsconfig.json | 10 - vendor/bytelyst/celebrations/package.json | 19 - vendor/bytelyst/celebrations/src/index.ts | 25 - vendor/bytelyst/celebrations/tsconfig.json | 9 - vendor/bytelyst/client-encrypt/package.json | 26 - .../client-encrypt/src/aes-gcm.test.ts | 181 ---- vendor/bytelyst/client-encrypt/src/aes-gcm.ts | 215 ----- vendor/bytelyst/client-encrypt/src/guards.ts | 22 - vendor/bytelyst/client-encrypt/src/hex.ts | 28 - vendor/bytelyst/client-encrypt/src/index.ts | 36 - vendor/bytelyst/client-encrypt/src/types.ts | 33 - vendor/bytelyst/client-encrypt/tsconfig.json | 10 - vendor/bytelyst/config/package.json | 48 -- .../config/src/__tests__/config.test.ts | 167 ---- .../config/src/__tests__/keyvault.test.ts | 180 ---- .../src/__tests__/product-manifest.test.ts | 467 ----------- vendor/bytelyst/config/src/base-schema.ts | 19 - vendor/bytelyst/config/src/index.ts | 38 - vendor/bytelyst/config/src/keyvault.ts | 136 --- vendor/bytelyst/config/src/loader.ts | 26 - .../bytelyst/config/src/product-identity.ts | 75 -- .../bytelyst/config/src/product-manifest.ts | 305 ------- vendor/bytelyst/config/tsconfig.json | 9 - vendor/bytelyst/cosmos/package.json | 26 - .../cosmos/src/__tests__/cosmos.test.ts | 152 ---- vendor/bytelyst/cosmos/src/client.ts | 54 -- vendor/bytelyst/cosmos/src/containers.ts | 125 --- vendor/bytelyst/cosmos/src/index.ts | 8 - vendor/bytelyst/cosmos/src/types.ts | 4 - vendor/bytelyst/cosmos/tsconfig.json | 9 - vendor/bytelyst/create-app/package.json | 26 - .../src/__tests__/scaffolder.test.ts | 136 --- .../src/__tests__/template-engine.test.ts | 77 -- .../create-app/src/generators/agents-md.ts | 605 -------------- .../create-app/src/generators/api-routes.ts | 770 ----------------- vendor/bytelyst/create-app/src/index.ts | 9 - .../create-app/src/lib/template-engine.ts | 34 - .../bytelyst/create-app/src/lib/templates.ts | 457 ----------- vendor/bytelyst/create-app/src/scaffolder.ts | 315 ------- vendor/bytelyst/create-app/tsconfig.json | 9 - vendor/bytelyst/create-app/vitest.config.ts | 8 - .../dashboard-components/package.json | 39 - .../dashboard-components/src/EmptyState.tsx | 53 -- .../dashboard-components/src/ErrorPage.tsx | 60 -- .../src/LoadingSkeleton.tsx | 20 - .../src/LoadingSpinner.tsx | 40 - .../dashboard-components/src/NotFoundPage.tsx | 61 -- .../dashboard-components/src/PageHeader.tsx | 55 -- .../src/components.test.tsx | 254 ------ .../dashboard-components/src/index.ts | 15 - .../dashboard-components/tsconfig.json | 13 - .../dashboard-components/vitest.config.ts | 10 - vendor/bytelyst/dashboard-shell/package.json | 39 - .../dashboard-shell/src/BillingPage.tsx | 189 ----- .../dashboard-shell/src/DashboardShell.tsx | 73 -- .../dashboard-shell/src/ProfilePage.tsx | 180 ---- .../dashboard-shell/src/SettingsPage.tsx | 71 -- .../bytelyst/dashboard-shell/src/Sidebar.tsx | 236 ------ .../bytelyst/dashboard-shell/src/TopBar.tsx | 244 ------ .../src/__tests__/dashboard-shell.test.tsx | 377 --------- vendor/bytelyst/dashboard-shell/src/index.ts | 30 - vendor/bytelyst/dashboard-shell/src/types.ts | 131 --- vendor/bytelyst/dashboard-shell/tsconfig.json | 11 - .../bytelyst/dashboard-shell/vitest.config.ts | 8 - vendor/bytelyst/datastore/package.json | 38 - .../datastore/src/__tests__/memory.test.ts | 285 ------- vendor/bytelyst/datastore/src/factory.ts | 59 -- vendor/bytelyst/datastore/src/filter.ts | 178 ---- vendor/bytelyst/datastore/src/index.ts | 18 - .../datastore/src/providers/cosmos.ts | 243 ------ .../datastore/src/providers/memory.ts | 202 ----- vendor/bytelyst/datastore/src/testing.ts | 53 -- vendor/bytelyst/datastore/src/types.ts | 112 --- vendor/bytelyst/datastore/tsconfig.json | 9 - vendor/bytelyst/design-tokens/README.md | 276 ------- .../bytelyst-design-tokens-0.1.0.tgz | Bin 29482 -> 0 bytes .../generated/MindLystTheme.swift | 88 -- .../design-tokens/generated/MindLystTokens.kt | 137 ---- .../design-tokens/generated/actiontrail.css | 97 --- .../design-tokens/generated/chronomind.css | 89 -- .../design-tokens/generated/flowmonk.css | 99 --- .../design-tokens/generated/jarvisjr.css | 88 -- .../design-tokens/generated/localllmlab.css | 94 --- .../design-tokens/generated/localmemgpt.css | 93 --- .../design-tokens/generated/lysnrai.css | 86 -- .../design-tokens/generated/mindlyst.css | 89 -- .../native/ActionTrailTheme.generated.swift | 103 --- .../native/ActionTrailTokens.generated.kt | 102 --- .../native/ChronoMindTheme.generated.swift | 95 --- .../native/ChronoMindTokens.generated.kt | 94 --- .../native/FlowMonkTheme.generated.swift | 105 --- .../native/FlowMonkTokens.generated.kt | 104 --- .../native/JarvisJrTheme.generated.swift | 94 --- .../native/JarvisJrTokens.generated.kt | 93 --- .../native/LocalLLMLabTheme.generated.swift | 100 --- .../native/LocalLLMLabTokens.generated.kt | 99 --- .../native/LocalMemGPTTheme.generated.swift | 99 --- .../native/LocalMemGPTTokens.generated.kt | 98 --- .../native/LysnrAITheme.generated.swift | 92 --- .../native/LysnrAITokens.generated.kt | 91 -- .../native/NomGapTheme.generated.swift | 95 --- .../native/NomGapTokens.generated.kt | 94 --- .../native/NoteLettTheme.generated.swift | 100 --- .../native/NoteLettTokens.generated.kt | 99 --- .../native/PeakPulseTheme.generated.swift | 95 --- .../native/PeakPulseTokens.generated.kt | 94 --- .../design-tokens/generated/nomgap.css | 89 -- .../design-tokens/generated/notelett.css | 94 --- .../design-tokens/generated/peakpulse.css | 89 -- .../generated/react-native/tokens.ts | 139 ---- .../design-tokens/generated/tokens.css | 78 -- .../design-tokens/generated/tokens.ts | 386 --------- vendor/bytelyst/design-tokens/package.json | 44 - .../scripts/generate-react-native.ts | 143 ---- .../design-tokens/scripts/generate.ts | 750 ----------------- .../design-tokens/scripts/token-coverage.cjs | 184 ----- .../design-tokens/scripts/validate-tokens.cjs | 113 --- .../src/__tests__/tokens.test.ts | 91 -- vendor/bytelyst/design-tokens/src/index.ts | 49 -- .../design-tokens/tokens/bytelyst.tokens.json | 369 --------- vendor/bytelyst/design-tokens/tsconfig.json | 9 - .../bytelyst/diagnostics-client/package.json | 35 - .../src/__tests__/client.test.ts | 227 ----- .../diagnostics-client/src/breadcrumbs.ts | 78 -- .../bytelyst/diagnostics-client/src/client.ts | 573 ------------- .../bytelyst/diagnostics-client/src/device.ts | 86 -- .../bytelyst/diagnostics-client/src/index.ts | 61 -- .../diagnostics-client/src/network.ts | 214 ----- .../bytelyst/diagnostics-client/src/types.ts | 233 ------ vendor/bytelyst/diagnostics-client/src/web.ts | 111 --- .../bytelyst/diagnostics-client/tsconfig.json | 10 - vendor/bytelyst/errors/package.json | 23 - .../errors/src/__tests__/errors.test.ts | 56 -- vendor/bytelyst/errors/src/http-errors.ts | 37 - vendor/bytelyst/errors/src/index.ts | 9 - vendor/bytelyst/errors/src/service-error.ts | 14 - vendor/bytelyst/errors/tsconfig.json | 9 - vendor/bytelyst/event-store/package.json | 24 - .../event-store/src/file-store.test.ts | 113 --- vendor/bytelyst/event-store/src/file-store.ts | 68 -- vendor/bytelyst/event-store/src/index.ts | 5 - .../event-store/src/memory-store.test.ts | 88 -- .../bytelyst/event-store/src/memory-store.ts | 54 -- vendor/bytelyst/event-store/src/types.ts | 29 - vendor/bytelyst/event-store/tsconfig.json | 9 - .../phase1/artifact-created.event.json | 27 - .../phase1/artifact-linked.event.json | 27 - .../capture-transcript-created.event.json | 28 - .../ecosystem/phase1/memory-artifact.json | 69 -- .../phase1/memory-entry-created.event.json | 29 - .../ecosystem/phase1/note-artifact.json | 57 -- .../ecosystem/phase1/transcript-artifact.json | 56 -- vendor/bytelyst/events/package.json | 33 - .../bytelyst/events/src/agent-runtime.test.ts | 170 ---- vendor/bytelyst/events/src/agent-runtime.ts | 162 ---- vendor/bytelyst/events/src/durable.test.ts | 91 -- vendor/bytelyst/events/src/durable.ts | 152 ---- vendor/bytelyst/events/src/ecosystem.test.ts | 401 --------- vendor/bytelyst/events/src/ecosystem.ts | 349 -------- vendor/bytelyst/events/src/index.ts | 80 -- vendor/bytelyst/events/src/memory.test.ts | 250 ------ vendor/bytelyst/events/src/memory.ts | 122 --- vendor/bytelyst/events/src/timeline.test.ts | 95 --- vendor/bytelyst/events/src/timeline.ts | 124 --- vendor/bytelyst/events/src/types.ts | 322 -------- vendor/bytelyst/events/tsconfig.json | 9 - vendor/bytelyst/extraction/package.json | 27 - .../src/__tests__/extraction.test.ts | 323 -------- vendor/bytelyst/extraction/src/client.ts | 90 -- vendor/bytelyst/extraction/src/index.ts | 14 - vendor/bytelyst/extraction/src/types.ts | 110 --- vendor/bytelyst/extraction/tsconfig.json | 10 - vendor/bytelyst/fastify-auth/package.json | 39 - vendor/bytelyst/fastify-auth/src/auth.ts | 88 -- .../bytelyst/fastify-auth/src/index.test.ts | 145 ---- vendor/bytelyst/fastify-auth/src/index.ts | 8 - .../fastify-auth/src/request-context.ts | 47 -- vendor/bytelyst/fastify-auth/src/types.ts | 44 - vendor/bytelyst/fastify-auth/tsconfig.json | 9 - vendor/bytelyst/fastify-core/package.json | 45 - .../src/__tests__/fastify-core.test.ts | 374 --------- vendor/bytelyst/fastify-core/src/auth.ts | 32 - .../bytelyst/fastify-core/src/create-app.ts | 171 ---- vendor/bytelyst/fastify-core/src/index.ts | 4 - vendor/bytelyst/fastify-core/src/start.ts | 45 - vendor/bytelyst/fastify-core/src/types.ts | 42 - vendor/bytelyst/fastify-core/tsconfig.json | 9 - vendor/bytelyst/fastify-sse/package.json | 30 - vendor/bytelyst/fastify-sse/src/hub.test.ts | 162 ---- vendor/bytelyst/fastify-sse/src/hub.ts | 143 ---- vendor/bytelyst/fastify-sse/src/index.ts | 4 - .../fastify-sse/src/per-request.test.ts | 88 -- .../bytelyst/fastify-sse/src/per-request.ts | 32 - vendor/bytelyst/fastify-sse/src/plugin.ts | 61 -- vendor/bytelyst/fastify-sse/tsconfig.json | 9 - .../bytelyst/feature-flag-client/package.json | 24 - .../feature-flag-client/src/client.test.ts | 165 ---- .../feature-flag-client/src/client.ts | 300 ------- .../bytelyst/feature-flag-client/src/index.ts | 7 - .../bytelyst/feature-flag-client/src/types.ts | 88 -- .../feature-flag-client/tsconfig.json | 10 - vendor/bytelyst/feedback-client/package.json | 34 - .../bytelyst/feedback-client/src/gdpr.test.ts | 140 ---- .../feedback-client/src/index.test.ts | 48 -- vendor/bytelyst/feedback-client/src/index.ts | 384 --------- .../feedback-client/src/integration.test.ts | 210 ----- vendor/bytelyst/feedback-client/tsconfig.json | 10 - vendor/bytelyst/field-encrypt/package.json | 43 - vendor/bytelyst/field-encrypt/src/aes-gcm.ts | 89 -- .../field-encrypt/src/dek-store-cosmos.ts | 75 -- .../field-encrypt/src/dek-store-memory.ts | 27 - vendor/bytelyst/field-encrypt/src/envelope.ts | 107 --- .../field-encrypt/src/field-encryptor.ts | 227 ----- vendor/bytelyst/field-encrypt/src/guards.ts | 27 - .../bytelyst/field-encrypt/src/index.test.ts | 608 -------------- vendor/bytelyst/field-encrypt/src/index.ts | 58 -- .../bytelyst/field-encrypt/src/key-cache.ts | 94 --- .../field-encrypt/src/key-provider-akv.ts | 68 -- .../field-encrypt/src/key-provider-env.ts | 62 -- .../field-encrypt/src/key-provider-memory.ts | 48 -- .../bytelyst/field-encrypt/src/migration.ts | 110 --- vendor/bytelyst/field-encrypt/src/types.ts | 90 -- vendor/bytelyst/field-encrypt/tsconfig.json | 9 - .../gentle-notifications/package.json | 19 - .../gentle-notifications/src/client.test.ts | 97 --- .../gentle-notifications/src/client.ts | 157 ---- .../gentle-notifications/src/index.ts | 44 - .../gentle-notifications/src/types.ts | 29 - .../gentle-notifications/tsconfig.json | 14 - .../bytelyst/kill-switch-client/package.json | 24 - .../kill-switch-client/src/index.test.ts | 102 --- .../bytelyst/kill-switch-client/src/index.ts | 73 -- .../bytelyst/kill-switch-client/tsconfig.json | 10 - .../bytelyst/kotlin-platform-sdk/.gitignore | 11 - vendor/bytelyst/kotlin-platform-sdk/README.md | 574 ------------- .../kotlin-platform-sdk/build.gradle.kts | 77 -- .../kotlin-platform-sdk/consumer-rules.pro | 3 - .../kotlin-platform-sdk/gradle.properties | 1 - .../kotlin-platform-sdk/settings.gradle.kts | 16 - .../src/main/AndroidManifest.xml | 5 - .../com/bytelyst/platform/BLAuditLogger.kt | 115 --- .../com/bytelyst/platform/BLAuthClient.kt | 641 --------------- .../com/bytelyst/platform/BLBiometricAuth.kt | 83 -- .../com/bytelyst/platform/BLBlobClient.kt | 93 --- .../bytelyst/platform/BLBroadcastClient.kt | 223 ----- .../com/bytelyst/platform/BLCrashReporter.kt | 106 --- .../bytelyst/platform/BLFeatureFlagClient.kt | 92 --- .../com/bytelyst/platform/BLFeedbackClient.kt | 266 ------ .../com/bytelyst/platform/BLFieldEncrypt.kt | 253 ------ .../bytelyst/platform/BLKillSwitchClient.kt | 43 - .../com/bytelyst/platform/BLLicenseClient.kt | 81 -- .../com/bytelyst/platform/BLPasskeyManager.kt | 132 --- .../com/bytelyst/platform/BLPlatformClient.kt | 99 --- .../com/bytelyst/platform/BLPlatformConfig.kt | 33 - .../com/bytelyst/platform/BLSecureStore.kt | 51 -- .../com/bytelyst/platform/BLSurveyClient.kt | 367 --------- .../com/bytelyst/platform/BLSyncEngine.kt | 111 --- .../bytelyst/platform/BLTelemetryClient.kt | 195 ----- .../com/bytelyst/platform/ByteLystPlatform.kt | 80 -- .../com/bytelyst/platform/DeepLinkRouter.kt | 172 ---- .../platform/diagnostics/BreadcrumbTrail.kt | 74 -- .../diagnostics/DeviceStateCollector.kt | 114 --- .../platform/diagnostics/DiagnosticsClient.kt | 535 ------------ .../platform/diagnostics/DiagnosticsTypes.kt | 152 ---- .../diagnostics/NetworkInterceptor.kt | 124 --- .../com/bytelyst/platform/ui/BLAuthUI.kt | 664 --------------- .../com/bytelyst/platform/ui/BroadcastUI.kt | 330 -------- .../com/bytelyst/platform/ui/SurveyUI.kt | 775 ------------------ .../platform/BLAuthClientSmartAuthTest.kt | 178 ---- .../platform/BLFeatureFlagClientTest.kt | 134 --- .../bytelyst/platform/BLFieldEncryptTest.kt | 206 ----- .../platform/BLKillSwitchClientTest.kt | 113 --- .../platform/BLKillSwitchResultTest.kt | 39 - .../bytelyst/platform/BLLicenseClientTest.kt | 137 ---- .../bytelyst/platform/BLPlatformClientTest.kt | 175 ---- .../bytelyst/platform/BLPlatformConfigTest.kt | 62 -- .../bytelyst/platform/BLTelemetryEventTest.kt | 92 --- .../diagnostics/DiagnosticsTypesTest.kt | 216 ----- vendor/bytelyst/llm-router/README.md | 134 --- vendor/bytelyst/llm-router/package.json | 29 - .../src/__tests__/classifier.test.ts | 73 -- .../llm-router/src/__tests__/health.test.ts | 121 --- .../llm-router/src/__tests__/registry.test.ts | 115 --- .../llm-router/src/__tests__/router.test.ts | 320 -------- .../llm-router/src/__tests__/selector.test.ts | 138 ---- vendor/bytelyst/llm-router/src/classifier.ts | 115 --- vendor/bytelyst/llm-router/src/client.ts | 68 -- vendor/bytelyst/llm-router/src/health.ts | 103 --- vendor/bytelyst/llm-router/src/index.ts | 31 - vendor/bytelyst/llm-router/src/registry.ts | 244 ------ vendor/bytelyst/llm-router/src/router.ts | 362 -------- vendor/bytelyst/llm-router/src/selector.ts | 108 --- vendor/bytelyst/llm-router/src/types.ts | 147 ---- vendor/bytelyst/llm-router/tsconfig.json | 9 - vendor/bytelyst/llm-router/vitest.config.ts | 9 - vendor/bytelyst/llm/package.json | 30 - .../llm/src/__tests__/fallback.test.ts | 99 --- vendor/bytelyst/llm/src/__tests__/llm.test.ts | 299 ------- .../llm/src/__tests__/providers.test.ts | 181 ---- vendor/bytelyst/llm/src/factory.ts | 105 --- vendor/bytelyst/llm/src/fallback.ts | 36 - vendor/bytelyst/llm/src/index.ts | 24 - .../llm/src/providers/azure-openai.ts | 226 ----- vendor/bytelyst/llm/src/providers/fallback.ts | 47 -- vendor/bytelyst/llm/src/providers/gemini.ts | 122 --- vendor/bytelyst/llm/src/providers/mock.ts | 118 --- vendor/bytelyst/llm/src/providers/openai.ts | 205 ----- .../bytelyst/llm/src/providers/perplexity.ts | 74 -- vendor/bytelyst/llm/src/testing.ts | 18 - vendor/bytelyst/llm/src/types.ts | 131 --- vendor/bytelyst/llm/tsconfig.json | 9 - vendor/bytelyst/logger/package.json | 24 - .../logger/src/__tests__/logger.test.ts | 191 ----- vendor/bytelyst/logger/src/index.ts | 2 - vendor/bytelyst/logger/src/logger.ts | 83 -- vendor/bytelyst/logger/src/types.ts | 25 - vendor/bytelyst/logger/tsconfig.json | 8 - .../bytelyst/marketplace-client/package.json | 19 - .../marketplace-client/src/client.test.ts | 282 ------- .../bytelyst/marketplace-client/src/client.ts | 219 ----- .../bytelyst/marketplace-client/src/index.ts | 75 -- .../bytelyst/marketplace-client/src/types.ts | 115 --- .../bytelyst/marketplace-client/tsconfig.json | 14 - vendor/bytelyst/monitoring/package.json | 24 - .../src/__tests__/monitoring.test.ts | 105 --- vendor/bytelyst/monitoring/src/health.ts | 105 --- vendor/bytelyst/monitoring/src/index.ts | 1 - vendor/bytelyst/monitoring/tsconfig.json | 9 - vendor/bytelyst/offline-queue/package.json | 24 - .../bytelyst/offline-queue/src/index.test.ts | 143 ---- vendor/bytelyst/offline-queue/src/index.ts | 166 ---- vendor/bytelyst/offline-queue/tsconfig.json | 8 - vendor/bytelyst/ollama-client/package.json | 29 - .../ollama-client/src/client-parsers.test.ts | 94 --- .../ollama-client/src/client-parsers.ts | 115 --- .../bytelyst/ollama-client/src/client.test.ts | 153 ---- vendor/bytelyst/ollama-client/src/client.ts | 145 ---- .../bytelyst/ollama-client/src/config.test.ts | 43 - vendor/bytelyst/ollama-client/src/config.ts | 47 -- .../bytelyst/ollama-client/src/embed.test.ts | 66 -- vendor/bytelyst/ollama-client/src/embed.ts | 47 -- .../bytelyst/ollama-client/src/format.test.ts | 77 -- vendor/bytelyst/ollama-client/src/format.ts | 56 -- .../bytelyst/ollama-client/src/health.test.ts | 53 -- vendor/bytelyst/ollama-client/src/health.ts | 44 - vendor/bytelyst/ollama-client/src/index.ts | 41 - .../bytelyst/ollama-client/src/ndjson.test.ts | 73 -- vendor/bytelyst/ollama-client/src/ndjson.ts | 45 - .../bytelyst/ollama-client/src/stream.test.ts | 99 --- vendor/bytelyst/ollama-client/src/stream.ts | 85 -- vendor/bytelyst/ollama-client/src/types.ts | 133 --- vendor/bytelyst/ollama-client/tsconfig.json | 10 - .../bytelyst/ollama-client/vitest.config.ts | 9 - vendor/bytelyst/org-client/package.json | 19 - vendor/bytelyst/org-client/src/client.test.ts | 289 ------- vendor/bytelyst/org-client/src/client.ts | 224 ----- vendor/bytelyst/org-client/src/index.ts | 58 -- vendor/bytelyst/org-client/src/types.ts | 113 --- vendor/bytelyst/org-client/tsconfig.json | 14 - vendor/bytelyst/palace/package.json | 31 - .../palace/src/__tests__/cosine.test.ts | 86 -- .../palace/src/__tests__/decay.test.ts | 57 -- .../palace/src/__tests__/dedup.test.ts | 70 -- .../palace/src/__tests__/extraction.test.ts | 131 --- .../palace/src/__tests__/halls.test.ts | 75 -- .../bytelyst/palace/src/__tests__/kg.test.ts | 113 --- .../palace/src/__tests__/wakeup.test.ts | 96 --- vendor/bytelyst/palace/src/config.ts | 36 - vendor/bytelyst/palace/src/cosine.ts | 70 -- vendor/bytelyst/palace/src/decay.ts | 52 -- vendor/bytelyst/palace/src/dedup.ts | 64 -- vendor/bytelyst/palace/src/extraction.ts | 154 ---- vendor/bytelyst/palace/src/halls.ts | 103 --- vendor/bytelyst/palace/src/index.ts | 48 -- vendor/bytelyst/palace/src/kg.ts | 138 ---- vendor/bytelyst/palace/src/types.ts | 105 --- vendor/bytelyst/palace/src/wakeup.ts | 126 --- vendor/bytelyst/palace/tsconfig.json | 9 - vendor/bytelyst/platform-client/package.json | 24 - .../platform-client/src/index.test.ts | 162 ---- vendor/bytelyst/platform-client/src/index.ts | 156 ---- vendor/bytelyst/platform-client/tsconfig.json | 10 - vendor/bytelyst/push/package.json | 30 - .../bytelyst/push/src/__tests__/push.test.ts | 47 -- vendor/bytelyst/push/src/factory.ts | 41 - vendor/bytelyst/push/src/index.ts | 5 - vendor/bytelyst/push/src/providers/expo.ts | 64 -- vendor/bytelyst/push/src/providers/mock.ts | 28 - vendor/bytelyst/push/src/testing.ts | 18 - vendor/bytelyst/push/src/types.ts | 36 - vendor/bytelyst/push/tsconfig.json | 9 - vendor/bytelyst/queue/package.json | 28 - vendor/bytelyst/queue/src/file-store.ts | 173 ---- vendor/bytelyst/queue/src/index.ts | 13 - vendor/bytelyst/queue/src/memory-store.ts | 136 --- vendor/bytelyst/queue/src/queue.test.ts | 70 -- vendor/bytelyst/queue/src/types.ts | 95 --- vendor/bytelyst/queue/src/worker.ts | 100 --- vendor/bytelyst/queue/tsconfig.json | 9 - vendor/bytelyst/quick-actions/package.json | 19 - .../bytelyst/quick-actions/src/client.test.ts | 117 --- vendor/bytelyst/quick-actions/src/client.ts | 47 -- vendor/bytelyst/quick-actions/src/index.ts | 27 - vendor/bytelyst/quick-actions/src/types.ts | 26 - vendor/bytelyst/quick-actions/tsconfig.json | 14 - vendor/bytelyst/react-auth/package.json | 37 - .../src/__tests__/react-auth.test.tsx | 479 ----------- .../src/__tests__/smartauth.test.tsx | 339 -------- .../bytelyst/react-auth/src/auth-context.tsx | 551 ------------- vendor/bytelyst/react-auth/src/index.ts | 8 - vendor/bytelyst/react-auth/src/types.ts | 89 -- vendor/bytelyst/react-auth/tsconfig.json | 11 - vendor/bytelyst/react-auth/vitest.config.ts | 10 - .../v22.22.0-x64-9de703df-0/2f014b13 | Bin 1322028 -> 0 bytes .../v22.22.0-x64-9de703df-0/b0598a2c | Bin 1322076 -> 0 bytes .../react-native-platform-sdk/package.json | 66 -- .../react-native-platform-sdk/src/auth.ts | 1 - .../src/auth/index.ts | 208 ----- .../src/broadcasts.ts | 7 - .../src/broadcasts/index.ts | 125 --- .../react-native-platform-sdk/src/core.ts | 40 - .../src/feature-flags.ts | 1 - .../src/feature-flags/index.ts | 89 -- .../react-native-platform-sdk/src/index.ts | 55 -- .../src/kill-switch.ts | 1 - .../src/kill-switch/index.ts | 76 -- .../react-native-platform-sdk/src/surveys.ts | 7 - .../src/surveys/index.ts | 111 --- .../src/telemetry.ts | 6 - .../src/telemetry/index.ts | 134 --- .../react-native-platform-sdk/tsconfig.json | 12 - vendor/bytelyst/referral-client/package.json | 19 - .../referral-client/src/client.test.ts | 224 ----- vendor/bytelyst/referral-client/src/client.ts | 122 --- vendor/bytelyst/referral-client/src/index.ts | 72 -- vendor/bytelyst/referral-client/src/types.ts | 55 -- vendor/bytelyst/referral-client/tsconfig.json | 14 - .../bytelyst/secure-storage-web/package.json | 27 - .../bytelyst/secure-storage-web/src/index.ts | 19 - .../src/secure-storage.test.ts | 123 --- .../secure-storage-web/src/secure-storage.ts | 259 ------ .../bytelyst/secure-storage-web/tsconfig.json | 10 - vendor/bytelyst/speech/package.json | 27 - .../speech/src/__tests__/speech.test.ts | 117 --- vendor/bytelyst/speech/src/factory.ts | 54 -- vendor/bytelyst/speech/src/index.ts | 10 - vendor/bytelyst/speech/src/providers/mock.ts | 70 -- vendor/bytelyst/speech/src/types.ts | 84 -- vendor/bytelyst/speech/tsconfig.json | 9 - vendor/bytelyst/storage/package.json | 33 - .../src/__tests__/memory-storage.test.ts | 80 -- vendor/bytelyst/storage/src/factory.ts | 52 -- vendor/bytelyst/storage/src/index.ts | 12 - .../storage/src/providers/azure-blob.ts | 207 ----- .../bytelyst/storage/src/providers/memory.ts | 83 -- vendor/bytelyst/storage/src/testing.ts | 24 - vendor/bytelyst/storage/src/types.ts | 59 -- vendor/bytelyst/storage/tsconfig.json | 9 - .../bytelyst/subscription-client/package.json | 19 - .../subscription-client/src/client.test.ts | 283 ------- .../subscription-client/src/client.ts | 193 ----- .../bytelyst/subscription-client/src/index.ts | 156 ---- .../bytelyst/subscription-client/src/types.ts | 76 -- .../subscription-client/tsconfig.json | 15 - vendor/bytelyst/survey-client/README.md | 349 -------- vendor/bytelyst/survey-client/package.json | 24 - vendor/bytelyst/survey-client/src/index.ts | 328 -------- vendor/bytelyst/survey-client/tsconfig.json | 10 - .../bytelyst/swift-diagnostics/Package.swift | 33 - .../ByteLystDiagnostics.swift | 73 -- .../Core/BreadcrumbTrail.swift | 53 -- .../Core/Configuration.swift | 103 --- .../Core/DiagnosticsClient.swift | 397 --------- .../ByteLystDiagnostics/Core/Types.swift | 335 -------- .../Device/DeviceState.swift | 191 ----- .../Network/NetworkInterceptor.swift | 160 ---- .../DiagnosticsClientTests.swift | 296 ------- .../bytelyst/swift-platform-sdk/Package.swift | 31 - vendor/bytelyst/swift-platform-sdk/README.md | 215 ----- .../Sources/BLAuditLogger.swift | 82 -- .../Sources/BLAuthClient.swift | 665 --------------- .../swift-platform-sdk/Sources/BLAuthUI.swift | 740 ----------------- .../Sources/BLBiometricAuth.swift | 77 -- .../Sources/BLBlobClient.swift | 86 -- .../Sources/BLBroadcastClient.swift | 153 ---- .../Sources/BLCrashReporter.swift | 135 --- .../Sources/BLDeepLinkRouter.swift | 182 ---- .../Sources/BLFeatureFlagClient.swift | 86 -- .../Sources/BLFeedbackClient.swift | 269 ------ .../Sources/BLFieldEncrypt.swift | 277 ------- .../Sources/BLInAppMessageUI.swift | 235 ------ .../Sources/BLKeychain.swift | 54 -- .../Sources/BLKillSwitchClient.swift | 60 -- .../Sources/BLLicenseClient.swift | 104 --- .../Sources/BLPlatformClient.swift | 234 ------ .../Sources/BLPlatformConfig.swift | 64 -- .../Sources/BLSurveyClient.swift | 369 --------- .../Sources/BLSurveyUI.swift | 592 ------------- .../Sources/BLSyncEngine.swift | 240 ------ .../Sources/BLTelemetryClient.swift | 214 ----- .../Sources/ByteLystPlatform.swift | 122 --- .../Sources/ByteLystPlatformSDK.swift | 34 - .../Tests/BLAuthClientSmartAuthTests.swift | 196 ----- .../Tests/BLFeatureFlagClientTests.swift | 31 - .../Tests/BLFieldEncryptTests.swift | 186 ----- .../Tests/BLKeychainTests.swift | 33 - .../Tests/BLKillSwitchClientTests.swift | 27 - .../Tests/BLPlatformConfigTests.swift | 34 - .../Tests/BLTelemetryClientTests.swift | 65 -- .../Tests/ByteLystPlatformTests.swift | 67 -- vendor/bytelyst/sync/package.json | 34 - vendor/bytelyst/sync/src/engine.ts | 603 -------------- vendor/bytelyst/sync/src/index.ts | 52 -- vendor/bytelyst/sync/src/storage.ts | 127 --- vendor/bytelyst/sync/src/sync.test.ts | 608 -------------- vendor/bytelyst/sync/src/types.ts | 129 --- vendor/bytelyst/sync/tsconfig.json | 12 - vendor/bytelyst/sync/vitest.config.ts | 15 - vendor/bytelyst/telemetry-client/package.json | 24 - .../src/__tests__/telemetry-client.test.ts | 255 ------ .../bytelyst/telemetry-client/src/client.ts | 236 ------ vendor/bytelyst/telemetry-client/src/index.ts | 8 - vendor/bytelyst/telemetry-client/src/types.ts | 107 --- vendor/bytelyst/telemetry-client/src/web.ts | 81 -- .../bytelyst/telemetry-client/tsconfig.json | 10 - vendor/bytelyst/testing/package.json | 37 - .../testing/src/__tests__/testing.test.ts | 100 --- vendor/bytelyst/testing/src/auth-fixtures.ts | 55 -- vendor/bytelyst/testing/src/cosmos-mocks.ts | 87 -- .../bytelyst/testing/src/fastify-helpers.ts | 80 -- vendor/bytelyst/testing/src/index.ts | 15 - vendor/bytelyst/testing/tsconfig.json | 9 - vendor/bytelyst/time-references/package.json | 19 - .../time-references/src/client.test.ts | 107 --- vendor/bytelyst/time-references/src/client.ts | 159 ---- vendor/bytelyst/time-references/src/index.ts | 54 -- vendor/bytelyst/time-references/src/types.ts | 16 - vendor/bytelyst/time-references/tsconfig.json | 14 - vendor/bytelyst/ui/.storybook/main.ts | 12 - vendor/bytelyst/ui/.storybook/preview.ts | 16 - vendor/bytelyst/ui/README.md | 239 ------ vendor/bytelyst/ui/eslint.config.js | 34 - vendor/bytelyst/ui/package.json | 178 ---- .../bytelyst/ui/src/components/AppShell.tsx | 313 ------- .../ui/src/components/Badge.stories.tsx | 25 - vendor/bytelyst/ui/src/components/Badge.tsx | 58 -- .../ui/src/components/Button.stories.tsx | 47 -- vendor/bytelyst/ui/src/components/Button.tsx | 59 -- .../ui/src/components/Card.stories.tsx | 38 - vendor/bytelyst/ui/src/components/Card.tsx | 79 -- .../bytelyst/ui/src/components/Checkbox.tsx | 43 - .../ui/src/components/ConfirmDialog.tsx | 80 -- .../ui/src/components/Controls.stories.tsx | 92 --- .../bytelyst/ui/src/components/DataList.tsx | 56 -- .../bytelyst/ui/src/components/DataTable.tsx | 60 -- .../bytelyst/ui/src/components/DiffCard.tsx | 31 - .../ui/src/components/DropdownMenu.tsx | 86 -- .../bytelyst/ui/src/components/EmptyState.tsx | 46 -- .../bytelyst/ui/src/components/IconButton.tsx | 26 - .../ui/src/components/Input.stories.tsx | 20 - vendor/bytelyst/ui/src/components/Input.tsx | 72 -- vendor/bytelyst/ui/src/components/Label.tsx | 25 - .../ui/src/components/ListItemButton.tsx | 28 - .../ui/src/components/LoadingSpinner.tsx | 35 - vendor/bytelyst/ui/src/components/Modal.tsx | 64 -- .../OperationalPrimitives.stories.tsx | 120 --- vendor/bytelyst/ui/src/components/Panel.tsx | 77 -- .../bytelyst/ui/src/components/RadioGroup.tsx | 49 -- .../ui/src/components/SegmentedControl.tsx | 51 -- vendor/bytelyst/ui/src/components/Select.tsx | 95 --- .../bytelyst/ui/src/components/Separator.tsx | 28 - vendor/bytelyst/ui/src/components/Sidebar.tsx | 110 --- .../bytelyst/ui/src/components/StatCard.tsx | 65 -- .../ui/src/components/StatusBadge.tsx | 59 -- vendor/bytelyst/ui/src/components/Surface.tsx | 100 --- vendor/bytelyst/ui/src/components/Switch.tsx | 42 - vendor/bytelyst/ui/src/components/Tabs.tsx | 60 -- .../bytelyst/ui/src/components/Textarea.tsx | 72 -- .../bytelyst/ui/src/components/Timeline.tsx | 61 -- vendor/bytelyst/ui/src/components/Toast.tsx | 97 --- vendor/bytelyst/ui/src/components/Tooltip.tsx | 30 - vendor/bytelyst/ui/src/index.ts | 138 ---- vendor/bytelyst/ui/tsconfig.json | 13 - .../use-keyboard-shortcuts/package.json | 36 - .../__tests__/use-keyboard-shortcuts.test.ts | 240 ------ .../use-keyboard-shortcuts/src/index.ts | 2 - .../src/use-keyboard-shortcuts.ts | 59 -- .../use-keyboard-shortcuts/tsconfig.json | 11 - vendor/bytelyst/use-theme/package.json | 36 - .../use-theme/src/__tests__/use-theme.test.ts | 138 ---- vendor/bytelyst/use-theme/src/index.ts | 2 - vendor/bytelyst/use-theme/src/use-theme.ts | 88 -- vendor/bytelyst/use-theme/tsconfig.json | 11 - vendor/bytelyst/webhook-dispatch/package.json | 28 - .../webhook-dispatch/src/dispatcher.test.ts | 163 ---- .../webhook-dispatch/src/dispatcher.ts | 157 ---- vendor/bytelyst/webhook-dispatch/src/index.ts | 8 - vendor/bytelyst/webhook-dispatch/src/types.ts | 53 -- .../bytelyst/webhook-dispatch/tsconfig.json | 9 - web/Dockerfile | 48 +- web/package.json | 17 +- web/src/index.css | 4 +- web/src/tabs/DevOpsTab.tsx | 87 ++ web/src/views/SettingsView.tsx | 6 +- web/vite.config.ts | 13 +- 687 files changed, 206 insertions(+), 71856 deletions(-) create mode 100644 .dockerignore delete mode 100644 vendor/bytelyst/accessibility/package.json delete mode 100644 vendor/bytelyst/accessibility/src/index.ts delete mode 100644 vendor/bytelyst/accessibility/tsconfig.json delete mode 100644 vendor/bytelyst/api-client/package.json delete mode 100644 vendor/bytelyst/api-client/src/__tests__/api-client.test.ts delete mode 100644 vendor/bytelyst/api-client/src/client.ts delete mode 100644 vendor/bytelyst/api-client/src/index.ts delete mode 100644 vendor/bytelyst/api-client/src/types.ts delete mode 100644 vendor/bytelyst/api-client/tsconfig.json delete mode 100644 vendor/bytelyst/auth-client/package.json delete mode 100644 vendor/bytelyst/auth-client/src/__tests__/auth-client.test.ts delete mode 100644 vendor/bytelyst/auth-client/src/__tests__/smartauth.test.ts delete mode 100644 vendor/bytelyst/auth-client/src/client.ts delete mode 100644 vendor/bytelyst/auth-client/src/index.ts delete mode 100644 vendor/bytelyst/auth-client/src/types.ts delete mode 100644 vendor/bytelyst/auth-client/tsconfig.json delete mode 100644 vendor/bytelyst/auth-ui/package.json delete mode 100644 vendor/bytelyst/auth-ui/src/AuthPageLayout.tsx delete mode 100644 vendor/bytelyst/auth-ui/src/ForgotPasswordForm.tsx delete mode 100644 vendor/bytelyst/auth-ui/src/LoginForm.tsx delete mode 100644 vendor/bytelyst/auth-ui/src/MfaChallenge.tsx delete mode 100644 vendor/bytelyst/auth-ui/src/OnboardingShell.tsx delete mode 100644 vendor/bytelyst/auth-ui/src/PasswordStrengthBar.tsx delete mode 100644 vendor/bytelyst/auth-ui/src/RegisterForm.tsx delete mode 100644 vendor/bytelyst/auth-ui/src/ResetPasswordForm.tsx delete mode 100644 vendor/bytelyst/auth-ui/src/SocialButtons.tsx delete mode 100644 vendor/bytelyst/auth-ui/src/VerifyEmailForm.tsx delete mode 100644 vendor/bytelyst/auth-ui/src/__tests__/auth-ui.test.tsx delete mode 100644 vendor/bytelyst/auth-ui/src/__tests__/new-components.test.tsx delete mode 100644 vendor/bytelyst/auth-ui/src/index.ts delete mode 100644 vendor/bytelyst/auth-ui/src/types.ts delete mode 100644 vendor/bytelyst/auth-ui/tsconfig.json delete mode 100644 vendor/bytelyst/auth-ui/vitest.config.ts delete mode 100644 vendor/bytelyst/auth/package.json delete mode 100644 vendor/bytelyst/auth/src/__tests__/auth.test.ts delete mode 100644 vendor/bytelyst/auth/src/__tests__/e2e-auth-flow.test.ts delete mode 100644 vendor/bytelyst/auth/src/__tests__/middleware.test.ts delete mode 100644 vendor/bytelyst/auth/src/__tests__/rs256.test.ts delete mode 100644 vendor/bytelyst/auth/src/index.ts delete mode 100644 vendor/bytelyst/auth/src/jwt.ts delete mode 100644 vendor/bytelyst/auth/src/middleware.ts delete mode 100644 vendor/bytelyst/auth/src/password.ts delete mode 100644 vendor/bytelyst/auth/src/server-auth.ts delete mode 100644 vendor/bytelyst/auth/src/types.ts delete mode 100644 vendor/bytelyst/auth/tsconfig.json delete mode 100644 vendor/bytelyst/auth/vitest.config.ts delete mode 100644 vendor/bytelyst/backend-config/package.json delete mode 100644 vendor/bytelyst/backend-config/src/index.test.ts delete mode 100644 vendor/bytelyst/backend-config/src/index.ts delete mode 100644 vendor/bytelyst/backend-config/tsconfig.json delete mode 100644 vendor/bytelyst/backend-flags/package.json delete mode 100644 vendor/bytelyst/backend-flags/src/index.test.ts delete mode 100644 vendor/bytelyst/backend-flags/src/index.ts delete mode 100644 vendor/bytelyst/backend-flags/tsconfig.json delete mode 100644 vendor/bytelyst/backend-telemetry/package.json delete mode 100644 vendor/bytelyst/backend-telemetry/src/index.test.ts delete mode 100644 vendor/bytelyst/backend-telemetry/src/index.ts delete mode 100644 vendor/bytelyst/backend-telemetry/tsconfig.json delete mode 100644 vendor/bytelyst/billing-client/.gitignore delete mode 100644 vendor/bytelyst/billing-client/package.json delete mode 100644 vendor/bytelyst/billing-client/src/index.test.ts delete mode 100644 vendor/bytelyst/billing-client/src/index.ts delete mode 100644 vendor/bytelyst/billing-client/tsconfig.json delete mode 100644 vendor/bytelyst/blob-client/package.json delete mode 100644 vendor/bytelyst/blob-client/src/index.test.ts delete mode 100644 vendor/bytelyst/blob-client/src/index.ts delete mode 100644 vendor/bytelyst/blob-client/tsconfig.json delete mode 100644 vendor/bytelyst/blob/package.json delete mode 100644 vendor/bytelyst/blob/src/__tests__/blob.test.ts delete mode 100644 vendor/bytelyst/blob/src/blob.ts delete mode 100644 vendor/bytelyst/blob/src/index.ts delete mode 100644 vendor/bytelyst/blob/tsconfig.json delete mode 100644 vendor/bytelyst/broadcast-client/README.md delete mode 100644 vendor/bytelyst/broadcast-client/package.json delete mode 100644 vendor/bytelyst/broadcast-client/src/deep-link.ts delete mode 100644 vendor/bytelyst/broadcast-client/src/index.test.ts delete mode 100644 vendor/bytelyst/broadcast-client/src/index.ts delete mode 100644 vendor/bytelyst/broadcast-client/tsconfig.json delete mode 100644 vendor/bytelyst/celebrations/package.json delete mode 100644 vendor/bytelyst/celebrations/src/index.ts delete mode 100644 vendor/bytelyst/celebrations/tsconfig.json delete mode 100644 vendor/bytelyst/client-encrypt/package.json delete mode 100644 vendor/bytelyst/client-encrypt/src/aes-gcm.test.ts delete mode 100644 vendor/bytelyst/client-encrypt/src/aes-gcm.ts delete mode 100644 vendor/bytelyst/client-encrypt/src/guards.ts delete mode 100644 vendor/bytelyst/client-encrypt/src/hex.ts delete mode 100644 vendor/bytelyst/client-encrypt/src/index.ts delete mode 100644 vendor/bytelyst/client-encrypt/src/types.ts delete mode 100644 vendor/bytelyst/client-encrypt/tsconfig.json delete mode 100644 vendor/bytelyst/config/package.json delete mode 100644 vendor/bytelyst/config/src/__tests__/config.test.ts delete mode 100644 vendor/bytelyst/config/src/__tests__/keyvault.test.ts delete mode 100644 vendor/bytelyst/config/src/__tests__/product-manifest.test.ts delete mode 100644 vendor/bytelyst/config/src/base-schema.ts delete mode 100644 vendor/bytelyst/config/src/index.ts delete mode 100644 vendor/bytelyst/config/src/keyvault.ts delete mode 100644 vendor/bytelyst/config/src/loader.ts delete mode 100644 vendor/bytelyst/config/src/product-identity.ts delete mode 100644 vendor/bytelyst/config/src/product-manifest.ts delete mode 100644 vendor/bytelyst/config/tsconfig.json delete mode 100644 vendor/bytelyst/cosmos/package.json delete mode 100644 vendor/bytelyst/cosmos/src/__tests__/cosmos.test.ts delete mode 100644 vendor/bytelyst/cosmos/src/client.ts delete mode 100644 vendor/bytelyst/cosmos/src/containers.ts delete mode 100644 vendor/bytelyst/cosmos/src/index.ts delete mode 100644 vendor/bytelyst/cosmos/src/types.ts delete mode 100644 vendor/bytelyst/cosmos/tsconfig.json delete mode 100644 vendor/bytelyst/create-app/package.json delete mode 100644 vendor/bytelyst/create-app/src/__tests__/scaffolder.test.ts delete mode 100644 vendor/bytelyst/create-app/src/__tests__/template-engine.test.ts delete mode 100644 vendor/bytelyst/create-app/src/generators/agents-md.ts delete mode 100644 vendor/bytelyst/create-app/src/generators/api-routes.ts delete mode 100644 vendor/bytelyst/create-app/src/index.ts delete mode 100644 vendor/bytelyst/create-app/src/lib/template-engine.ts delete mode 100644 vendor/bytelyst/create-app/src/lib/templates.ts delete mode 100644 vendor/bytelyst/create-app/src/scaffolder.ts delete mode 100644 vendor/bytelyst/create-app/tsconfig.json delete mode 100644 vendor/bytelyst/create-app/vitest.config.ts delete mode 100644 vendor/bytelyst/dashboard-components/package.json delete mode 100644 vendor/bytelyst/dashboard-components/src/EmptyState.tsx delete mode 100644 vendor/bytelyst/dashboard-components/src/ErrorPage.tsx delete mode 100644 vendor/bytelyst/dashboard-components/src/LoadingSkeleton.tsx delete mode 100644 vendor/bytelyst/dashboard-components/src/LoadingSpinner.tsx delete mode 100644 vendor/bytelyst/dashboard-components/src/NotFoundPage.tsx delete mode 100644 vendor/bytelyst/dashboard-components/src/PageHeader.tsx delete mode 100644 vendor/bytelyst/dashboard-components/src/components.test.tsx delete mode 100644 vendor/bytelyst/dashboard-components/src/index.ts delete mode 100644 vendor/bytelyst/dashboard-components/tsconfig.json delete mode 100644 vendor/bytelyst/dashboard-components/vitest.config.ts delete mode 100644 vendor/bytelyst/dashboard-shell/package.json delete mode 100644 vendor/bytelyst/dashboard-shell/src/BillingPage.tsx delete mode 100644 vendor/bytelyst/dashboard-shell/src/DashboardShell.tsx delete mode 100644 vendor/bytelyst/dashboard-shell/src/ProfilePage.tsx delete mode 100644 vendor/bytelyst/dashboard-shell/src/SettingsPage.tsx delete mode 100644 vendor/bytelyst/dashboard-shell/src/Sidebar.tsx delete mode 100644 vendor/bytelyst/dashboard-shell/src/TopBar.tsx delete mode 100644 vendor/bytelyst/dashboard-shell/src/__tests__/dashboard-shell.test.tsx delete mode 100644 vendor/bytelyst/dashboard-shell/src/index.ts delete mode 100644 vendor/bytelyst/dashboard-shell/src/types.ts delete mode 100644 vendor/bytelyst/dashboard-shell/tsconfig.json delete mode 100644 vendor/bytelyst/dashboard-shell/vitest.config.ts delete mode 100644 vendor/bytelyst/datastore/package.json delete mode 100644 vendor/bytelyst/datastore/src/__tests__/memory.test.ts delete mode 100644 vendor/bytelyst/datastore/src/factory.ts delete mode 100644 vendor/bytelyst/datastore/src/filter.ts delete mode 100644 vendor/bytelyst/datastore/src/index.ts delete mode 100644 vendor/bytelyst/datastore/src/providers/cosmos.ts delete mode 100644 vendor/bytelyst/datastore/src/providers/memory.ts delete mode 100644 vendor/bytelyst/datastore/src/testing.ts delete mode 100644 vendor/bytelyst/datastore/src/types.ts delete mode 100644 vendor/bytelyst/datastore/tsconfig.json delete mode 100644 vendor/bytelyst/design-tokens/README.md delete mode 100644 vendor/bytelyst/design-tokens/bytelyst-design-tokens-0.1.0.tgz delete mode 100644 vendor/bytelyst/design-tokens/generated/MindLystTheme.swift delete mode 100644 vendor/bytelyst/design-tokens/generated/MindLystTokens.kt delete mode 100644 vendor/bytelyst/design-tokens/generated/actiontrail.css delete mode 100644 vendor/bytelyst/design-tokens/generated/chronomind.css delete mode 100644 vendor/bytelyst/design-tokens/generated/flowmonk.css delete mode 100644 vendor/bytelyst/design-tokens/generated/jarvisjr.css delete mode 100644 vendor/bytelyst/design-tokens/generated/localllmlab.css delete mode 100644 vendor/bytelyst/design-tokens/generated/localmemgpt.css delete mode 100644 vendor/bytelyst/design-tokens/generated/lysnrai.css delete mode 100644 vendor/bytelyst/design-tokens/generated/mindlyst.css delete mode 100644 vendor/bytelyst/design-tokens/generated/native/ActionTrailTheme.generated.swift delete mode 100644 vendor/bytelyst/design-tokens/generated/native/ActionTrailTokens.generated.kt delete mode 100644 vendor/bytelyst/design-tokens/generated/native/ChronoMindTheme.generated.swift delete mode 100644 vendor/bytelyst/design-tokens/generated/native/ChronoMindTokens.generated.kt delete mode 100644 vendor/bytelyst/design-tokens/generated/native/FlowMonkTheme.generated.swift delete mode 100644 vendor/bytelyst/design-tokens/generated/native/FlowMonkTokens.generated.kt delete mode 100644 vendor/bytelyst/design-tokens/generated/native/JarvisJrTheme.generated.swift delete mode 100644 vendor/bytelyst/design-tokens/generated/native/JarvisJrTokens.generated.kt delete mode 100644 vendor/bytelyst/design-tokens/generated/native/LocalLLMLabTheme.generated.swift delete mode 100644 vendor/bytelyst/design-tokens/generated/native/LocalLLMLabTokens.generated.kt delete mode 100644 vendor/bytelyst/design-tokens/generated/native/LocalMemGPTTheme.generated.swift delete mode 100644 vendor/bytelyst/design-tokens/generated/native/LocalMemGPTTokens.generated.kt delete mode 100644 vendor/bytelyst/design-tokens/generated/native/LysnrAITheme.generated.swift delete mode 100644 vendor/bytelyst/design-tokens/generated/native/LysnrAITokens.generated.kt delete mode 100644 vendor/bytelyst/design-tokens/generated/native/NomGapTheme.generated.swift delete mode 100644 vendor/bytelyst/design-tokens/generated/native/NomGapTokens.generated.kt delete mode 100644 vendor/bytelyst/design-tokens/generated/native/NoteLettTheme.generated.swift delete mode 100644 vendor/bytelyst/design-tokens/generated/native/NoteLettTokens.generated.kt delete mode 100644 vendor/bytelyst/design-tokens/generated/native/PeakPulseTheme.generated.swift delete mode 100644 vendor/bytelyst/design-tokens/generated/native/PeakPulseTokens.generated.kt delete mode 100644 vendor/bytelyst/design-tokens/generated/nomgap.css delete mode 100644 vendor/bytelyst/design-tokens/generated/notelett.css delete mode 100644 vendor/bytelyst/design-tokens/generated/peakpulse.css delete mode 100644 vendor/bytelyst/design-tokens/generated/react-native/tokens.ts delete mode 100644 vendor/bytelyst/design-tokens/generated/tokens.css delete mode 100644 vendor/bytelyst/design-tokens/generated/tokens.ts delete mode 100644 vendor/bytelyst/design-tokens/package.json delete mode 100644 vendor/bytelyst/design-tokens/scripts/generate-react-native.ts delete mode 100644 vendor/bytelyst/design-tokens/scripts/generate.ts delete mode 100755 vendor/bytelyst/design-tokens/scripts/token-coverage.cjs delete mode 100755 vendor/bytelyst/design-tokens/scripts/validate-tokens.cjs delete mode 100644 vendor/bytelyst/design-tokens/src/__tests__/tokens.test.ts delete mode 100644 vendor/bytelyst/design-tokens/src/index.ts delete mode 100644 vendor/bytelyst/design-tokens/tokens/bytelyst.tokens.json delete mode 100644 vendor/bytelyst/design-tokens/tsconfig.json delete mode 100644 vendor/bytelyst/diagnostics-client/package.json delete mode 100644 vendor/bytelyst/diagnostics-client/src/__tests__/client.test.ts delete mode 100644 vendor/bytelyst/diagnostics-client/src/breadcrumbs.ts delete mode 100644 vendor/bytelyst/diagnostics-client/src/client.ts delete mode 100644 vendor/bytelyst/diagnostics-client/src/device.ts delete mode 100644 vendor/bytelyst/diagnostics-client/src/index.ts delete mode 100644 vendor/bytelyst/diagnostics-client/src/network.ts delete mode 100644 vendor/bytelyst/diagnostics-client/src/types.ts delete mode 100644 vendor/bytelyst/diagnostics-client/src/web.ts delete mode 100644 vendor/bytelyst/diagnostics-client/tsconfig.json delete mode 100644 vendor/bytelyst/errors/package.json delete mode 100644 vendor/bytelyst/errors/src/__tests__/errors.test.ts delete mode 100644 vendor/bytelyst/errors/src/http-errors.ts delete mode 100644 vendor/bytelyst/errors/src/index.ts delete mode 100644 vendor/bytelyst/errors/src/service-error.ts delete mode 100644 vendor/bytelyst/errors/tsconfig.json delete mode 100644 vendor/bytelyst/event-store/package.json delete mode 100644 vendor/bytelyst/event-store/src/file-store.test.ts delete mode 100644 vendor/bytelyst/event-store/src/file-store.ts delete mode 100644 vendor/bytelyst/event-store/src/index.ts delete mode 100644 vendor/bytelyst/event-store/src/memory-store.test.ts delete mode 100644 vendor/bytelyst/event-store/src/memory-store.ts delete mode 100644 vendor/bytelyst/event-store/src/types.ts delete mode 100644 vendor/bytelyst/event-store/tsconfig.json delete mode 100644 vendor/bytelyst/events/fixtures/ecosystem/phase1/artifact-created.event.json delete mode 100644 vendor/bytelyst/events/fixtures/ecosystem/phase1/artifact-linked.event.json delete mode 100644 vendor/bytelyst/events/fixtures/ecosystem/phase1/capture-transcript-created.event.json delete mode 100644 vendor/bytelyst/events/fixtures/ecosystem/phase1/memory-artifact.json delete mode 100644 vendor/bytelyst/events/fixtures/ecosystem/phase1/memory-entry-created.event.json delete mode 100644 vendor/bytelyst/events/fixtures/ecosystem/phase1/note-artifact.json delete mode 100644 vendor/bytelyst/events/fixtures/ecosystem/phase1/transcript-artifact.json delete mode 100644 vendor/bytelyst/events/package.json delete mode 100644 vendor/bytelyst/events/src/agent-runtime.test.ts delete mode 100644 vendor/bytelyst/events/src/agent-runtime.ts delete mode 100644 vendor/bytelyst/events/src/durable.test.ts delete mode 100644 vendor/bytelyst/events/src/durable.ts delete mode 100644 vendor/bytelyst/events/src/ecosystem.test.ts delete mode 100644 vendor/bytelyst/events/src/ecosystem.ts delete mode 100644 vendor/bytelyst/events/src/index.ts delete mode 100644 vendor/bytelyst/events/src/memory.test.ts delete mode 100644 vendor/bytelyst/events/src/memory.ts delete mode 100644 vendor/bytelyst/events/src/timeline.test.ts delete mode 100644 vendor/bytelyst/events/src/timeline.ts delete mode 100644 vendor/bytelyst/events/src/types.ts delete mode 100644 vendor/bytelyst/events/tsconfig.json delete mode 100644 vendor/bytelyst/extraction/package.json delete mode 100644 vendor/bytelyst/extraction/src/__tests__/extraction.test.ts delete mode 100644 vendor/bytelyst/extraction/src/client.ts delete mode 100644 vendor/bytelyst/extraction/src/index.ts delete mode 100644 vendor/bytelyst/extraction/src/types.ts delete mode 100644 vendor/bytelyst/extraction/tsconfig.json delete mode 100644 vendor/bytelyst/fastify-auth/package.json delete mode 100644 vendor/bytelyst/fastify-auth/src/auth.ts delete mode 100644 vendor/bytelyst/fastify-auth/src/index.test.ts delete mode 100644 vendor/bytelyst/fastify-auth/src/index.ts delete mode 100644 vendor/bytelyst/fastify-auth/src/request-context.ts delete mode 100644 vendor/bytelyst/fastify-auth/src/types.ts delete mode 100644 vendor/bytelyst/fastify-auth/tsconfig.json delete mode 100644 vendor/bytelyst/fastify-core/package.json delete mode 100644 vendor/bytelyst/fastify-core/src/__tests__/fastify-core.test.ts delete mode 100644 vendor/bytelyst/fastify-core/src/auth.ts delete mode 100644 vendor/bytelyst/fastify-core/src/create-app.ts delete mode 100644 vendor/bytelyst/fastify-core/src/index.ts delete mode 100644 vendor/bytelyst/fastify-core/src/start.ts delete mode 100644 vendor/bytelyst/fastify-core/src/types.ts delete mode 100644 vendor/bytelyst/fastify-core/tsconfig.json delete mode 100644 vendor/bytelyst/fastify-sse/package.json delete mode 100644 vendor/bytelyst/fastify-sse/src/hub.test.ts delete mode 100644 vendor/bytelyst/fastify-sse/src/hub.ts delete mode 100644 vendor/bytelyst/fastify-sse/src/index.ts delete mode 100644 vendor/bytelyst/fastify-sse/src/per-request.test.ts delete mode 100644 vendor/bytelyst/fastify-sse/src/per-request.ts delete mode 100644 vendor/bytelyst/fastify-sse/src/plugin.ts delete mode 100644 vendor/bytelyst/fastify-sse/tsconfig.json delete mode 100644 vendor/bytelyst/feature-flag-client/package.json delete mode 100644 vendor/bytelyst/feature-flag-client/src/client.test.ts delete mode 100644 vendor/bytelyst/feature-flag-client/src/client.ts delete mode 100644 vendor/bytelyst/feature-flag-client/src/index.ts delete mode 100644 vendor/bytelyst/feature-flag-client/src/types.ts delete mode 100644 vendor/bytelyst/feature-flag-client/tsconfig.json delete mode 100644 vendor/bytelyst/feedback-client/package.json delete mode 100644 vendor/bytelyst/feedback-client/src/gdpr.test.ts delete mode 100644 vendor/bytelyst/feedback-client/src/index.test.ts delete mode 100644 vendor/bytelyst/feedback-client/src/index.ts delete mode 100644 vendor/bytelyst/feedback-client/src/integration.test.ts delete mode 100644 vendor/bytelyst/feedback-client/tsconfig.json delete mode 100644 vendor/bytelyst/field-encrypt/package.json delete mode 100644 vendor/bytelyst/field-encrypt/src/aes-gcm.ts delete mode 100644 vendor/bytelyst/field-encrypt/src/dek-store-cosmos.ts delete mode 100644 vendor/bytelyst/field-encrypt/src/dek-store-memory.ts delete mode 100644 vendor/bytelyst/field-encrypt/src/envelope.ts delete mode 100644 vendor/bytelyst/field-encrypt/src/field-encryptor.ts delete mode 100644 vendor/bytelyst/field-encrypt/src/guards.ts delete mode 100644 vendor/bytelyst/field-encrypt/src/index.test.ts delete mode 100644 vendor/bytelyst/field-encrypt/src/index.ts delete mode 100644 vendor/bytelyst/field-encrypt/src/key-cache.ts delete mode 100644 vendor/bytelyst/field-encrypt/src/key-provider-akv.ts delete mode 100644 vendor/bytelyst/field-encrypt/src/key-provider-env.ts delete mode 100644 vendor/bytelyst/field-encrypt/src/key-provider-memory.ts delete mode 100644 vendor/bytelyst/field-encrypt/src/migration.ts delete mode 100644 vendor/bytelyst/field-encrypt/src/types.ts delete mode 100644 vendor/bytelyst/field-encrypt/tsconfig.json delete mode 100644 vendor/bytelyst/gentle-notifications/package.json delete mode 100644 vendor/bytelyst/gentle-notifications/src/client.test.ts delete mode 100644 vendor/bytelyst/gentle-notifications/src/client.ts delete mode 100644 vendor/bytelyst/gentle-notifications/src/index.ts delete mode 100644 vendor/bytelyst/gentle-notifications/src/types.ts delete mode 100644 vendor/bytelyst/gentle-notifications/tsconfig.json delete mode 100644 vendor/bytelyst/kill-switch-client/package.json delete mode 100644 vendor/bytelyst/kill-switch-client/src/index.test.ts delete mode 100644 vendor/bytelyst/kill-switch-client/src/index.ts delete mode 100644 vendor/bytelyst/kill-switch-client/tsconfig.json delete mode 100644 vendor/bytelyst/kotlin-platform-sdk/.gitignore delete mode 100644 vendor/bytelyst/kotlin-platform-sdk/README.md delete mode 100644 vendor/bytelyst/kotlin-platform-sdk/build.gradle.kts delete mode 100644 vendor/bytelyst/kotlin-platform-sdk/consumer-rules.pro delete mode 100644 vendor/bytelyst/kotlin-platform-sdk/gradle.properties delete mode 100644 vendor/bytelyst/kotlin-platform-sdk/settings.gradle.kts delete mode 100644 vendor/bytelyst/kotlin-platform-sdk/src/main/AndroidManifest.xml delete mode 100644 vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLAuditLogger.kt delete mode 100644 vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLAuthClient.kt delete mode 100644 vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLBiometricAuth.kt delete mode 100644 vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLBlobClient.kt delete mode 100644 vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLBroadcastClient.kt delete mode 100644 vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLCrashReporter.kt delete mode 100644 vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLFeatureFlagClient.kt delete mode 100644 vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLFeedbackClient.kt delete mode 100644 vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLFieldEncrypt.kt delete mode 100644 vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLKillSwitchClient.kt delete mode 100644 vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLLicenseClient.kt delete mode 100644 vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLPasskeyManager.kt delete mode 100644 vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLPlatformClient.kt delete mode 100644 vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLPlatformConfig.kt delete mode 100644 vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLSecureStore.kt delete mode 100644 vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLSurveyClient.kt delete mode 100644 vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLSyncEngine.kt delete mode 100644 vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLTelemetryClient.kt delete mode 100644 vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/ByteLystPlatform.kt delete mode 100644 vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/DeepLinkRouter.kt delete mode 100644 vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/BreadcrumbTrail.kt delete mode 100644 vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/DeviceStateCollector.kt delete mode 100644 vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/DiagnosticsClient.kt delete mode 100644 vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/DiagnosticsTypes.kt delete mode 100644 vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/NetworkInterceptor.kt delete mode 100644 vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/ui/BLAuthUI.kt delete mode 100644 vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/ui/BroadcastUI.kt delete mode 100644 vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/ui/SurveyUI.kt delete mode 100644 vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLAuthClientSmartAuthTest.kt delete mode 100644 vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLFeatureFlagClientTest.kt delete mode 100644 vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLFieldEncryptTest.kt delete mode 100644 vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLKillSwitchClientTest.kt delete mode 100644 vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLKillSwitchResultTest.kt delete mode 100644 vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLLicenseClientTest.kt delete mode 100644 vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLPlatformClientTest.kt delete mode 100644 vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLPlatformConfigTest.kt delete mode 100644 vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLTelemetryEventTest.kt delete mode 100644 vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/diagnostics/DiagnosticsTypesTest.kt delete mode 100644 vendor/bytelyst/llm-router/README.md delete mode 100644 vendor/bytelyst/llm-router/package.json delete mode 100644 vendor/bytelyst/llm-router/src/__tests__/classifier.test.ts delete mode 100644 vendor/bytelyst/llm-router/src/__tests__/health.test.ts delete mode 100644 vendor/bytelyst/llm-router/src/__tests__/registry.test.ts delete mode 100644 vendor/bytelyst/llm-router/src/__tests__/router.test.ts delete mode 100644 vendor/bytelyst/llm-router/src/__tests__/selector.test.ts delete mode 100644 vendor/bytelyst/llm-router/src/classifier.ts delete mode 100644 vendor/bytelyst/llm-router/src/client.ts delete mode 100644 vendor/bytelyst/llm-router/src/health.ts delete mode 100644 vendor/bytelyst/llm-router/src/index.ts delete mode 100644 vendor/bytelyst/llm-router/src/registry.ts delete mode 100644 vendor/bytelyst/llm-router/src/router.ts delete mode 100644 vendor/bytelyst/llm-router/src/selector.ts delete mode 100644 vendor/bytelyst/llm-router/src/types.ts delete mode 100644 vendor/bytelyst/llm-router/tsconfig.json delete mode 100644 vendor/bytelyst/llm-router/vitest.config.ts delete mode 100644 vendor/bytelyst/llm/package.json delete mode 100644 vendor/bytelyst/llm/src/__tests__/fallback.test.ts delete mode 100644 vendor/bytelyst/llm/src/__tests__/llm.test.ts delete mode 100644 vendor/bytelyst/llm/src/__tests__/providers.test.ts delete mode 100644 vendor/bytelyst/llm/src/factory.ts delete mode 100644 vendor/bytelyst/llm/src/fallback.ts delete mode 100644 vendor/bytelyst/llm/src/index.ts delete mode 100644 vendor/bytelyst/llm/src/providers/azure-openai.ts delete mode 100644 vendor/bytelyst/llm/src/providers/fallback.ts delete mode 100644 vendor/bytelyst/llm/src/providers/gemini.ts delete mode 100644 vendor/bytelyst/llm/src/providers/mock.ts delete mode 100644 vendor/bytelyst/llm/src/providers/openai.ts delete mode 100644 vendor/bytelyst/llm/src/providers/perplexity.ts delete mode 100644 vendor/bytelyst/llm/src/testing.ts delete mode 100644 vendor/bytelyst/llm/src/types.ts delete mode 100644 vendor/bytelyst/llm/tsconfig.json delete mode 100644 vendor/bytelyst/logger/package.json delete mode 100644 vendor/bytelyst/logger/src/__tests__/logger.test.ts delete mode 100644 vendor/bytelyst/logger/src/index.ts delete mode 100644 vendor/bytelyst/logger/src/logger.ts delete mode 100644 vendor/bytelyst/logger/src/types.ts delete mode 100644 vendor/bytelyst/logger/tsconfig.json delete mode 100644 vendor/bytelyst/marketplace-client/package.json delete mode 100644 vendor/bytelyst/marketplace-client/src/client.test.ts delete mode 100644 vendor/bytelyst/marketplace-client/src/client.ts delete mode 100644 vendor/bytelyst/marketplace-client/src/index.ts delete mode 100644 vendor/bytelyst/marketplace-client/src/types.ts delete mode 100644 vendor/bytelyst/marketplace-client/tsconfig.json delete mode 100644 vendor/bytelyst/monitoring/package.json delete mode 100644 vendor/bytelyst/monitoring/src/__tests__/monitoring.test.ts delete mode 100644 vendor/bytelyst/monitoring/src/health.ts delete mode 100644 vendor/bytelyst/monitoring/src/index.ts delete mode 100644 vendor/bytelyst/monitoring/tsconfig.json delete mode 100644 vendor/bytelyst/offline-queue/package.json delete mode 100644 vendor/bytelyst/offline-queue/src/index.test.ts delete mode 100644 vendor/bytelyst/offline-queue/src/index.ts delete mode 100644 vendor/bytelyst/offline-queue/tsconfig.json delete mode 100644 vendor/bytelyst/ollama-client/package.json delete mode 100644 vendor/bytelyst/ollama-client/src/client-parsers.test.ts delete mode 100644 vendor/bytelyst/ollama-client/src/client-parsers.ts delete mode 100644 vendor/bytelyst/ollama-client/src/client.test.ts delete mode 100644 vendor/bytelyst/ollama-client/src/client.ts delete mode 100644 vendor/bytelyst/ollama-client/src/config.test.ts delete mode 100644 vendor/bytelyst/ollama-client/src/config.ts delete mode 100644 vendor/bytelyst/ollama-client/src/embed.test.ts delete mode 100644 vendor/bytelyst/ollama-client/src/embed.ts delete mode 100644 vendor/bytelyst/ollama-client/src/format.test.ts delete mode 100644 vendor/bytelyst/ollama-client/src/format.ts delete mode 100644 vendor/bytelyst/ollama-client/src/health.test.ts delete mode 100644 vendor/bytelyst/ollama-client/src/health.ts delete mode 100644 vendor/bytelyst/ollama-client/src/index.ts delete mode 100644 vendor/bytelyst/ollama-client/src/ndjson.test.ts delete mode 100644 vendor/bytelyst/ollama-client/src/ndjson.ts delete mode 100644 vendor/bytelyst/ollama-client/src/stream.test.ts delete mode 100644 vendor/bytelyst/ollama-client/src/stream.ts delete mode 100644 vendor/bytelyst/ollama-client/src/types.ts delete mode 100644 vendor/bytelyst/ollama-client/tsconfig.json delete mode 100644 vendor/bytelyst/ollama-client/vitest.config.ts delete mode 100644 vendor/bytelyst/org-client/package.json delete mode 100644 vendor/bytelyst/org-client/src/client.test.ts delete mode 100644 vendor/bytelyst/org-client/src/client.ts delete mode 100644 vendor/bytelyst/org-client/src/index.ts delete mode 100644 vendor/bytelyst/org-client/src/types.ts delete mode 100644 vendor/bytelyst/org-client/tsconfig.json delete mode 100644 vendor/bytelyst/palace/package.json delete mode 100644 vendor/bytelyst/palace/src/__tests__/cosine.test.ts delete mode 100644 vendor/bytelyst/palace/src/__tests__/decay.test.ts delete mode 100644 vendor/bytelyst/palace/src/__tests__/dedup.test.ts delete mode 100644 vendor/bytelyst/palace/src/__tests__/extraction.test.ts delete mode 100644 vendor/bytelyst/palace/src/__tests__/halls.test.ts delete mode 100644 vendor/bytelyst/palace/src/__tests__/kg.test.ts delete mode 100644 vendor/bytelyst/palace/src/__tests__/wakeup.test.ts delete mode 100644 vendor/bytelyst/palace/src/config.ts delete mode 100644 vendor/bytelyst/palace/src/cosine.ts delete mode 100644 vendor/bytelyst/palace/src/decay.ts delete mode 100644 vendor/bytelyst/palace/src/dedup.ts delete mode 100644 vendor/bytelyst/palace/src/extraction.ts delete mode 100644 vendor/bytelyst/palace/src/halls.ts delete mode 100644 vendor/bytelyst/palace/src/index.ts delete mode 100644 vendor/bytelyst/palace/src/kg.ts delete mode 100644 vendor/bytelyst/palace/src/types.ts delete mode 100644 vendor/bytelyst/palace/src/wakeup.ts delete mode 100644 vendor/bytelyst/palace/tsconfig.json delete mode 100644 vendor/bytelyst/platform-client/package.json delete mode 100644 vendor/bytelyst/platform-client/src/index.test.ts delete mode 100644 vendor/bytelyst/platform-client/src/index.ts delete mode 100644 vendor/bytelyst/platform-client/tsconfig.json delete mode 100644 vendor/bytelyst/push/package.json delete mode 100644 vendor/bytelyst/push/src/__tests__/push.test.ts delete mode 100644 vendor/bytelyst/push/src/factory.ts delete mode 100644 vendor/bytelyst/push/src/index.ts delete mode 100644 vendor/bytelyst/push/src/providers/expo.ts delete mode 100644 vendor/bytelyst/push/src/providers/mock.ts delete mode 100644 vendor/bytelyst/push/src/testing.ts delete mode 100644 vendor/bytelyst/push/src/types.ts delete mode 100644 vendor/bytelyst/push/tsconfig.json delete mode 100644 vendor/bytelyst/queue/package.json delete mode 100644 vendor/bytelyst/queue/src/file-store.ts delete mode 100644 vendor/bytelyst/queue/src/index.ts delete mode 100644 vendor/bytelyst/queue/src/memory-store.ts delete mode 100644 vendor/bytelyst/queue/src/queue.test.ts delete mode 100644 vendor/bytelyst/queue/src/types.ts delete mode 100644 vendor/bytelyst/queue/src/worker.ts delete mode 100644 vendor/bytelyst/queue/tsconfig.json delete mode 100644 vendor/bytelyst/quick-actions/package.json delete mode 100644 vendor/bytelyst/quick-actions/src/client.test.ts delete mode 100644 vendor/bytelyst/quick-actions/src/client.ts delete mode 100644 vendor/bytelyst/quick-actions/src/index.ts delete mode 100644 vendor/bytelyst/quick-actions/src/types.ts delete mode 100644 vendor/bytelyst/quick-actions/tsconfig.json delete mode 100644 vendor/bytelyst/react-auth/package.json delete mode 100644 vendor/bytelyst/react-auth/src/__tests__/react-auth.test.tsx delete mode 100644 vendor/bytelyst/react-auth/src/__tests__/smartauth.test.tsx delete mode 100644 vendor/bytelyst/react-auth/src/auth-context.tsx delete mode 100644 vendor/bytelyst/react-auth/src/index.ts delete mode 100644 vendor/bytelyst/react-auth/src/types.ts delete mode 100644 vendor/bytelyst/react-auth/tsconfig.json delete mode 100644 vendor/bytelyst/react-auth/vitest.config.ts delete mode 100644 vendor/bytelyst/react-native-platform-sdk/node-compile-cache/v22.22.0-x64-9de703df-0/2f014b13 delete mode 100644 vendor/bytelyst/react-native-platform-sdk/node-compile-cache/v22.22.0-x64-9de703df-0/b0598a2c delete mode 100644 vendor/bytelyst/react-native-platform-sdk/package.json delete mode 100644 vendor/bytelyst/react-native-platform-sdk/src/auth.ts delete mode 100644 vendor/bytelyst/react-native-platform-sdk/src/auth/index.ts delete mode 100644 vendor/bytelyst/react-native-platform-sdk/src/broadcasts.ts delete mode 100644 vendor/bytelyst/react-native-platform-sdk/src/broadcasts/index.ts delete mode 100644 vendor/bytelyst/react-native-platform-sdk/src/core.ts delete mode 100644 vendor/bytelyst/react-native-platform-sdk/src/feature-flags.ts delete mode 100644 vendor/bytelyst/react-native-platform-sdk/src/feature-flags/index.ts delete mode 100644 vendor/bytelyst/react-native-platform-sdk/src/index.ts delete mode 100644 vendor/bytelyst/react-native-platform-sdk/src/kill-switch.ts delete mode 100644 vendor/bytelyst/react-native-platform-sdk/src/kill-switch/index.ts delete mode 100644 vendor/bytelyst/react-native-platform-sdk/src/surveys.ts delete mode 100644 vendor/bytelyst/react-native-platform-sdk/src/surveys/index.ts delete mode 100644 vendor/bytelyst/react-native-platform-sdk/src/telemetry.ts delete mode 100644 vendor/bytelyst/react-native-platform-sdk/src/telemetry/index.ts delete mode 100644 vendor/bytelyst/react-native-platform-sdk/tsconfig.json delete mode 100644 vendor/bytelyst/referral-client/package.json delete mode 100644 vendor/bytelyst/referral-client/src/client.test.ts delete mode 100644 vendor/bytelyst/referral-client/src/client.ts delete mode 100644 vendor/bytelyst/referral-client/src/index.ts delete mode 100644 vendor/bytelyst/referral-client/src/types.ts delete mode 100644 vendor/bytelyst/referral-client/tsconfig.json delete mode 100644 vendor/bytelyst/secure-storage-web/package.json delete mode 100644 vendor/bytelyst/secure-storage-web/src/index.ts delete mode 100644 vendor/bytelyst/secure-storage-web/src/secure-storage.test.ts delete mode 100644 vendor/bytelyst/secure-storage-web/src/secure-storage.ts delete mode 100644 vendor/bytelyst/secure-storage-web/tsconfig.json delete mode 100644 vendor/bytelyst/speech/package.json delete mode 100644 vendor/bytelyst/speech/src/__tests__/speech.test.ts delete mode 100644 vendor/bytelyst/speech/src/factory.ts delete mode 100644 vendor/bytelyst/speech/src/index.ts delete mode 100644 vendor/bytelyst/speech/src/providers/mock.ts delete mode 100644 vendor/bytelyst/speech/src/types.ts delete mode 100644 vendor/bytelyst/speech/tsconfig.json delete mode 100644 vendor/bytelyst/storage/package.json delete mode 100644 vendor/bytelyst/storage/src/__tests__/memory-storage.test.ts delete mode 100644 vendor/bytelyst/storage/src/factory.ts delete mode 100644 vendor/bytelyst/storage/src/index.ts delete mode 100644 vendor/bytelyst/storage/src/providers/azure-blob.ts delete mode 100644 vendor/bytelyst/storage/src/providers/memory.ts delete mode 100644 vendor/bytelyst/storage/src/testing.ts delete mode 100644 vendor/bytelyst/storage/src/types.ts delete mode 100644 vendor/bytelyst/storage/tsconfig.json delete mode 100644 vendor/bytelyst/subscription-client/package.json delete mode 100644 vendor/bytelyst/subscription-client/src/client.test.ts delete mode 100644 vendor/bytelyst/subscription-client/src/client.ts delete mode 100644 vendor/bytelyst/subscription-client/src/index.ts delete mode 100644 vendor/bytelyst/subscription-client/src/types.ts delete mode 100644 vendor/bytelyst/subscription-client/tsconfig.json delete mode 100644 vendor/bytelyst/survey-client/README.md delete mode 100644 vendor/bytelyst/survey-client/package.json delete mode 100644 vendor/bytelyst/survey-client/src/index.ts delete mode 100644 vendor/bytelyst/survey-client/tsconfig.json delete mode 100644 vendor/bytelyst/swift-diagnostics/Package.swift delete mode 100644 vendor/bytelyst/swift-diagnostics/Sources/ByteLystDiagnostics/ByteLystDiagnostics.swift delete mode 100644 vendor/bytelyst/swift-diagnostics/Sources/ByteLystDiagnostics/Core/BreadcrumbTrail.swift delete mode 100644 vendor/bytelyst/swift-diagnostics/Sources/ByteLystDiagnostics/Core/Configuration.swift delete mode 100644 vendor/bytelyst/swift-diagnostics/Sources/ByteLystDiagnostics/Core/DiagnosticsClient.swift delete mode 100644 vendor/bytelyst/swift-diagnostics/Sources/ByteLystDiagnostics/Core/Types.swift delete mode 100644 vendor/bytelyst/swift-diagnostics/Sources/ByteLystDiagnostics/Device/DeviceState.swift delete mode 100644 vendor/bytelyst/swift-diagnostics/Sources/ByteLystDiagnostics/Network/NetworkInterceptor.swift delete mode 100644 vendor/bytelyst/swift-diagnostics/Tests/ByteLystDiagnosticsTests/DiagnosticsClientTests.swift delete mode 100644 vendor/bytelyst/swift-platform-sdk/Package.swift delete mode 100644 vendor/bytelyst/swift-platform-sdk/README.md delete mode 100644 vendor/bytelyst/swift-platform-sdk/Sources/BLAuditLogger.swift delete mode 100644 vendor/bytelyst/swift-platform-sdk/Sources/BLAuthClient.swift delete mode 100644 vendor/bytelyst/swift-platform-sdk/Sources/BLAuthUI.swift delete mode 100644 vendor/bytelyst/swift-platform-sdk/Sources/BLBiometricAuth.swift delete mode 100644 vendor/bytelyst/swift-platform-sdk/Sources/BLBlobClient.swift delete mode 100644 vendor/bytelyst/swift-platform-sdk/Sources/BLBroadcastClient.swift delete mode 100644 vendor/bytelyst/swift-platform-sdk/Sources/BLCrashReporter.swift delete mode 100644 vendor/bytelyst/swift-platform-sdk/Sources/BLDeepLinkRouter.swift delete mode 100644 vendor/bytelyst/swift-platform-sdk/Sources/BLFeatureFlagClient.swift delete mode 100644 vendor/bytelyst/swift-platform-sdk/Sources/BLFeedbackClient.swift delete mode 100644 vendor/bytelyst/swift-platform-sdk/Sources/BLFieldEncrypt.swift delete mode 100644 vendor/bytelyst/swift-platform-sdk/Sources/BLInAppMessageUI.swift delete mode 100644 vendor/bytelyst/swift-platform-sdk/Sources/BLKeychain.swift delete mode 100644 vendor/bytelyst/swift-platform-sdk/Sources/BLKillSwitchClient.swift delete mode 100644 vendor/bytelyst/swift-platform-sdk/Sources/BLLicenseClient.swift delete mode 100644 vendor/bytelyst/swift-platform-sdk/Sources/BLPlatformClient.swift delete mode 100644 vendor/bytelyst/swift-platform-sdk/Sources/BLPlatformConfig.swift delete mode 100644 vendor/bytelyst/swift-platform-sdk/Sources/BLSurveyClient.swift delete mode 100644 vendor/bytelyst/swift-platform-sdk/Sources/BLSurveyUI.swift delete mode 100644 vendor/bytelyst/swift-platform-sdk/Sources/BLSyncEngine.swift delete mode 100644 vendor/bytelyst/swift-platform-sdk/Sources/BLTelemetryClient.swift delete mode 100644 vendor/bytelyst/swift-platform-sdk/Sources/ByteLystPlatform.swift delete mode 100644 vendor/bytelyst/swift-platform-sdk/Sources/ByteLystPlatformSDK.swift delete mode 100644 vendor/bytelyst/swift-platform-sdk/Tests/BLAuthClientSmartAuthTests.swift delete mode 100644 vendor/bytelyst/swift-platform-sdk/Tests/BLFeatureFlagClientTests.swift delete mode 100644 vendor/bytelyst/swift-platform-sdk/Tests/BLFieldEncryptTests.swift delete mode 100644 vendor/bytelyst/swift-platform-sdk/Tests/BLKeychainTests.swift delete mode 100644 vendor/bytelyst/swift-platform-sdk/Tests/BLKillSwitchClientTests.swift delete mode 100644 vendor/bytelyst/swift-platform-sdk/Tests/BLPlatformConfigTests.swift delete mode 100644 vendor/bytelyst/swift-platform-sdk/Tests/BLTelemetryClientTests.swift delete mode 100644 vendor/bytelyst/swift-platform-sdk/Tests/ByteLystPlatformTests.swift delete mode 100644 vendor/bytelyst/sync/package.json delete mode 100644 vendor/bytelyst/sync/src/engine.ts delete mode 100644 vendor/bytelyst/sync/src/index.ts delete mode 100644 vendor/bytelyst/sync/src/storage.ts delete mode 100644 vendor/bytelyst/sync/src/sync.test.ts delete mode 100644 vendor/bytelyst/sync/src/types.ts delete mode 100644 vendor/bytelyst/sync/tsconfig.json delete mode 100644 vendor/bytelyst/sync/vitest.config.ts delete mode 100644 vendor/bytelyst/telemetry-client/package.json delete mode 100644 vendor/bytelyst/telemetry-client/src/__tests__/telemetry-client.test.ts delete mode 100644 vendor/bytelyst/telemetry-client/src/client.ts delete mode 100644 vendor/bytelyst/telemetry-client/src/index.ts delete mode 100644 vendor/bytelyst/telemetry-client/src/types.ts delete mode 100644 vendor/bytelyst/telemetry-client/src/web.ts delete mode 100644 vendor/bytelyst/telemetry-client/tsconfig.json delete mode 100644 vendor/bytelyst/testing/package.json delete mode 100644 vendor/bytelyst/testing/src/__tests__/testing.test.ts delete mode 100644 vendor/bytelyst/testing/src/auth-fixtures.ts delete mode 100644 vendor/bytelyst/testing/src/cosmos-mocks.ts delete mode 100644 vendor/bytelyst/testing/src/fastify-helpers.ts delete mode 100644 vendor/bytelyst/testing/src/index.ts delete mode 100644 vendor/bytelyst/testing/tsconfig.json delete mode 100644 vendor/bytelyst/time-references/package.json delete mode 100644 vendor/bytelyst/time-references/src/client.test.ts delete mode 100644 vendor/bytelyst/time-references/src/client.ts delete mode 100644 vendor/bytelyst/time-references/src/index.ts delete mode 100644 vendor/bytelyst/time-references/src/types.ts delete mode 100644 vendor/bytelyst/time-references/tsconfig.json delete mode 100644 vendor/bytelyst/ui/.storybook/main.ts delete mode 100644 vendor/bytelyst/ui/.storybook/preview.ts delete mode 100644 vendor/bytelyst/ui/README.md delete mode 100644 vendor/bytelyst/ui/eslint.config.js delete mode 100644 vendor/bytelyst/ui/package.json delete mode 100644 vendor/bytelyst/ui/src/components/AppShell.tsx delete mode 100644 vendor/bytelyst/ui/src/components/Badge.stories.tsx delete mode 100644 vendor/bytelyst/ui/src/components/Badge.tsx delete mode 100644 vendor/bytelyst/ui/src/components/Button.stories.tsx delete mode 100644 vendor/bytelyst/ui/src/components/Button.tsx delete mode 100644 vendor/bytelyst/ui/src/components/Card.stories.tsx delete mode 100644 vendor/bytelyst/ui/src/components/Card.tsx delete mode 100644 vendor/bytelyst/ui/src/components/Checkbox.tsx delete mode 100644 vendor/bytelyst/ui/src/components/ConfirmDialog.tsx delete mode 100644 vendor/bytelyst/ui/src/components/Controls.stories.tsx delete mode 100644 vendor/bytelyst/ui/src/components/DataList.tsx delete mode 100644 vendor/bytelyst/ui/src/components/DataTable.tsx delete mode 100644 vendor/bytelyst/ui/src/components/DiffCard.tsx delete mode 100644 vendor/bytelyst/ui/src/components/DropdownMenu.tsx delete mode 100644 vendor/bytelyst/ui/src/components/EmptyState.tsx delete mode 100644 vendor/bytelyst/ui/src/components/IconButton.tsx delete mode 100644 vendor/bytelyst/ui/src/components/Input.stories.tsx delete mode 100644 vendor/bytelyst/ui/src/components/Input.tsx delete mode 100644 vendor/bytelyst/ui/src/components/Label.tsx delete mode 100644 vendor/bytelyst/ui/src/components/ListItemButton.tsx delete mode 100644 vendor/bytelyst/ui/src/components/LoadingSpinner.tsx delete mode 100644 vendor/bytelyst/ui/src/components/Modal.tsx delete mode 100644 vendor/bytelyst/ui/src/components/OperationalPrimitives.stories.tsx delete mode 100644 vendor/bytelyst/ui/src/components/Panel.tsx delete mode 100644 vendor/bytelyst/ui/src/components/RadioGroup.tsx delete mode 100644 vendor/bytelyst/ui/src/components/SegmentedControl.tsx delete mode 100644 vendor/bytelyst/ui/src/components/Select.tsx delete mode 100644 vendor/bytelyst/ui/src/components/Separator.tsx delete mode 100644 vendor/bytelyst/ui/src/components/Sidebar.tsx delete mode 100644 vendor/bytelyst/ui/src/components/StatCard.tsx delete mode 100644 vendor/bytelyst/ui/src/components/StatusBadge.tsx delete mode 100644 vendor/bytelyst/ui/src/components/Surface.tsx delete mode 100644 vendor/bytelyst/ui/src/components/Switch.tsx delete mode 100644 vendor/bytelyst/ui/src/components/Tabs.tsx delete mode 100644 vendor/bytelyst/ui/src/components/Textarea.tsx delete mode 100644 vendor/bytelyst/ui/src/components/Timeline.tsx delete mode 100644 vendor/bytelyst/ui/src/components/Toast.tsx delete mode 100644 vendor/bytelyst/ui/src/components/Tooltip.tsx delete mode 100644 vendor/bytelyst/ui/src/index.ts delete mode 100644 vendor/bytelyst/ui/tsconfig.json delete mode 100644 vendor/bytelyst/use-keyboard-shortcuts/package.json delete mode 100644 vendor/bytelyst/use-keyboard-shortcuts/src/__tests__/use-keyboard-shortcuts.test.ts delete mode 100644 vendor/bytelyst/use-keyboard-shortcuts/src/index.ts delete mode 100644 vendor/bytelyst/use-keyboard-shortcuts/src/use-keyboard-shortcuts.ts delete mode 100644 vendor/bytelyst/use-keyboard-shortcuts/tsconfig.json delete mode 100644 vendor/bytelyst/use-theme/package.json delete mode 100644 vendor/bytelyst/use-theme/src/__tests__/use-theme.test.ts delete mode 100644 vendor/bytelyst/use-theme/src/index.ts delete mode 100644 vendor/bytelyst/use-theme/src/use-theme.ts delete mode 100644 vendor/bytelyst/use-theme/tsconfig.json delete mode 100644 vendor/bytelyst/webhook-dispatch/package.json delete mode 100644 vendor/bytelyst/webhook-dispatch/src/dispatcher.test.ts delete mode 100644 vendor/bytelyst/webhook-dispatch/src/dispatcher.ts delete mode 100644 vendor/bytelyst/webhook-dispatch/src/index.ts delete mode 100644 vendor/bytelyst/webhook-dispatch/src/types.ts delete mode 100644 vendor/bytelyst/webhook-dispatch/tsconfig.json create mode 100644 web/src/tabs/DevOpsTab.tsx diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..64d3585 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +node_modules +.git +mobile +ios +android +web/node_modules +web/dist +web/.next \ No newline at end of file diff --git a/.npmrc.docker b/.npmrc.docker index 1bc9e12..6ddafb5 100644 --- a/.npmrc.docker +++ b/.npmrc.docker @@ -1 +1 @@ -@bytelyst:registry=http://gitea.bytelyst.com:3300/api/packages/bytelyst/npm/ \ No newline at end of file +@bytelyst:registry=http://localhost:3300/api/packages/bytelyst/npm/ diff --git a/backend/Dockerfile b/backend/Dockerfile index 05e9527..7f78838 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,33 +1,48 @@ FROM node:20-alpine AS builder -WORKDIR /app/backend +WORKDIR /app RUN corepack enable && corepack prepare pnpm@10.6.5 --activate # Use Gitea npm registry for @bytelyst/* packages -COPY .npmrc.docker ./.npmrc COPY backend/package.json ./package.json RUN --mount=type=secret,id=gitea_npm_token \ TOKEN=$(cat /run/secrets/gitea_npm_token) && \ - echo "//gitea.bytelyst.com:3300/:_authToken=$TOKEN" >> .npmrc && \ + printf '@bytelyst:registry=http://localhost:3300/api/packages/bytelyst/npm/\n//localhost:3300/api/packages/bytelyst/npm/:_authToken=%s\nlink-workspace-packages=false\nshared-workspace-lockfile=false\n' "$TOKEN" > .npmrc && \ pnpm install --ignore-scripts --lockfile=false -COPY backend/tsconfig.json ./tsconfig.json -COPY backend/src/ ./src/ -COPY shared/ ../shared/ -RUN pnpm run build +COPY backend/tsconfig.json ./backend/tsconfig.json +COPY backend/src/ ./backend/src/ +COPY shared/ ./shared/ +RUN cd /app/backend && ../node_modules/.bin/tsc -p tsconfig.json FROM node:20-alpine WORKDIR /app/backend ENV NODE_ENV=production -COPY --from=builder /app/backend/node_modules ./node_modules -COPY --from=builder /app/backend/package.json ./package.json +# Build metadata baked at image build time (consumed by @bytelyst/devops) +ARG BYTELYST_COMMIT_SHA=unknown +ARG BYTELYST_COMMIT_SHA_FULL=unknown +ARG BYTELYST_BRANCH=unknown +ARG BYTELYST_BUILT_AT=unknown +ARG BYTELYST_COMMIT_AUTHOR=unknown +ARG BYTELYST_COMMIT_MESSAGE=unknown +ARG BYTELYST_DOCKER_IMAGE=invttrdg-backend:latest +ENV BYTELYST_COMMIT_SHA=${BYTELYST_COMMIT_SHA} \ + BYTELYST_COMMIT_SHA_FULL=${BYTELYST_COMMIT_SHA_FULL} \ + BYTELYST_BRANCH=${BYTELYST_BRANCH} \ + BYTELYST_BUILT_AT=${BYTELYST_BUILT_AT} \ + BYTELYST_COMMIT_AUTHOR=${BYTELYST_COMMIT_AUTHOR} \ + BYTELYST_COMMIT_MESSAGE=${BYTELYST_COMMIT_MESSAGE} \ + BYTELYST_DOCKER_IMAGE=${BYTELYST_DOCKER_IMAGE} + +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/package.json ./package.json COPY --from=builder /app/backend/dist ./dist -COPY shared/ ../shared/ +COPY --from=builder /app/shared/ ../shared/ RUN chown -R node:node /app USER node EXPOSE 4018 -CMD ["node", "dist/backend/src/bootstrap.js"] \ No newline at end of file +CMD ["node", "dist/backend/src/bootstrap.js"] diff --git a/backend/package.json b/backend/package.json index 8669fe5..c8070a3 100644 --- a/backend/package.json +++ b/backend/package.json @@ -75,7 +75,9 @@ "jose": "^6.1.2", "prom-client": "^15.1.3", "socket.io": "^4.8.3", - "winston": "^3.19.0" + "winston": "^3.19.0", + "@bytelyst/telemetry-client": "*", + "@bytelyst/devops": "^0.1.1" }, "devDependencies": { "@types/node": "^25.0.3", diff --git a/backend/src/services/apiServer.ts b/backend/src/services/apiServer.ts index c367c03..9dc99c6 100644 --- a/backend/src/services/apiServer.ts +++ b/backend/src/services/apiServer.ts @@ -3,6 +3,7 @@ import { createServer } from 'http'; import { Server, Socket } from 'socket.io'; import cors from 'cors'; import { randomUUID } from 'node:crypto'; +import { collectDevopsInfo } from '@bytelyst/devops/server'; import logger from '../utils/logger.js'; import fs from 'fs'; import path from 'path'; @@ -2364,6 +2365,20 @@ export class ApiServer { res.json(flags); }); + // ── DevOps info: build, runtime, config (auth required) ────────────── + this.app.get('/api/devops/info', this.requireAuth, async (_req, res) => { + try { + const info = await collectDevopsInfo({ + productId: config.PRODUCT_ID || 'invttrdg', + serviceName: 'trading-backend', + serviceVersion: process.env.npm_package_version || '0.1.0', + }); + res.json(info); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + }); + this.app.get('/api/me/profile', this.requireAuth, async (req, res) => { const authReq = req as AuthenticatedRequest; const authUserId = authReq.authUserId; @@ -2381,6 +2396,11 @@ export class ApiServer { trade_enable: true, }); + // JWT role is authoritative — prevent drift between platform users and trading_users. + if (authReq.authRole) { + profile.role = authReq.authRole; + } + res.json({ profile }); }); @@ -2394,13 +2414,19 @@ export class ApiServer { const displayNameParts = String(authReq.authDisplayName || '').trim().split(/\s+/).filter(Boolean); try { - const profile = await saveCurrentUserProfile(authUserId, req.body || {}, { + // Strip role from client-supplied patch — role comes from JWT only. + const { role: _ignoredRole, ...patchBody } = (req.body || {}) as Record; + const profile = await saveCurrentUserProfile(authUserId, patchBody, { email: authReq.authEmail, role: authReq.authRole, first_name: displayNameParts[0] || '', last_name: displayNameParts.slice(1).join(' '), trade_enable: true, }); + // JWT role is authoritative. + if (authReq.authRole) { + profile.role = authReq.authRole; + } res.json({ profile }); } catch (error: any) { res.status(400).json({ error: `Failed to update profile: ${error.message}` }); diff --git a/vendor/bytelyst/accessibility/package.json b/vendor/bytelyst/accessibility/package.json deleted file mode 100644 index 365bed6..0000000 --- a/vendor/bytelyst/accessibility/package.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "@bytelyst/accessibility", - "version": "0.1.5", - "type": "module", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "scripts": { - "build": "tsc" - }, - "dependencies": {}, - "devDependencies": { - "typescript": "^5.7.3" - } -} diff --git a/vendor/bytelyst/accessibility/src/index.ts b/vendor/bytelyst/accessibility/src/index.ts deleted file mode 100644 index 1d760ab..0000000 --- a/vendor/bytelyst/accessibility/src/index.ts +++ /dev/null @@ -1,204 +0,0 @@ -export type AlertA11yProps = { - role: 'alert'; - 'aria-live': 'assertive' | 'polite'; - 'aria-label': string; -}; - -export function alertLabel(level: string, description: string): AlertA11yProps { - return { - role: 'alert', - 'aria-live': level === 'danger' ? 'assertive' : 'polite', - 'aria-label': description, - }; -} - -const FOCUSABLE_SELECTOR = [ - 'a[href]', - 'button:not([disabled])', - 'input:not([disabled])', - 'select:not([disabled])', - 'textarea:not([disabled])', - '[tabindex]:not([tabindex="-1"])', -].join(','); - -export function getFocusableElements(container: HTMLElement): HTMLElement[] { - return Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR)).filter( - element => - !element.hasAttribute('disabled') && - element.getAttribute('aria-hidden') !== 'true' && - element.offsetParent !== null - ); -} - -export function trapFocusKeydown(event: KeyboardEvent, container: HTMLElement): void { - if (event.key !== 'Tab') return; - - const focusable = getFocusableElements(container); - if (focusable.length === 0) { - event.preventDefault(); - return; - } - - const first = focusable[0]; - const last = focusable[focusable.length - 1]; - - if (event.shiftKey && document.activeElement === first) { - event.preventDefault(); - last.focus(); - } else if (!event.shiftKey && document.activeElement === last) { - event.preventDefault(); - first.focus(); - } -} - -export function focusFirstElement(container: HTMLElement, selector = FOCUSABLE_SELECTOR): void { - const preferred = container.querySelector(selector); - const fallback = getFocusableElements(container)[0]; - (preferred ?? fallback)?.focus(); -} - -export type ScreenReaderPoliteness = 'assertive' | 'polite'; - -export function announceToScreenReader( - message: string, - politeness: ScreenReaderPoliteness = 'polite' -): void { - if (typeof document === 'undefined') return; - - const id = `bytelyst-sr-announcer-${politeness}`; - let announcer = document.getElementById(id); - if (!announcer) { - announcer = document.createElement('div'); - announcer.id = id; - announcer.setAttribute('aria-live', politeness); - announcer.setAttribute('aria-atomic', 'true'); - announcer.style.position = 'absolute'; - announcer.style.width = '1px'; - announcer.style.height = '1px'; - announcer.style.margin = '-1px'; - announcer.style.padding = '0'; - announcer.style.overflow = 'hidden'; - announcer.style.clip = 'rect(0 0 0 0)'; - announcer.style.whiteSpace = 'nowrap'; - announcer.style.border = '0'; - document.body.appendChild(announcer); - } - - announcer.textContent = ''; - window.setTimeout(() => { - if (announcer) { - announcer.textContent = message; - } - }, 10); -} - -export type ProgressbarA11yProps = { - role: 'progressbar'; - 'aria-label': string; - 'aria-valuenow': number; - 'aria-valuemin': number; - 'aria-valuemax': number; - 'aria-valuetext': string; -}; - -export function progressLabel( - label: string, - valuePct: number, - description: string -): ProgressbarA11yProps { - return { - role: 'progressbar', - 'aria-label': label, - 'aria-valuenow': valuePct, - 'aria-valuemin': 0, - 'aria-valuemax': 100, - 'aria-valuetext': description, - }; -} - -export type AriaLabelOnly = { 'aria-label': string }; - -export function streakLabel(days: number): AriaLabelOnly { - return { 'aria-label': `${days} day streak` }; -} - -export type ButtonA11yProps = { - 'aria-label': string; - 'aria-roledescription'?: string; -}; - -export function buttonLabel(text: string, hint?: string): ButtonA11yProps { - return { - 'aria-label': text, - ...(hint ? { 'aria-roledescription': hint } : {}), - }; -} - -export function achievementLabel(name: string, description: string): AriaLabelOnly { - return { 'aria-label': `Achievement: ${name} — ${description}` }; -} - -export type TimerA11yProps = { - 'aria-label': string; - 'aria-live': 'polite'; -}; - -export function timerLabel( - hours: number, - minutes: number, - seconds: number, - status: string -): TimerA11yProps { - return { - 'aria-label': `Timer: ${hours}h ${minutes}m ${seconds}s, ${status}`, - 'aria-live': 'polite', - }; -} - -export type SliderA11yProps = { - role: 'slider'; - 'aria-label': string; - 'aria-valuenow': number; - 'aria-valuemin': number; - 'aria-valuemax': number; -}; - -export function sliderLabel( - label: string, - value: number, - min: number, - max: number -): SliderA11yProps { - return { - role: 'slider', - 'aria-label': label, - 'aria-valuenow': value, - 'aria-valuemin': min, - 'aria-valuemax': max, - }; -} - -function plural(n: number, singular: string, pluralForm: string): string { - const word = n === 1 ? singular : pluralForm; - return `${n} ${word}`; -} - -/** - * Spoken-friendly duration from a fractional hour value, e.g. 12 → "12 hours", 1.5 → "1 hour 30 minutes". - */ -export function formatDurationForA11y(hours: number): string { - const totalMinutes = Math.round(hours * 60); - const h = Math.floor(totalMinutes / 60); - const m = totalMinutes % 60; - - if (h === 0 && m === 0) { - return '0 minutes'; - } - if (m === 0) { - return plural(h, 'hour', 'hours'); - } - if (h === 0) { - return plural(m, 'minute', 'minutes'); - } - return `${plural(h, 'hour', 'hours')} ${plural(m, 'minute', 'minutes')}`; -} diff --git a/vendor/bytelyst/accessibility/tsconfig.json b/vendor/bytelyst/accessibility/tsconfig.json deleted file mode 100644 index 5626e4b..0000000 --- a/vendor/bytelyst/accessibility/tsconfig.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "lib": ["ES2022", "DOM"], - "strict": true, - "skipLibCheck": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "outDir": "dist", - "rootDir": "src", - "declaration": true, - "declarationMap": true, - "sourceMap": true - }, - "include": ["src/**/*.ts"] -} diff --git a/vendor/bytelyst/api-client/package.json b/vendor/bytelyst/api-client/package.json deleted file mode 100644 index 0261f1f..0000000 --- a/vendor/bytelyst/api-client/package.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "name": "@bytelyst/api-client", - "version": "0.1.5", - "type": "module", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/api-client/src/__tests__/api-client.test.ts b/vendor/bytelyst/api-client/src/__tests__/api-client.test.ts deleted file mode 100644 index 1e14e65..0000000 --- a/vendor/bytelyst/api-client/src/__tests__/api-client.test.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { describe, expect, it, vi, beforeEach } from 'vitest'; -import { createApiClient } from '../index.js'; - -// Mock globalThis.fetch -const mockFetch = vi.fn(); -globalThis.fetch = mockFetch; - -function jsonResponse(data: unknown, status = 200) { - return { - ok: status >= 200 && status < 300, - status, - statusText: status === 200 ? 'OK' : 'Error', - json: () => Promise.resolve(data), - }; -} - -describe('createApiClient', () => { - beforeEach(() => { - mockFetch.mockReset(); - }); - - it('returns an object with fetch and safeFetch', () => { - const api = createApiClient({ baseUrl: 'http://localhost:4003' }); - expect(typeof api.fetch).toBe('function'); - expect(typeof api.safeFetch).toBe('function'); - }); - - describe('fetch', () => { - it('calls correct URL with base + path', async () => { - mockFetch.mockResolvedValue(jsonResponse({ users: [] })); - const api = createApiClient({ baseUrl: 'http://localhost:4003/api' }); - - await api.fetch('/users'); - - expect(mockFetch).toHaveBeenCalledWith( - 'http://localhost:4003/api/users', - expect.objectContaining({ - headers: expect.objectContaining({ 'Content-Type': 'application/json' }), - }) - ); - }); - - it('returns parsed JSON on success', async () => { - mockFetch.mockResolvedValue(jsonResponse({ id: '1', name: 'Test' })); - const api = createApiClient({ baseUrl: '/api' }); - - const result = await api.fetch<{ id: string; name: string }>('/users/1'); - expect(result).toEqual({ id: '1', name: 'Test' }); - }); - - it('throws on HTTP error', async () => { - mockFetch.mockResolvedValue(jsonResponse({ error: 'Not found' }, 404)); - const api = createApiClient({ baseUrl: '/api' }); - - await expect(api.fetch('/users/999')).rejects.toThrow('Not found'); - }); - - it('injects auth token from getToken', async () => { - mockFetch.mockResolvedValue(jsonResponse({ ok: true })); - const api = createApiClient({ - baseUrl: '/api', - getToken: () => 'my-jwt-token', - }); - - await api.fetch('/protected'); - - expect(mockFetch).toHaveBeenCalledWith( - '/api/protected', - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: 'Bearer my-jwt-token', - }), - }) - ); - }); - - it('skips auth header when getToken returns null', async () => { - mockFetch.mockResolvedValue(jsonResponse({ ok: true })); - const api = createApiClient({ - baseUrl: '/api', - getToken: () => null, - }); - - await api.fetch('/public'); - - const headers = mockFetch.mock.calls[0][1].headers as Record; - expect(headers.Authorization).toBeUndefined(); - }); - - it('merges defaultHeaders', async () => { - mockFetch.mockResolvedValue(jsonResponse({ ok: true })); - const api = createApiClient({ - baseUrl: '/api', - defaultHeaders: { 'X-Custom': 'value' }, - }); - - await api.fetch('/test'); - - const headers = mockFetch.mock.calls[0][1].headers as Record; - expect(headers['X-Custom']).toBe('value'); - expect(headers['Content-Type']).toBe('application/json'); - }); - }); - - describe('safeFetch', () => { - it('returns { data, error: null } on success', async () => { - mockFetch.mockResolvedValue(jsonResponse({ id: '1' })); - const api = createApiClient({ baseUrl: '/api' }); - - const result = await api.safeFetch<{ id: string }>('/items/1'); - expect(result.data).toEqual({ id: '1' }); - expect(result.error).toBeNull(); - }); - - it('returns { data: null, error } on HTTP error', async () => { - mockFetch.mockResolvedValue(jsonResponse({ error: 'Forbidden' }, 403)); - const api = createApiClient({ baseUrl: '/api' }); - - const result = await api.safeFetch('/secret'); - expect(result.data).toBeNull(); - expect(result.error).toBe('Forbidden'); - }); - - it('returns { data: null, error } on network error', async () => { - mockFetch.mockRejectedValue(new Error('Network error')); - const api = createApiClient({ baseUrl: '/api' }); - - const result = await api.safeFetch('/unreachable'); - expect(result.data).toBeNull(); - expect(result.error).toBe('API unavailable'); - }); - }); -}); diff --git a/vendor/bytelyst/api-client/src/client.ts b/vendor/bytelyst/api-client/src/client.ts deleted file mode 100644 index 8230966..0000000 --- a/vendor/bytelyst/api-client/src/client.ts +++ /dev/null @@ -1,151 +0,0 @@ -/** - * Configurable API client factory. - * Creates a fetch wrapper with base URL, auth token injection, error handling, - * timeout, and retry with exponential backoff for idempotent requests. - */ - -import type { ApiClient, ApiClientConfig, ApiResult } from './types.js'; - -const IDEMPOTENT_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); - -function sleep(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)); -} - -/** - * Create an API client with a base URL and optional auth token. - * - * @example - * ```ts - * const api = createApiClient({ - * baseUrl: "/api", - * getToken: () => localStorage.getItem("access_token"), - * }); - * - * // Throws on error - * const users = await api.fetch("/users"); - * - * // Never throws - * const { data, error } = await api.safeFetch("/users"); - * ``` - */ -export function createApiClient(config: ApiClientConfig): ApiClient { - const { - baseUrl, - getToken, - defaultHeaders, - timeoutMs = 10_000, - retries = 2, - retryDelayMs = 500, - } = config; - - function buildHeaders(options?: RequestInit): HeadersInit { - const headers: Record = { - 'Content-Type': 'application/json', - 'x-request-id': - typeof globalThis.crypto?.randomUUID === 'function' - ? globalThis.crypto.randomUUID() - : `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`, - ...defaultHeaders, - }; - - if (getToken) { - const token = getToken(); - if (token) { - headers['Authorization'] = `Bearer ${token}`; - } - } - - if (options?.headers) { - const extra: Record = {}; - if (options.headers instanceof Headers) { - options.headers.forEach((value, key) => { - extra[key] = value; - }); - } else if (Array.isArray(options.headers)) { - Object.assign(extra, Object.fromEntries(options.headers)); - } else { - Object.assign(extra, options.headers); - } - Object.assign(headers, extra); - } - - return headers; - } - - function buildInit(options?: RequestInit): RequestInit { - const init: RequestInit = { - ...options, - headers: buildHeaders(options), - }; - - // AbortController timeout (skip if caller already supplies a signal or timeoutMs is 0) - if (timeoutMs > 0 && !options?.signal) { - const controller = new AbortController(); - init.signal = controller.signal; - setTimeout(() => controller.abort(), timeoutMs); - } - - return init; - } - - function isRetryable(method: string | undefined): boolean { - return IDEMPOTENT_METHODS.has((method ?? 'GET').toUpperCase()); - } - - async function fetchWithRetry(url: string, init: RequestInit): Promise { - const maxAttempts = isRetryable(init.method) ? retries + 1 : 1; - let lastError: unknown; - - for (let attempt = 0; attempt < maxAttempts; attempt++) { - try { - const res = await globalThis.fetch(url, init); - // Only retry on 502/503/504 for idempotent methods - if (res.status >= 502 && res.status <= 504 && attempt < maxAttempts - 1) { - await sleep(retryDelayMs * 2 ** attempt); - continue; - } - return res; - } catch (err) { - lastError = err; - if (attempt < maxAttempts - 1) { - await sleep(retryDelayMs * 2 ** attempt); - continue; - } - } - } - - throw lastError; - } - - return { - async fetch(path: string, options?: RequestInit): Promise { - const init = buildInit(options); - const res = await fetchWithRetry(`${baseUrl}${path}`, init); - - if (!res.ok) { - const body = await res.json().catch(() => ({ error: res.statusText })); - throw new Error(body.error || `HTTP ${res.status}`); - } - - return res.json() as Promise; - }, - - async safeFetch(path: string, options?: RequestInit): Promise> { - try { - const init = buildInit(options); - const res = await fetchWithRetry(`${baseUrl}${path}`, init); - - if (!res.ok) { - const body = await res.json().catch(() => ({ error: res.statusText })); - return { data: null, error: body.error || `HTTP ${res.status}` }; - } - - const data = (await res.json()) as T; - return { data, error: null }; - } catch { - return { data: null, error: 'API unavailable' }; - } - }, - }; -} diff --git a/vendor/bytelyst/api-client/src/index.ts b/vendor/bytelyst/api-client/src/index.ts deleted file mode 100644 index 4805981..0000000 --- a/vendor/bytelyst/api-client/src/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { createApiClient } from './client.js'; -export type { ApiClient, ApiClientConfig, ApiResult } from './types.js'; diff --git a/vendor/bytelyst/api-client/src/types.ts b/vendor/bytelyst/api-client/src/types.ts deleted file mode 100644 index a4252a6..0000000 --- a/vendor/bytelyst/api-client/src/types.ts +++ /dev/null @@ -1,28 +0,0 @@ -export interface ApiClientConfig { - baseUrl: string; - getToken?: () => string | null; - defaultHeaders?: Record; - /** Request timeout in milliseconds. Default: 10000 (10s). Set 0 to disable. */ - timeoutMs?: number; - /** Max retries for idempotent requests (GET/HEAD/OPTIONS). Default: 2. */ - retries?: number; - /** Base delay in ms for exponential backoff between retries. Default: 500. */ - retryDelayMs?: number; -} - -export interface ApiResult { - data: T | null; - error: string | null; -} - -export interface ApiClient { - /** - * Fetch that throws on error — use when caller handles errors. - */ - fetch(path: string, options?: RequestInit): Promise; - - /** - * Safe fetch that never throws — returns { data, error } tuple. - */ - safeFetch(path: string, options?: RequestInit): Promise>; -} diff --git a/vendor/bytelyst/api-client/tsconfig.json b/vendor/bytelyst/api-client/tsconfig.json deleted file mode 100644 index 318c075..0000000 --- a/vendor/bytelyst/api-client/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src", - "lib": ["ES2022", "DOM"] - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/vendor/bytelyst/auth-client/package.json b/vendor/bytelyst/auth-client/package.json deleted file mode 100644 index bc36103..0000000 --- a/vendor/bytelyst/auth-client/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "@bytelyst/auth-client", - "version": "0.1.5", - "type": "module", - "description": "Browser/React Native-safe auth API client for platform-service", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/auth-client/src/__tests__/auth-client.test.ts b/vendor/bytelyst/auth-client/src/__tests__/auth-client.test.ts deleted file mode 100644 index ac5292a..0000000 --- a/vendor/bytelyst/auth-client/src/__tests__/auth-client.test.ts +++ /dev/null @@ -1,346 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { createAuthClient } from '../client.js'; -import type { TokenStorage } from '../types.js'; - -function createMockStorage(): TokenStorage & { store: Map } { - const store = new Map(); - return { - store, - getItem: (key: string) => store.get(key) ?? null, - setItem: (key: string, value: string) => store.set(key, value), - removeItem: (key: string) => store.delete(key), - }; -} - -function mockFetchResponse(data: unknown, status = 200) { - return vi.fn().mockResolvedValue({ - ok: status >= 200 && status < 300, - status, - json: () => Promise.resolve(data), - }); -} - -describe('@bytelyst/auth-client', () => { - let storage: ReturnType; - - beforeEach(() => { - storage = createMockStorage(); - vi.restoreAllMocks(); - }); - - describe('createAuthClient', () => { - it('creates a client with all expected methods', () => { - const client = createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - storage, - }); - - expect(client.login).toBeTypeOf('function'); - expect(client.register).toBeTypeOf('function'); - expect(client.getMe).toBeTypeOf('function'); - expect(client.refreshAccessToken).toBeTypeOf('function'); - expect(client.forgotPassword).toBeTypeOf('function'); - expect(client.resetPassword).toBeTypeOf('function'); - expect(client.changePassword).toBeTypeOf('function'); - expect(client.deleteAccount).toBeTypeOf('function'); - expect(client.verifyEmail).toBeTypeOf('function'); - expect(client.resendVerification).toBeTypeOf('function'); - expect(client.getAccessToken).toBeTypeOf('function'); - expect(client.getRefreshToken).toBeTypeOf('function'); - expect(client.setTokens).toBeTypeOf('function'); - expect(client.clearTokens).toBeTypeOf('function'); - expect(client.isAuthenticated).toBeTypeOf('function'); - }); - }); - - describe('token management', () => { - it('stores and retrieves tokens', () => { - const client = createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - storage, - }); - - expect(client.isAuthenticated()).toBe(false); - expect(client.getAccessToken()).toBeNull(); - - client.setTokens('access-123', 'refresh-456'); - - expect(client.isAuthenticated()).toBe(true); - expect(client.getAccessToken()).toBe('access-123'); - expect(client.getRefreshToken()).toBe('refresh-456'); - }); - - it('clears tokens', () => { - const client = createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - storage, - }); - - client.setTokens('access-123', 'refresh-456'); - client.clearTokens(); - - expect(client.isAuthenticated()).toBe(false); - expect(client.getAccessToken()).toBeNull(); - expect(client.getRefreshToken()).toBeNull(); - }); - - it('uses productId as storage key prefix by default', () => { - createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'chronomind', - storage, - }).setTokens('a', 'b'); - - expect(storage.store.get('chronomind-auth-token')).toBe('a'); - expect(storage.store.get('chronomind-refresh-token')).toBe('b'); - }); - - it('respects custom storagePrefix', () => { - createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'chronomind', - storagePrefix: 'cm', - storage, - }).setTokens('a', 'b'); - - expect(storage.store.get('cm-auth-token')).toBe('a'); - }); - }); - - describe('login', () => { - it('sends correct request and stores tokens', async () => { - const mockData = { - accessToken: 'at-123', - refreshToken: 'rt-456', - user: { id: 'u1', email: 'a@b.com', displayName: 'A', role: 'user', plan: 'free' }, - }; - globalThis.fetch = mockFetchResponse(mockData); - - const client = createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - storage, - }); - - const result = await client.login('a@b.com', 'pass123'); - - expect(result.user.email).toBe('a@b.com'); - expect(client.getAccessToken()).toBe('at-123'); - expect(client.getRefreshToken()).toBe('rt-456'); - - const [url, opts] = (globalThis.fetch as ReturnType).mock.calls[0]; - expect(url).toBe('http://localhost:4003/api/auth/login'); - expect(opts.method).toBe('POST'); - const body = JSON.parse(opts.body); - expect(body.email).toBe('a@b.com'); - expect(body.productId).toBe('testapp'); - - expect(opts.headers['x-product-id']).toBe('testapp'); - expect(opts.headers['x-request-id']).toBeTruthy(); - }); - - it('throws on login failure', async () => { - globalThis.fetch = mockFetchResponse({ message: 'Invalid credentials' }, 401); - - const client = createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - storage, - }); - - await expect(client.login('a@b.com', 'wrong')).rejects.toThrow('Invalid credentials'); - expect(client.isAuthenticated()).toBe(false); - }); - }); - - describe('register', () => { - it('sends correct request and stores tokens', async () => { - const mockData = { - accessToken: 'at-new', - refreshToken: 'rt-new', - user: { id: 'u2', email: 'new@b.com', displayName: 'New', role: 'user', plan: 'free' }, - }; - globalThis.fetch = mockFetchResponse(mockData); - - const client = createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'nomgap', - storage, - }); - - const result = await client.register('new@b.com', 'pass1234', 'New User'); - - expect(result.user.displayName).toBe('New'); - expect(client.getAccessToken()).toBe('at-new'); - - const body = JSON.parse((globalThis.fetch as ReturnType).mock.calls[0][1].body); - expect(body.displayName).toBe('New User'); - expect(body.productId).toBe('nomgap'); - }); - }); - - describe('getMe', () => { - it('sends authorization header', async () => { - const mockUser = { id: 'u1', email: 'a@b.com', displayName: 'A', role: 'user', plan: 'free' }; - globalThis.fetch = mockFetchResponse(mockUser); - - const client = createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - storage, - }); - client.setTokens('my-token', 'my-refresh'); - - const user = await client.getMe(); - expect(user.email).toBe('a@b.com'); - - const opts = (globalThis.fetch as ReturnType).mock.calls[0][1]; - expect(opts.headers['Authorization']).toBe('Bearer my-token'); - }); - }); - - describe('refreshAccessToken', () => { - it('refreshes and stores new tokens', async () => { - const mockRefresh = { accessToken: 'at-refreshed', refreshToken: 'rt-refreshed' }; - globalThis.fetch = mockFetchResponse(mockRefresh); - - const client = createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - storage, - }); - client.setTokens('old-at', 'old-rt'); - - const ok = await client.refreshAccessToken(); - - expect(ok).toBe(true); - expect(client.getAccessToken()).toBe('at-refreshed'); - expect(client.getRefreshToken()).toBe('rt-refreshed'); - }); - - it('clears tokens on refresh failure', async () => { - globalThis.fetch = mockFetchResponse({ error: 'expired' }, 401); - - const client = createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - storage, - }); - client.setTokens('old-at', 'old-rt'); - - const ok = await client.refreshAccessToken(); - - expect(ok).toBe(false); - expect(client.isAuthenticated()).toBe(false); - }); - - it('returns false if no refresh token', async () => { - const client = createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - storage, - }); - - const ok = await client.refreshAccessToken(); - expect(ok).toBe(false); - }); - }); - - describe('forgotPassword', () => { - it('sends email and productId', async () => { - globalThis.fetch = mockFetchResponse({ message: 'Reset email sent' }); - - const client = createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'mindlyst', - storage, - }); - - const result = await client.forgotPassword('user@test.com'); - expect(result.message).toBe('Reset email sent'); - - const body = JSON.parse((globalThis.fetch as ReturnType).mock.calls[0][1].body); - expect(body.email).toBe('user@test.com'); - expect(body.productId).toBe('mindlyst'); - }); - }); - - describe('changePassword', () => { - it('sends authenticated request', async () => { - globalThis.fetch = mockFetchResponse({ message: 'Password changed' }); - - const client = createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - storage, - }); - client.setTokens('tok', 'ref'); - - const result = await client.changePassword('old', 'new12345'); - expect(result.message).toBe('Password changed'); - - const [, opts] = (globalThis.fetch as ReturnType).mock.calls[0]; - expect(opts.headers['Authorization']).toBe('Bearer tok'); - const body = JSON.parse(opts.body); - expect(body.currentPassword).toBe('old'); - expect(body.newPassword).toBe('new12345'); - }); - }); - - describe('deleteAccount', () => { - it('clears tokens after deletion', async () => { - globalThis.fetch = mockFetchResponse({ message: 'Deleted' }); - - const client = createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - storage, - }); - client.setTokens('tok', 'ref'); - - await client.deleteAccount('mypassword'); - - expect(client.isAuthenticated()).toBe(false); - - const [url, opts] = (globalThis.fetch as ReturnType).mock.calls[0]; - expect(url).toContain('/auth/account'); - expect(opts.method).toBe('DELETE'); - }); - }); - - describe('verifyEmail', () => { - it('sends verification token', async () => { - globalThis.fetch = mockFetchResponse({ message: 'Verified' }); - - const client = createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - storage, - }); - - const result = await client.verifyEmail('verify-token-abc'); - expect(result.message).toBe('Verified'); - }); - }); - - describe('resendVerification', () => { - it('sends email and productId', async () => { - globalThis.fetch = mockFetchResponse({ message: 'Sent' }); - - const client = createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'chronomind', - storage, - }); - - await client.resendVerification('user@test.com'); - - const body = JSON.parse((globalThis.fetch as ReturnType).mock.calls[0][1].body); - expect(body.email).toBe('user@test.com'); - expect(body.productId).toBe('chronomind'); - }); - }); -}); diff --git a/vendor/bytelyst/auth-client/src/__tests__/smartauth.test.ts b/vendor/bytelyst/auth-client/src/__tests__/smartauth.test.ts deleted file mode 100644 index e7c5f17..0000000 --- a/vendor/bytelyst/auth-client/src/__tests__/smartauth.test.ts +++ /dev/null @@ -1,571 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { createAuthClient } from '../client.js'; -import type { TokenStorage } from '../types.js'; - -function createMockStorage(): TokenStorage & { store: Map } { - const store = new Map(); - return { - store, - getItem: (key: string) => store.get(key) ?? null, - setItem: (key: string, value: string) => store.set(key, value), - removeItem: (key: string) => store.delete(key), - }; -} - -function mockFetchResponse(data: unknown, status = 200) { - return vi.fn().mockResolvedValue({ - ok: status >= 200 && status < 300, - status, - json: () => Promise.resolve(data), - }); -} - -describe('@bytelyst/auth-client — SmartAuth', () => { - let storage: ReturnType; - - beforeEach(() => { - storage = createMockStorage(); - vi.restoreAllMocks(); - }); - - // ── Phase 1C: Google Sign-In ────────────────────── - - describe('loginWithGoogle', () => { - it('calls POST /auth/oauth/google and stores tokens', async () => { - const mockData = { - accessToken: 'google-at', - refreshToken: 'google-rt', - user: { id: 'u1', email: 'g@gmail.com', displayName: 'G', role: 'user', plan: 'free' }, - }; - globalThis.fetch = mockFetchResponse(mockData); - - const client = createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - storage, - }); - - const result = await client.loginWithGoogle('google-id-token-123'); - - expect('user' in result).toBe(true); - if ('user' in result) { - expect(result.user.email).toBe('g@gmail.com'); - } - expect(client.getAccessToken()).toBe('google-at'); - expect(client.getRefreshToken()).toBe('google-rt'); - - const [url, opts] = (globalThis.fetch as ReturnType).mock.calls[0]; - expect(url).toBe('http://localhost:4003/api/auth/oauth/google'); - expect(opts.method).toBe('POST'); - const body = JSON.parse(opts.body); - expect(body.idToken).toBe('google-id-token-123'); - expect(body.productId).toBe('testapp'); - }); - - it('returns MFA challenge when mfaRequired is true', async () => { - const mfaData = { - mfaRequired: true, - mfaChallenge: 'challenge-token-abc', - methods: ['totp'], - }; - globalThis.fetch = mockFetchResponse(mfaData); - - const client = createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - storage, - }); - - const result = await client.loginWithGoogle('google-id-token-456'); - - expect('mfaRequired' in result).toBe(true); - if ('mfaRequired' in result) { - expect(result.mfaRequired).toBe(true); - expect(result.mfaChallenge).toBe('challenge-token-abc'); - expect(result.methods).toEqual(['totp']); - } - // Tokens should NOT be stored when MFA is required - expect(client.isAuthenticated()).toBe(false); - }); - }); - - describe('loginWithMicrosoft', () => { - it('calls POST /auth/oauth/microsoft', async () => { - const mockData = { - accessToken: 'ms-at', - refreshToken: 'ms-rt', - user: { id: 'u2', email: 'ms@outlook.com', displayName: 'MS', role: 'user', plan: 'free' }, - }; - globalThis.fetch = mockFetchResponse(mockData); - - const client = createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - storage, - }); - - await client.loginWithMicrosoft('ms-id-token'); - - const [url] = (globalThis.fetch as ReturnType).mock.calls[0]; - expect(url).toBe('http://localhost:4003/api/auth/oauth/microsoft'); - expect(client.getAccessToken()).toBe('ms-at'); - }); - }); - - describe('loginWithApple', () => { - it('calls POST /auth/oauth/apple', async () => { - const mockData = { - accessToken: 'apple-at', - refreshToken: 'apple-rt', - user: { id: 'u3', email: 'a@icloud.com', displayName: 'A', role: 'user', plan: 'free' }, - }; - globalThis.fetch = mockFetchResponse(mockData); - - const client = createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - storage, - }); - - await client.loginWithApple('apple-id-token'); - - const [url] = (globalThis.fetch as ReturnType).mock.calls[0]; - expect(url).toBe('http://localhost:4003/api/auth/oauth/apple'); - expect(client.getAccessToken()).toBe('apple-at'); - }); - }); - - // ── Phase 1C: Provider management ───────────────── - - describe('getProviders', () => { - it('calls GET /auth/providers', async () => { - const providers = { - providers: [ - { - provider: 'google', - email: 'g@gmail.com', - linkedAt: '2025-01-01T00:00:00Z', - lastUsedAt: null, - }, - ], - }; - globalThis.fetch = mockFetchResponse(providers); - - const client = createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - storage, - }); - client.setTokens('tok', 'ref'); - - const result = await client.getProviders(); - - expect(result).toHaveLength(1); - expect(result[0].provider).toBe('google'); - - const [url, opts] = (globalThis.fetch as ReturnType).mock.calls[0]; - expect(url).toBe('http://localhost:4003/api/auth/providers'); - expect(opts.method).toBe('GET'); - expect(opts.headers['Authorization']).toBe('Bearer tok'); - }); - }); - - describe('linkProvider', () => { - it('calls POST /auth/providers/link with provider and idToken', async () => { - globalThis.fetch = mockFetchResponse({}); - - const client = createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - storage, - }); - client.setTokens('tok', 'ref'); - - await client.linkProvider('google', 'link-token'); - - const [url, opts] = (globalThis.fetch as ReturnType).mock.calls[0]; - expect(url).toBe('http://localhost:4003/api/auth/providers/link'); - expect(opts.method).toBe('POST'); - const body = JSON.parse(opts.body); - expect(body.provider).toBe('google'); - expect(body.idToken).toBe('link-token'); - }); - }); - - describe('unlinkProvider', () => { - it('calls DELETE /auth/providers/:provider', async () => { - globalThis.fetch = mockFetchResponse({}); - - const client = createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - storage, - }); - client.setTokens('tok', 'ref'); - - await client.unlinkProvider('google'); - - const [url, opts] = (globalThis.fetch as ReturnType).mock.calls[0]; - expect(url).toBe('http://localhost:4003/api/auth/providers/google'); - expect(opts.method).toBe('DELETE'); - }); - }); - - // ── Phase 2D: MFA ───────────────────────────────── - - describe('verifyMfa', () => { - it('sends challenge token and TOTP code, stores tokens', async () => { - const mockData = { - accessToken: 'mfa-at', - refreshToken: 'mfa-rt', - user: { id: 'u1', email: 'a@b.com', displayName: 'A', role: 'user', plan: 'free' }, - }; - globalThis.fetch = mockFetchResponse(mockData); - - const client = createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - storage, - }); - - const result = await client.verifyMfa('challenge-xyz', '123456', 'totp'); - - expect(result.user.email).toBe('a@b.com'); - expect(client.getAccessToken()).toBe('mfa-at'); - - const body = JSON.parse((globalThis.fetch as ReturnType).mock.calls[0][1].body); - expect(body.challengeToken).toBe('challenge-xyz'); - expect(body.code).toBe('123456'); - expect(body.method).toBe('totp'); - }); - }); - - describe('setupTotp', () => { - it('calls POST /auth/mfa/setup', async () => { - const setup = { - secret: 'ABCDEFGH', - otpauthUri: 'otpauth://totp/Test?secret=ABC', - recoveryCodes: ['code1', 'code2'], - }; - globalThis.fetch = mockFetchResponse(setup); - - const client = createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - storage, - }); - client.setTokens('tok', 'ref'); - - const result = await client.setupTotp(); - - expect(result.otpauthUri).toContain('otpauth://'); - expect(result.secret).toBe('ABCDEFGH'); - expect(result.recoveryCodes).toHaveLength(2); - - const [url] = (globalThis.fetch as ReturnType).mock.calls[0]; - expect(url).toBe('http://localhost:4003/api/auth/mfa/setup'); - }); - }); - - describe('getMfaStatus', () => { - it('returns MFA status', async () => { - const status = { mfaEnabled: true, methods: ['totp'], recoveryCodesRemaining: 6 }; - globalThis.fetch = mockFetchResponse(status); - - const client = createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - storage, - }); - client.setTokens('tok', 'ref'); - - const result = await client.getMfaStatus(); - expect(result.mfaEnabled).toBe(true); - expect(result.methods).toContain('totp'); - }); - }); - - // ── Phase 3: Passkeys ───────────────────────────── - - describe('listPasskeys', () => { - it('calls GET /auth/passkeys and unwraps response', async () => { - const data = { - passkeys: [ - { - credentialId: 'pk1', - friendlyName: 'MacBook', - deviceType: 'singleDevice', - backedUp: true, - lastUsedAt: '2025-01-01T00:00:00Z', - createdAt: '2025-01-01T00:00:00Z', - }, - ], - }; - globalThis.fetch = mockFetchResponse(data); - - const client = createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - storage, - }); - client.setTokens('tok', 'ref'); - - const result = await client.listPasskeys(); - expect(result).toHaveLength(1); - expect(result[0].friendlyName).toBe('MacBook'); - expect(result[0].credentialId).toBe('pk1'); - }); - }); - - describe('verifyPasskeyAuth', () => { - it('stores tokens after passkey authentication', async () => { - const mockData = { - accessToken: 'pk-at', - refreshToken: 'pk-rt', - user: { id: 'u1', email: 'a@b.com', displayName: 'A', role: 'user', plan: 'free' }, - }; - globalThis.fetch = mockFetchResponse(mockData); - - const client = createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - storage, - }); - - const result = await client.verifyPasskeyAuth({ id: 'cred-1', response: {} }); - - expect(result.user.email).toBe('a@b.com'); - expect(client.getAccessToken()).toBe('pk-at'); - - const [url] = (globalThis.fetch as ReturnType).mock.calls[0]; - expect(url).toBe('http://localhost:4003/api/auth/passkeys/authenticate/verify'); - }); - }); - - // ── Phase 3: Devices ────────────────────────────── - - describe('listDevices', () => { - it('calls GET /auth/devices and unwraps response', async () => { - const data = { - devices: [ - { - fingerprint: 'fp-abc', - trustLevel: 'trusted', - deviceInfo: { platform: 'web' }, - lastIp: '1.2.3.4', - trustExpiresAt: '2025-04-01T00:00:00Z', - createdAt: '2025-01-01T00:00:00Z', - lastSeenAt: '2025-01-01T00:00:00Z', - isTrusted: true, - }, - ], - }; - globalThis.fetch = mockFetchResponse(data); - - const client = createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - storage, - }); - client.setTokens('tok', 'ref'); - - const result = await client.listDevices(); - expect(result).toHaveLength(1); - expect(result[0].trustLevel).toBe('trusted'); - expect(result[0].fingerprint).toBe('fp-abc'); - }); - }); - - describe('trustDevice', () => { - it('calls POST /auth/devices/trust with fingerprint and trustLevel', async () => { - globalThis.fetch = mockFetchResponse({}); - - const client = createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - storage, - }); - client.setTokens('tok', 'ref'); - - await client.trustDevice('fp-abc', 'trusted', { platform: 'web' }); - - const [url, opts] = (globalThis.fetch as ReturnType).mock.calls[0]; - expect(url).toBe('http://localhost:4003/api/auth/devices/trust'); - expect(opts.method).toBe('POST'); - const body = JSON.parse(opts.body); - expect(body.fingerprint).toBe('fp-abc'); - expect(body.trustLevel).toBe('trusted'); - expect(body.deviceInfo).toEqual({ platform: 'web' }); - }); - }); - - describe('revokeDevice', () => { - it('calls DELETE /auth/devices/:fingerprint', async () => { - globalThis.fetch = mockFetchResponse({}); - - const client = createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - storage, - }); - client.setTokens('tok', 'ref'); - - await client.revokeDevice('fp-xyz'); - - const [url, opts] = (globalThis.fetch as ReturnType).mock.calls[0]; - expect(url).toBe('http://localhost:4003/api/auth/devices/fp-xyz'); - expect(opts.method).toBe('DELETE'); - }); - }); - - describe('revokeAllDevices', () => { - it('calls POST /auth/devices/revoke-all', async () => { - globalThis.fetch = mockFetchResponse({}); - - const client = createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - storage, - }); - client.setTokens('tok', 'ref'); - - await client.revokeAllDevices(); - - const [url, opts] = (globalThis.fetch as ReturnType).mock.calls[0]; - expect(url).toBe('http://localhost:4003/api/auth/devices/revoke-all'); - expect(opts.method).toBe('POST'); - }); - }); - - // ── Phase 5B: Admin security ────────────────────── - - describe('getSecurityOverview', () => { - it('calls GET /auth/security/overview', async () => { - const overview = { - totalUsers: 100, - mfaAdoptionPercent: 42.5, - providerDistribution: { google: 60, microsoft: 30, password: 10 }, - activeSessions: 250, - suspiciousEvents24h: 3, - }; - globalThis.fetch = mockFetchResponse(overview); - - const client = createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - storage, - }); - client.setTokens('admin-tok', 'admin-ref'); - - const result = await client.getSecurityOverview(); - expect(result.totalUsers).toBe(100); - expect(result.mfaAdoptionPercent).toBe(42.5); - }); - }); - - describe('unlockUser', () => { - it('calls POST /auth/users/:id/unlock', async () => { - globalThis.fetch = mockFetchResponse({}); - - const client = createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - storage, - }); - client.setTokens('admin-tok', 'admin-ref'); - - await client.unlockUser('user-locked-123'); - - const [url, opts] = (globalThis.fetch as ReturnType).mock.calls[0]; - expect(url).toBe('http://localhost:4003/api/auth/users/user-locked-123/unlock'); - expect(opts.method).toBe('POST'); - }); - }); - - describe('cancelDeletion', () => { - it('calls POST /auth/account/cancel-deletion', async () => { - globalThis.fetch = mockFetchResponse({ message: 'Deletion cancelled' }); - - const client = createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - storage, - }); - client.setTokens('tok', 'ref'); - - const result = await client.cancelDeletion(); - expect(result.message).toBe('Deletion cancelled'); - }); - }); - - // ── login() MFA flow ────────────────────────────── - - describe('login with MFA challenge', () => { - it('returns MfaLoginResult and does not store tokens', async () => { - const mfaResponse = { - mfaRequired: true, - mfaChallenge: 'login-challenge', - methods: ['totp', 'recovery'], - }; - globalThis.fetch = mockFetchResponse(mfaResponse); - - const client = createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - storage, - }); - - const result = await client.login('user@test.com', 'password'); - - expect('mfaRequired' in result).toBe(true); - if ('mfaRequired' in result) { - expect(result.mfaChallenge).toBe('login-challenge'); - expect(result.methods).toEqual(['totp', 'recovery']); - } - expect(client.isAuthenticated()).toBe(false); - }); - }); - - // ── createAuthClient includes all SmartAuth methods ── - - describe('client exposes all SmartAuth methods', () => { - it('has all phase 1C, 2D, 3, 5B methods', () => { - const client = createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - storage, - }); - - // Phase 1C - expect(client.loginWithGoogle).toBeTypeOf('function'); - expect(client.loginWithMicrosoft).toBeTypeOf('function'); - expect(client.loginWithApple).toBeTypeOf('function'); - expect(client.getProviders).toBeTypeOf('function'); - expect(client.linkProvider).toBeTypeOf('function'); - expect(client.unlinkProvider).toBeTypeOf('function'); - // Phase 2D - expect(client.verifyMfa).toBeTypeOf('function'); - expect(client.setupTotp).toBeTypeOf('function'); - expect(client.verifyTotpSetup).toBeTypeOf('function'); - expect(client.disableMfa).toBeTypeOf('function'); - expect(client.getMfaStatus).toBeTypeOf('function'); - expect(client.regenerateRecoveryCodes).toBeTypeOf('function'); - // Phase 3 - expect(client.getPasskeyRegisterOptions).toBeTypeOf('function'); - expect(client.verifyPasskeyRegistration).toBeTypeOf('function'); - expect(client.getPasskeyAuthOptions).toBeTypeOf('function'); - expect(client.verifyPasskeyAuth).toBeTypeOf('function'); - expect(client.listPasskeys).toBeTypeOf('function'); - expect(client.deletePasskey).toBeTypeOf('function'); - expect(client.listDevices).toBeTypeOf('function'); - expect(client.trustDevice).toBeTypeOf('function'); - expect(client.revokeDevice).toBeTypeOf('function'); - expect(client.revokeAllDevices).toBeTypeOf('function'); - // Phase 5B - expect(client.getSecurityOverview).toBeTypeOf('function'); - expect(client.unlockUser).toBeTypeOf('function'); - expect(client.exportAuthData).toBeTypeOf('function'); - expect(client.cancelDeletion).toBeTypeOf('function'); - }); - }); -}); diff --git a/vendor/bytelyst/auth-client/src/client.ts b/vendor/bytelyst/auth-client/src/client.ts deleted file mode 100644 index 10e1161..0000000 --- a/vendor/bytelyst/auth-client/src/client.ts +++ /dev/null @@ -1,527 +0,0 @@ -/** - * Browser/React Native-safe auth API client for platform-service. - * - * Replaces hand-rolled auth clients in ChronoMind web, MindLyst web, NomGap, etc. - * No Node.js dependencies — uses globalThis.fetch and configurable storage. - * - * @example - * ```ts - * import { createAuthClient } from '@bytelyst/auth-client'; - * - * const auth = createAuthClient({ - * baseUrl: 'http://localhost:4003/api', - * productId: 'chronomind', - * }); - * - * const result = await auth.login('user@example.com', 'password123'); - * console.log(result.user.displayName); - * ``` - */ - -import type { - AuthClient, - AuthClientConfig, - AuthProvider, - AuthResult, - AuthUser, - Device, - LoginEventInfo, - MfaRequiredResult, - MfaStatus, - Passkey, - SecurityOverview, - TokenStorage, - TotpSetupResult, -} from './types.js'; - -// ── Default localStorage adapter ───────────────────────────────── - -/** - * No-op storage fallback used when `localStorage` is unavailable (e.g. SSR / Node.js). - * Tokens stored via noopStorage are NOT persisted — they are lost on page reload. - * For server-side rendering, use cookie-based auth instead of relying on this client. - */ -const noopStorage: TokenStorage = { - getItem: () => null, - setItem: () => {}, - removeItem: () => {}, -}; - -function getDefaultStorage(): TokenStorage { - if ( - typeof globalThis.localStorage !== 'undefined' && - typeof globalThis.localStorage?.getItem === 'function' - ) { - return globalThis.localStorage; - } - return noopStorage; -} - -// ── UUID helper (browser + RN safe) ────────────────────────────── - -function uuid(): string { - if (typeof globalThis.crypto?.randomUUID === 'function') { - return globalThis.crypto.randomUUID(); - } - return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { - const r = (Math.random() * 16) | 0; - return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16); - }); -} - -// ── Factory ────────────────────────────────────────────────────── - -export function createAuthClient(config: AuthClientConfig): AuthClient { - const { baseUrl, productId, timeoutMs = 15_000 } = config; - const storage = config.storage ?? getDefaultStorage(); - const prefix = config.storagePrefix ?? productId; - - const KEYS = { - accessToken: `${prefix}-auth-token`, - refreshToken: `${prefix}-refresh-token`, - } as const; - - // ── Token management ──────────────────────────── - - function getAccessToken(): string | null { - return storage.getItem(KEYS.accessToken); - } - - function getRefreshToken(): string | null { - return storage.getItem(KEYS.refreshToken); - } - - function setTokens(accessToken: string, refreshToken: string): void { - storage.setItem(KEYS.accessToken, accessToken); - storage.setItem(KEYS.refreshToken, refreshToken); - } - - function clearTokens(): void { - storage.removeItem(KEYS.accessToken); - storage.removeItem(KEYS.refreshToken); - } - - function isAuthenticated(): boolean { - return getAccessToken() !== null; - } - - // ── HTTP helper ───────────────────────────────── - - async function request( - path: string, - method: string, - body?: unknown, - opts?: { skipAuth?: boolean } - ): Promise { - const headers: Record = { - 'Content-Type': 'application/json', - 'x-product-id': productId, - 'x-request-id': uuid(), - }; - - if (!opts?.skipAuth) { - const token = getAccessToken(); - if (token) headers['Authorization'] = `Bearer ${token}`; - } - - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - - try { - const res = await globalThis.fetch(`${baseUrl}${path}`, { - method, - headers, - body: body ? JSON.stringify(body) : undefined, - signal: controller.signal, - }); - - if (!res.ok) { - const data = await res.json().catch(() => ({})); - throw new Error( - (data as Record).message || - (data as Record).error || - `HTTP ${res.status}` - ); - } - - if (res.status === 204) return undefined as T; - return res.json() as Promise; - } finally { - clearTimeout(timer); - } - } - - // ── Singleton refresh guard ───────────────────── - - let _refreshPromise: Promise | null = null; - - async function refreshAccessToken(): Promise { - if (_refreshPromise) return _refreshPromise; - - _refreshPromise = (async () => { - const rt = getRefreshToken(); - if (!rt) return false; - - try { - const data = await request<{ accessToken: string; refreshToken: string }>( - '/auth/refresh', - 'POST', - { refreshToken: rt }, - { skipAuth: true } - ); - setTokens(data.accessToken, data.refreshToken); - return true; - } catch { - clearTokens(); - return false; - } - })(); - - try { - return await _refreshPromise; - } finally { - _refreshPromise = null; - } - } - - // ── Auth operations ───────────────────────────── - - async function login(email: string, password: string): Promise { - const result = await request('/auth/login', 'POST', { - email, - password, - productId, - }); - if ('mfaRequired' in result && result.mfaRequired) { - return result; - } - const authResult = result as AuthResult; - setTokens(authResult.accessToken, authResult.refreshToken); - return authResult; - } - - async function register( - email: string, - password: string, - displayName: string - ): Promise { - const result = await request('/auth/register', 'POST', { - email, - password, - displayName, - productId, - }); - setTokens(result.accessToken, result.refreshToken); - return result; - } - - async function getMe(): Promise { - return request('/auth/me', 'GET'); - } - - // ── Password management ───────────────────────── - - async function forgotPassword(email: string): Promise<{ message: string }> { - return request<{ message: string }>('/auth/forgot-password', 'POST', { - email, - productId, - }); - } - - async function resetPassword(token: string, newPassword: string): Promise<{ message: string }> { - return request<{ message: string }>('/auth/reset-password', 'POST', { - token, - newPassword, - }); - } - - async function changePassword( - currentPassword: string, - newPassword: string - ): Promise<{ message: string }> { - return request<{ message: string }>('/auth/change-password', 'POST', { - currentPassword, - newPassword, - }); - } - - // ── Account management ────────────────────────── - - async function deleteAccount(password: string): Promise<{ message: string }> { - const result = await request<{ message: string }>('/auth/account', 'DELETE', { - password, - }); - clearTokens(); - return result; - } - - // ── Email verification ────────────────────────── - - async function verifyEmail(token: string): Promise<{ message: string }> { - return request<{ message: string }>('/auth/verify-email', 'POST', { token }); - } - - async function resendVerification(email: string): Promise<{ message: string }> { - return request<{ message: string }>('/auth/resend-verification', 'POST', { - email, - productId, - }); - } - - // ── OAuth / Social login (Phase 1C) ──────────────── - - async function loginWithOAuth( - provider: string, - idToken: string - ): Promise { - const result = await request( - `/auth/oauth/${provider}`, - 'POST', - { idToken, productId }, - { skipAuth: true } - ); - if ('mfaRequired' in result && result.mfaRequired) { - return result; - } - const authResult = result as AuthResult; - setTokens(authResult.accessToken, authResult.refreshToken); - return authResult; - } - - async function loginWithGoogle(idToken: string): Promise { - return loginWithOAuth('google', idToken); - } - - async function loginWithMicrosoft(idToken: string): Promise { - return loginWithOAuth('microsoft', idToken); - } - - async function loginWithApple(idToken: string): Promise { - return loginWithOAuth('apple', idToken); - } - - // ── Provider management (Phase 1C) ───────────────── - - async function getProviders(): Promise { - const data = await request<{ providers: AuthProvider[] }>('/auth/providers', 'GET'); - return data.providers; - } - - async function linkProvider(provider: string, idToken: string): Promise { - await request('/auth/providers/link', 'POST', { provider, idToken }); - } - - async function unlinkProvider(provider: string): Promise { - await request(`/auth/providers/${provider}`, 'DELETE'); - } - - // ── MFA (Phase 2D) ───────────────────────────────── - - async function verifyMfa( - challengeToken: string, - code: string, - method: 'totp' | 'recovery' - ): Promise { - const result = await request('/auth/mfa/verify', 'POST', { - challengeToken, - code, - method, - }); - setTokens(result.accessToken, result.refreshToken); - return result; - } - - async function setupTotp(): Promise { - return request('/auth/mfa/setup', 'POST'); - } - - async function verifyTotpSetup(code: string): Promise { - await request('/auth/mfa/verify-setup', 'POST', { code }); - } - - async function disableMfa(code: string): Promise { - await request('/auth/mfa/disable', 'POST', { code }); - } - - async function getMfaStatus(): Promise { - return request('/auth/mfa/status', 'GET'); - } - - async function regenerateRecoveryCodes(): Promise<{ codes: string[] }> { - return request<{ codes: string[] }>('/auth/mfa/recovery/regenerate', 'POST'); - } - - // ── Passkeys (Phase 3) ───────────────────────────── - - async function getPasskeyRegisterOptions(): Promise { - return request('/auth/passkeys/register/options', 'POST'); - } - - async function verifyPasskeyRegistration(response: unknown): Promise { - await request('/auth/passkeys/register/verify', 'POST', response); - } - - async function getPasskeyAuthOptions(): Promise { - return request('/auth/passkeys/authenticate/options', 'POST', undefined, { - skipAuth: true, - }); - } - - async function verifyPasskeyAuth(response: unknown): Promise { - const result = await request( - '/auth/passkeys/authenticate/verify', - 'POST', - response, - { skipAuth: true } - ); - setTokens(result.accessToken, result.refreshToken); - return result; - } - - async function listPasskeys(): Promise { - const data = await request<{ passkeys: Passkey[] }>('/auth/passkeys', 'GET'); - return data.passkeys; - } - - async function deletePasskey(id: string): Promise { - await request(`/auth/passkeys/${id}`, 'DELETE'); - } - - // ── Devices (Phase 3) ────────────────────────────── - - async function listDevices(): Promise { - const data = await request<{ devices: Device[] }>('/auth/devices', 'GET'); - return data.devices; - } - - async function trustDevice( - fingerprint: string, - trustLevel: 'trusted' | 'remembered', - deviceInfo?: Record - ): Promise { - await request('/auth/devices/trust', 'POST', { fingerprint, trustLevel, deviceInfo }); - } - - async function revokeDevice(fingerprint: string): Promise { - await request(`/auth/devices/${fingerprint}`, 'DELETE'); - } - - async function revokeAllDevices(): Promise { - await request('/auth/devices/revoke-all', 'POST'); - } - - // ── Admin security (Phase 5B) ────────────────────── - - async function getSecurityOverview(): Promise { - return request('/auth/security/overview', 'GET'); - } - - async function unlockUser(userId: string): Promise { - await request(`/auth/users/${userId}/unlock`, 'POST'); - } - - async function exportAuthData(): Promise { - return request('/auth/export', 'GET'); - } - - async function cancelDeletion(): Promise<{ message: string }> { - return request<{ message: string }>('/auth/account/cancel-deletion', 'POST'); - } - - // ── Step-up auth ──────────────────────────────────── - - async function stepUp(method: string, credential: string): Promise<{ stepUpToken: string }> { - return request<{ stepUpToken: string }>('/auth/step-up', 'POST', { method, credential }); - } - - // ── Login history ─────────────────────────────────── - - async function getLoginHistory(limit = 50): Promise { - const data = await request<{ events: LoginEventInfo[] }>( - `/auth/login-events?limit=${limit}`, - 'GET' - ); - return data.events; - } - - // ── Admin security ────────────────────────────────── - - async function getAdminLoginEvents(opts?: { - userId?: string; - suspicious?: boolean; - limit?: number; - }): Promise { - const params = new URLSearchParams(); - if (opts?.userId) params.set('userId', opts.userId); - if (opts?.suspicious) params.set('suspicious', 'true'); - if (opts?.limit) params.set('limit', String(opts.limit)); - const qs = params.toString(); - const data = await request<{ events: LoginEventInfo[] }>( - `/auth/login-events/admin${qs ? `?${qs}` : ''}`, - 'GET' - ); - return data.events; - } - - async function getAdminDevices(userId: string): Promise { - const data = await request<{ devices: Device[] }>(`/auth/devices/user/${userId}`, 'GET'); - return data.devices; - } - - return { - getAccessToken, - getRefreshToken, - setTokens, - clearTokens, - isAuthenticated, - login, - register, - getMe, - refreshAccessToken, - forgotPassword, - resetPassword, - changePassword, - deleteAccount, - verifyEmail, - resendVerification, - // OAuth / Social login (Phase 1C) - loginWithGoogle, - loginWithMicrosoft, - loginWithApple, - // Provider management (Phase 1C) - getProviders, - linkProvider, - unlinkProvider, - // MFA (Phase 2D) - verifyMfa, - setupTotp, - verifyTotpSetup, - disableMfa, - getMfaStatus, - regenerateRecoveryCodes, - // Passkeys (Phase 3) - getPasskeyRegisterOptions, - verifyPasskeyRegistration, - getPasskeyAuthOptions, - verifyPasskeyAuth, - listPasskeys, - deletePasskey, - // Devices (Phase 3) - listDevices, - trustDevice, - revokeDevice, - revokeAllDevices, - // Admin security (Phase 5B) - getSecurityOverview, - unlockUser, - exportAuthData, - cancelDeletion, - // Step-up auth - stepUp, - // Login history - getLoginHistory, - // Admin queries - getAdminLoginEvents, - getAdminDevices, - }; -} diff --git a/vendor/bytelyst/auth-client/src/index.ts b/vendor/bytelyst/auth-client/src/index.ts deleted file mode 100644 index 9592e2e..0000000 --- a/vendor/bytelyst/auth-client/src/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -export { createAuthClient } from './client.js'; -export type { - AuthClient, - AuthClientConfig, - AuthProvider, - AuthResult, - AuthUser, - Device, - LoginEventInfo, - MfaRequiredResult, - MfaStatus, - Passkey, - SecurityOverview, - TokenStorage, - TotpSetupResult, -} from './types.js'; diff --git a/vendor/bytelyst/auth-client/src/types.ts b/vendor/bytelyst/auth-client/src/types.ts deleted file mode 100644 index 3f760b5..0000000 --- a/vendor/bytelyst/auth-client/src/types.ts +++ /dev/null @@ -1,190 +0,0 @@ -/** - * Types for @bytelyst/auth-client. - * Browser/React Native-safe — no Node.js dependencies. - */ - -export interface AuthClientConfig { - /** Platform-service base URL (e.g. "http://localhost:4003/api" or "https://api.example.com"). */ - baseUrl: string; - - /** Product identifier sent with every request as x-product-id header. */ - productId: string; - - /** Storage adapter for tokens. Defaults to localStorage if available. */ - storage?: TokenStorage; - - /** Optional prefix for storage keys. Default: product ID. */ - storagePrefix?: string; - - /** Request timeout in milliseconds. Default: 15000. */ - timeoutMs?: number; -} - -export interface TokenStorage { - getItem(key: string): string | null; - setItem(key: string, value: string): void; - removeItem(key: string): void; -} - -export interface AuthUser { - id: string; - email: string; - displayName: string; - role: string; - plan: string; - mfaEnabled?: boolean; - mfaMethods?: string[]; - providers?: string[]; - products?: string[]; -} - -export interface AuthResult { - accessToken: string; - refreshToken: string; - user: AuthUser; -} - -export interface MfaRequiredResult { - mfaRequired: true; - mfaChallenge: string; - methods: string[]; -} - -export interface TotpSetupResult { - secret: string; - otpauthUri: string; - recoveryCodes: string[]; -} - -export interface MfaStatus { - mfaEnabled: boolean; - methods: string[]; - recoveryCodesRemaining: number; -} - -export interface AuthProvider { - provider: string; - email: string; - linkedAt: string; - lastUsedAt: string | null; -} - -export interface Passkey { - credentialId: string; - friendlyName: string; - deviceType: string; - backedUp: boolean; - lastUsedAt: string; - createdAt: string; -} - -export interface Device { - fingerprint: string; - trustLevel: 'trusted' | 'remembered' | 'unknown'; - deviceInfo: Record; - lastIp?: string; - lastLocation?: string; - trustExpiresAt: string; - createdAt: string; - lastSeenAt: string; - isTrusted: boolean; -} - -export interface LoginEventInfo { - id: string; - result: string; - method: string; - ip: string; - userAgent?: string; - fingerprint?: string; - location?: string; - riskLevel: string; - riskScore: number; - riskFlags: string[]; - createdAt: string; -} - -export interface SecurityOverview { - totalUsers: number; - mfaAdoptionPercent: number; - providerDistribution: Record; - activeSessions: number; - suspiciousEvents24h: number; -} - -export interface AuthClient { - // ── Token management ──────────────────────────── - getAccessToken(): string | null; - getRefreshToken(): string | null; - setTokens(accessToken: string, refreshToken: string): void; - clearTokens(): void; - isAuthenticated(): boolean; - - // ── Auth operations ───────────────────────────── - login(email: string, password: string): Promise; - register(email: string, password: string, displayName: string): Promise; - getMe(): Promise; - refreshAccessToken(): Promise; - - // ── OAuth / Social login ──────────────────────── - loginWithGoogle(idToken: string): Promise; - loginWithMicrosoft(idToken: string): Promise; - loginWithApple(idToken: string): Promise; - - // ── Provider management ───────────────────────── - getProviders(): Promise; - linkProvider(provider: string, idToken: string): Promise; - unlinkProvider(provider: string): Promise; - - // ── MFA ───────────────────────────────────────── - verifyMfa(challengeToken: string, code: string, method: 'totp' | 'recovery'): Promise; - setupTotp(): Promise; - verifyTotpSetup(code: string): Promise; - disableMfa(code: string): Promise; - getMfaStatus(): Promise; - regenerateRecoveryCodes(): Promise<{ codes: string[] }>; - - // ── Passkeys (WebAuthn) ───────────────────────── - getPasskeyRegisterOptions(): Promise; - verifyPasskeyRegistration(response: unknown): Promise; - getPasskeyAuthOptions(): Promise; - verifyPasskeyAuth(response: unknown): Promise; - listPasskeys(): Promise; - deletePasskey(id: string): Promise; - - // ── Devices ───────────────────────────────────── - listDevices(): Promise; - trustDevice( - fingerprint: string, - trustLevel: 'trusted' | 'remembered', - deviceInfo?: Record - ): Promise; - revokeDevice(fingerprint: string): Promise; - revokeAllDevices(): Promise; - - // ── Step-up auth ──────────────────────────────── - stepUp(method: string, credential: string): Promise<{ stepUpToken: string }>; - - // ── Login history ─────────────────────────────── - getLoginHistory(limit?: number): Promise; - - // ── Admin security ────────────────────────────── - getSecurityOverview(): Promise; - unlockUser(userId: string): Promise; - getAdminLoginEvents(opts?: { suspicious?: boolean; limit?: number }): Promise; - getAdminDevices(userId: string): Promise; - exportAuthData(): Promise; - - // ── Password management ───────────────────────── - forgotPassword(email: string): Promise<{ message: string }>; - resetPassword(token: string, newPassword: string): Promise<{ message: string }>; - changePassword(currentPassword: string, newPassword: string): Promise<{ message: string }>; - - // ── Account management ────────────────────────── - deleteAccount(password: string): Promise<{ message: string }>; - cancelDeletion(): Promise<{ message: string }>; - - // ── Email verification ────────────────────────── - verifyEmail(token: string): Promise<{ message: string }>; - resendVerification(email: string): Promise<{ message: string }>; -} diff --git a/vendor/bytelyst/auth-client/tsconfig.json b/vendor/bytelyst/auth-client/tsconfig.json deleted file mode 100644 index 318c075..0000000 --- a/vendor/bytelyst/auth-client/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src", - "lib": ["ES2022", "DOM"] - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/vendor/bytelyst/auth-ui/package.json b/vendor/bytelyst/auth-ui/package.json deleted file mode 100644 index 3f6285a..0000000 --- a/vendor/bytelyst/auth-ui/package.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "name": "@bytelyst/auth-ui", - "version": "0.1.5", - "type": "module", - "description": "Shared auth UI components for SmartAuth (LoginForm, MfaChallenge, SocialButtons)", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks" - }, - "peerDependencies": { - "react": ">=18.0.0", - "react-dom": ">=18.0.0" - }, - "devDependencies": { - "@testing-library/react": "^16.3.2", - "@types/react": "^19.2.14", - "@types/react-dom": "^19.2.3", - "happy-dom": "^18.0.1", - "react": "^19.2.4", - "react-dom": "^19.2.4" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/auth-ui/src/AuthPageLayout.tsx b/vendor/bytelyst/auth-ui/src/AuthPageLayout.tsx deleted file mode 100644 index 26a28d2..0000000 --- a/vendor/bytelyst/auth-ui/src/AuthPageLayout.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import type { AuthPageLayoutProps } from './types.js'; - -/** - * Full-page auth layout — centered card with product branding. - * Wraps any auth form (LoginForm, RegisterForm, etc.). - * Styled via CSS custom properties (inherits --bl-* from host app). - */ -export function AuthPageLayout({ - productName, - logo, - title, - subtitle, - children, - footer, - className, -}: AuthPageLayoutProps) { - return ( -
-
- {/* Branding */} -
- {logo && ( -
- {typeof logo === 'string' ? ( - {productName} - ) : ( - logo - )} -
- )} -
- {productName} -
-
- {title} -
- {subtitle && ( -
- {subtitle} -
- )} -
- - {/* Form content */} - {children} - - {/* Footer */} - {footer && ( -
- {footer} -
- )} -
-
- ); -} diff --git a/vendor/bytelyst/auth-ui/src/ForgotPasswordForm.tsx b/vendor/bytelyst/auth-ui/src/ForgotPasswordForm.tsx deleted file mode 100644 index 94c7125..0000000 --- a/vendor/bytelyst/auth-ui/src/ForgotPasswordForm.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import { useState, type FormEvent } from 'react'; -import type { ForgotPasswordFormProps } from './types.js'; - -/** - * Forgot password form — email input to request a reset link. - * Styled via CSS custom properties (inherits --bl-* from host app). - */ -export function ForgotPasswordForm({ - onSubmit, - isLoading = false, - error, - success, - onBack, - className, -}: ForgotPasswordFormProps) { - const [email, setEmail] = useState(''); - - function handleSubmit(e: FormEvent) { - e.preventDefault(); - onSubmit(email); - } - - const inputStyle = { - padding: '10px 12px', - border: '1px solid var(--bl-border, #ccc)', - borderRadius: 'var(--bl-radius, 6px)', - fontSize: '14px', - width: '100%', - boxSizing: 'border-box' as const, - }; - - return ( -
-
-
- Enter your email address and we'll send you a link to reset your password. -
- - setEmail(e.target.value)} - required - disabled={isLoading} - data-testid="bl-forgot-email" - style={inputStyle} - /> - - {error && ( -
- {error} -
- )} - - {success && ( -
- {success} -
- )} - - - - {onBack && ( - - )} -
-
- ); -} diff --git a/vendor/bytelyst/auth-ui/src/LoginForm.tsx b/vendor/bytelyst/auth-ui/src/LoginForm.tsx deleted file mode 100644 index d4fbbc8..0000000 --- a/vendor/bytelyst/auth-ui/src/LoginForm.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { useState, type FormEvent } from 'react'; -import { SocialButtons } from './SocialButtons.js'; -import type { LoginFormProps } from './types.js'; - -/** - * Email/password login form with optional social login buttons. - * Styled via CSS custom properties (inherits --bl-* from host app). - */ -export function LoginForm({ - onSubmit, - providers, - onSocialLogin, - isLoading = false, - error, - className, -}: LoginFormProps) { - const [email, setEmail] = useState(''); - const [password, setPassword] = useState(''); - - function handleSubmit(e: FormEvent) { - e.preventDefault(); - onSubmit(email, password); - } - - return ( -
-
- setEmail(e.target.value)} - required - disabled={isLoading} - data-testid="bl-login-email" - style={{ - padding: '10px 12px', - border: '1px solid var(--bl-border, #ccc)', - borderRadius: 'var(--bl-radius, 6px)', - fontSize: '14px', - }} - /> - setPassword(e.target.value)} - required - disabled={isLoading} - data-testid="bl-login-password" - style={{ - padding: '10px 12px', - border: '1px solid var(--bl-border, #ccc)', - borderRadius: 'var(--bl-radius, 6px)', - fontSize: '14px', - }} - /> - - {error && ( -
- {error} -
- )} - - -
- - {providers && providers.length > 0 && onSocialLogin && ( - <> -
-
- or -
-
- - - )} -
- ); -} diff --git a/vendor/bytelyst/auth-ui/src/MfaChallenge.tsx b/vendor/bytelyst/auth-ui/src/MfaChallenge.tsx deleted file mode 100644 index 289cfe0..0000000 --- a/vendor/bytelyst/auth-ui/src/MfaChallenge.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import { useState, type FormEvent } from 'react'; -import type { MfaChallengeProps } from './types.js'; - -/** - * MFA code entry form (6-digit TOTP or recovery code). - * Styled via CSS custom properties (inherits --bl-* from host app). - */ -export function MfaChallenge({ - onSubmit, - onUseRecovery, - methods, - isLoading = false, - error, - className, -}: MfaChallengeProps) { - const [code, setCode] = useState(''); - - function handleSubmit(e: FormEvent) { - e.preventDefault(); - onSubmit(code); - } - - return ( -
-
-
- Enter your authentication code -
- - {methods && methods.length > 0 && ( -
- Available methods: {methods.join(', ')} -
- )} - - setCode(e.target.value)} - required - disabled={isLoading} - maxLength={8} - data-testid="bl-mfa-code" - style={{ - padding: '12px', - border: '1px solid var(--bl-border, #ccc)', - borderRadius: 'var(--bl-radius, 6px)', - fontSize: '24px', - textAlign: 'center', - letterSpacing: '4px', - fontFamily: 'monospace', - }} - /> - - {error && ( -
- {error} -
- )} - - - - {onUseRecovery && ( - - )} -
-
- ); -} diff --git a/vendor/bytelyst/auth-ui/src/OnboardingShell.tsx b/vendor/bytelyst/auth-ui/src/OnboardingShell.tsx deleted file mode 100644 index a0d9b5a..0000000 --- a/vendor/bytelyst/auth-ui/src/OnboardingShell.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import type { OnboardingShellProps } from './types.js'; - -/** - * Onboarding shell — step indicator, navigation, progress bar. - * Wraps arbitrary step content provided via children. - * Styled via CSS custom properties (inherits --bl-* from host app). - */ -export function OnboardingShell({ - steps, - currentStep, - onNext, - onBack, - onComplete, - children, - className, -}: OnboardingShellProps) { - const isFirst = currentStep === 0; - const isLast = currentStep === steps.length - 1; - const progress = steps.length > 1 ? ((currentStep + 1) / steps.length) * 100 : 100; - - return ( -
- {/* Progress bar */} -
-
-
- - {/* Step indicator */} -
- {steps.map((step, i) => ( -
- - {i < currentStep ? '✓' : i + 1} - - {step.label} -
- ))} -
- - {/* Step content */} -
- {children} -
- - {/* Navigation */} -
- - - -
-
- ); -} diff --git a/vendor/bytelyst/auth-ui/src/PasswordStrengthBar.tsx b/vendor/bytelyst/auth-ui/src/PasswordStrengthBar.tsx deleted file mode 100644 index 9c3e2b6..0000000 --- a/vendor/bytelyst/auth-ui/src/PasswordStrengthBar.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { useMemo } from 'react'; -import type { PasswordStrength } from './types.js'; - -interface PasswordStrengthBarProps { - password: string; - className?: string; -} - -const STRENGTH_CONFIG: Record = { - weak: { color: 'var(--bl-error, #dc3545)', label: 'Weak' }, - fair: { color: 'var(--bl-warning, #f59e0b)', label: 'Fair' }, - good: { color: 'var(--bl-info, #3b82f6)', label: 'Good' }, - strong: { color: 'var(--bl-success, #22c55e)', label: 'Strong' }, -}; - -export function getPasswordStrength(password: string): PasswordStrength { - let score = 0; - if (password.length >= 8) score++; - if (password.length >= 12) score++; - if (/[A-Z]/.test(password)) score++; - if (/[a-z]/.test(password)) score++; - if (/\d/.test(password)) score++; - if (/[^A-Za-z0-9]/.test(password)) score++; - - if (score <= 2) return 'weak'; - if (score <= 3) return 'fair'; - if (score <= 4) return 'good'; - return 'strong'; -} - -export function PasswordStrengthBar({ password, className }: PasswordStrengthBarProps) { - const strength = useMemo(() => getPasswordStrength(password), [password]); - const config = STRENGTH_CONFIG[strength]; - const widthPercent = { weak: 25, fair: 50, good: 75, strong: 100 }[strength]; - - if (!password) return null; - - return ( -
-
-
-
-
- {config.label} -
-
- ); -} diff --git a/vendor/bytelyst/auth-ui/src/RegisterForm.tsx b/vendor/bytelyst/auth-ui/src/RegisterForm.tsx deleted file mode 100644 index 0ab1b1b..0000000 --- a/vendor/bytelyst/auth-ui/src/RegisterForm.tsx +++ /dev/null @@ -1,226 +0,0 @@ -import { useState, type FormEvent } from 'react'; -import { PasswordStrengthBar } from './PasswordStrengthBar.js'; -import type { RegisterFormProps } from './types.js'; - -/** - * Registration form with name, email, password, confirm password, - * password strength indicator, and optional terms checkbox. - * Styled via CSS custom properties (inherits --bl-* from host app). - */ -export function RegisterForm({ - onSubmit, - isLoading = false, - error, - termsUrl, - privacyUrl, - onSwitchToLogin, - className, -}: RegisterFormProps) { - const [name, setName] = useState(''); - const [email, setEmail] = useState(''); - const [password, setPassword] = useState(''); - const [confirm, setConfirm] = useState(''); - const [termsAccepted, setTermsAccepted] = useState(!termsUrl); - - const passwordMismatch = confirm.length > 0 && password !== confirm; - const canSubmit = - name.trim().length > 0 && - email.length > 0 && - password.length >= 8 && - !passwordMismatch && - termsAccepted && - !isLoading; - - function handleSubmit(e: FormEvent) { - e.preventDefault(); - if (!canSubmit) return; - onSubmit({ name: name.trim(), email, password }); - } - - const inputStyle = { - padding: '10px 12px', - border: '1px solid var(--bl-border, #ccc)', - borderRadius: 'var(--bl-radius, 6px)', - fontSize: '14px', - width: '100%', - boxSizing: 'border-box' as const, - }; - - return ( -
-
- - - - - - - - - - - {passwordMismatch && ( -
- Passwords do not match -
- )} - - {termsUrl && ( - - )} - - {error && ( -
- {error} -
- )} - - - - {onSwitchToLogin && ( -
- Already have an account?{' '} - -
- )} - -
- ); -} diff --git a/vendor/bytelyst/auth-ui/src/ResetPasswordForm.tsx b/vendor/bytelyst/auth-ui/src/ResetPasswordForm.tsx deleted file mode 100644 index bd6454e..0000000 --- a/vendor/bytelyst/auth-ui/src/ResetPasswordForm.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import { useState, type FormEvent } from 'react'; -import { PasswordStrengthBar } from './PasswordStrengthBar.js'; -import type { ResetPasswordFormProps } from './types.js'; - -/** - * Reset password form — new password + confirm, with strength indicator. - * Styled via CSS custom properties (inherits --bl-* from host app). - */ -export function ResetPasswordForm({ - onSubmit, - isLoading = false, - error, - success, - className, -}: ResetPasswordFormProps) { - const [password, setPassword] = useState(''); - const [confirm, setConfirm] = useState(''); - - const passwordMismatch = confirm.length > 0 && password !== confirm; - const canSubmit = password.length >= 8 && !passwordMismatch && !isLoading; - - function handleSubmit(e: FormEvent) { - e.preventDefault(); - if (!canSubmit) return; - onSubmit(password); - } - - const inputStyle = { - padding: '10px 12px', - border: '1px solid var(--bl-border, #ccc)', - borderRadius: 'var(--bl-radius, 6px)', - fontSize: '14px', - width: '100%', - boxSizing: 'border-box' as const, - }; - - return ( -
-
-
- Enter your new password. -
- - - - - - - - {passwordMismatch && ( -
- Passwords do not match -
- )} - - {error && ( -
- {error} -
- )} - - {success && ( -
- {success} -
- )} - - - -
- ); -} diff --git a/vendor/bytelyst/auth-ui/src/SocialButtons.tsx b/vendor/bytelyst/auth-ui/src/SocialButtons.tsx deleted file mode 100644 index 8e0a67a..0000000 --- a/vendor/bytelyst/auth-ui/src/SocialButtons.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import type { SocialButtonsProps, SocialProvider } from './types.js'; - -const PROVIDER_LABELS: Record = { - google: 'Google', - microsoft: 'Microsoft', - apple: 'Apple', -}; - -/** - * Renders social login buttons for the configured providers. - * Styled via CSS custom properties (inherits --bl-* from host app). - */ -export function SocialButtons({ - providers, - onSelect, - disabled = false, - className, -}: SocialButtonsProps) { - return ( -
- {providers.map(provider => ( - - ))} -
- ); -} diff --git a/vendor/bytelyst/auth-ui/src/VerifyEmailForm.tsx b/vendor/bytelyst/auth-ui/src/VerifyEmailForm.tsx deleted file mode 100644 index fce0746..0000000 --- a/vendor/bytelyst/auth-ui/src/VerifyEmailForm.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import { useState, type FormEvent } from 'react'; -import type { VerifyEmailFormProps } from './types.js'; - -/** - * Email verification form — 6-digit code input with resend option. - * Styled via CSS custom properties (inherits --bl-* from host app). - */ -export function VerifyEmailForm({ - onSubmit, - onResend, - isLoading = false, - error, - success, - email, - className, -}: VerifyEmailFormProps) { - const [code, setCode] = useState(''); - - function handleSubmit(e: FormEvent) { - e.preventDefault(); - onSubmit(code); - } - - return ( -
-
-
- Enter the 6-digit code sent to {email ? {email} : 'your email'}. -
- - setCode(e.target.value.replace(/\D/g, '').slice(0, 6))} - required - disabled={isLoading} - maxLength={6} - data-testid="bl-verify-code" - style={{ - padding: '12px', - border: '1px solid var(--bl-border, #ccc)', - borderRadius: 'var(--bl-radius, 6px)', - fontSize: '24px', - textAlign: 'center', - letterSpacing: '6px', - fontFamily: 'monospace', - width: '100%', - boxSizing: 'border-box', - }} - /> - - {error && ( -
- {error} -
- )} - - {success && ( -
- {success} -
- )} - - - - {onResend && ( - - )} -
-
- ); -} diff --git a/vendor/bytelyst/auth-ui/src/__tests__/auth-ui.test.tsx b/vendor/bytelyst/auth-ui/src/__tests__/auth-ui.test.tsx deleted file mode 100644 index 81eb973..0000000 --- a/vendor/bytelyst/auth-ui/src/__tests__/auth-ui.test.tsx +++ /dev/null @@ -1,155 +0,0 @@ -// @vitest-environment happy-dom -import { describe, expect, it, vi, beforeEach } from 'vitest'; -import { render, screen, fireEvent, cleanup } from '@testing-library/react'; -import { LoginForm } from '../LoginForm.js'; -import { MfaChallenge } from '../MfaChallenge.js'; -import { SocialButtons } from '../SocialButtons.js'; - -describe('@bytelyst/auth-ui', () => { - beforeEach(() => { - cleanup(); - }); - - describe('SocialButtons', () => { - it('renders buttons for each provider', () => { - const onSelect = vi.fn(); - render(); - - expect(screen.getByTestId('bl-social-google')).toBeDefined(); - expect(screen.getByTestId('bl-social-microsoft')).toBeDefined(); - expect(screen.getByTestId('bl-social-apple')).toBeDefined(); - expect(screen.getByText('Continue with Google')).toBeDefined(); - expect(screen.getByText('Continue with Microsoft')).toBeDefined(); - expect(screen.getByText('Continue with Apple')).toBeDefined(); - }); - - it('calls onSelect with provider when clicked', () => { - const onSelect = vi.fn(); - render(); - - fireEvent.click(screen.getByTestId('bl-social-google')); - expect(onSelect).toHaveBeenCalledWith('google'); - }); - - it('disables buttons when disabled prop is true', () => { - const onSelect = vi.fn(); - render(); - - const btn = screen.getByTestId('bl-social-google'); - expect(btn.getAttribute('disabled')).toBe(''); - }); - }); - - describe('LoginForm', () => { - it('renders email, password, and submit button', () => { - const onSubmit = vi.fn(); - render(); - - expect(screen.getByTestId('bl-login-email')).toBeDefined(); - expect(screen.getByTestId('bl-login-password')).toBeDefined(); - expect(screen.getByTestId('bl-login-submit')).toBeDefined(); - expect(screen.getByText('Sign in')).toBeDefined(); - }); - - it('calls onSubmit with email and password', () => { - const onSubmit = vi.fn(); - render(); - - fireEvent.change(screen.getByTestId('bl-login-email'), { - target: { value: 'test@example.com' }, - }); - fireEvent.change(screen.getByTestId('bl-login-password'), { - target: { value: 'password123' }, - }); - fireEvent.submit(screen.getByTestId('bl-login-submit').closest('form')!); - - expect(onSubmit).toHaveBeenCalledWith('test@example.com', 'password123'); - }); - - it('displays error message', () => { - const onSubmit = vi.fn(); - render(); - - expect(screen.getByTestId('bl-login-error')).toBeDefined(); - expect(screen.getByText('Invalid credentials')).toBeDefined(); - }); - - it('renders social buttons when providers are given', () => { - const onSubmit = vi.fn(); - const onSocialLogin = vi.fn(); - render( - - ); - - expect(screen.getByTestId('bl-social-google')).toBeDefined(); - expect(screen.getByTestId('bl-social-apple')).toBeDefined(); - - fireEvent.click(screen.getByTestId('bl-social-google')); - expect(onSocialLogin).toHaveBeenCalledWith('google'); - }); - - it('shows loading state', () => { - const onSubmit = vi.fn(); - render(); - - expect(screen.getByText('Signing in...')).toBeDefined(); - const btn = screen.getByTestId('bl-login-submit'); - expect(btn.getAttribute('disabled')).toBe(''); - }); - }); - - describe('MfaChallenge', () => { - it('renders code input and verify button', () => { - const onSubmit = vi.fn(); - render(); - - expect(screen.getByTestId('bl-mfa-code')).toBeDefined(); - expect(screen.getByTestId('bl-mfa-submit')).toBeDefined(); - expect(screen.getByText('Verify')).toBeDefined(); - }); - - it('calls onSubmit with code', () => { - const onSubmit = vi.fn(); - render(); - - fireEvent.change(screen.getByTestId('bl-mfa-code'), { - target: { value: '123456' }, - }); - fireEvent.submit(screen.getByTestId('bl-mfa-submit').closest('form')!); - - expect(onSubmit).toHaveBeenCalledWith('123456'); - }); - - it('displays available methods', () => { - const onSubmit = vi.fn(); - render(); - - expect(screen.getByTestId('bl-mfa-methods')).toBeDefined(); - expect(screen.getByText('Available methods: totp, recovery')).toBeDefined(); - }); - - it('shows recovery code button when handler provided', () => { - const onSubmit = vi.fn(); - const onUseRecovery = vi.fn(); - render(); - - const recoveryBtn = screen.getByTestId('bl-mfa-recovery'); - expect(recoveryBtn).toBeDefined(); - - fireEvent.click(recoveryBtn); - expect(onUseRecovery).toHaveBeenCalledOnce(); - }); - - it('displays error message', () => { - const onSubmit = vi.fn(); - render(); - - expect(screen.getByTestId('bl-mfa-error')).toBeDefined(); - expect(screen.getByText('Invalid code')).toBeDefined(); - }); - }); -}); diff --git a/vendor/bytelyst/auth-ui/src/__tests__/new-components.test.tsx b/vendor/bytelyst/auth-ui/src/__tests__/new-components.test.tsx deleted file mode 100644 index 6134b61..0000000 --- a/vendor/bytelyst/auth-ui/src/__tests__/new-components.test.tsx +++ /dev/null @@ -1,402 +0,0 @@ -// @vitest-environment happy-dom -import { describe, expect, it, vi, beforeEach } from 'vitest'; -import { render, screen, fireEvent, cleanup } from '@testing-library/react'; -import { RegisterForm } from '../RegisterForm.js'; -import { ForgotPasswordForm } from '../ForgotPasswordForm.js'; -import { ResetPasswordForm } from '../ResetPasswordForm.js'; -import { VerifyEmailForm } from '../VerifyEmailForm.js'; -import { OnboardingShell } from '../OnboardingShell.js'; -import { AuthPageLayout } from '../AuthPageLayout.js'; -import { PasswordStrengthBar, getPasswordStrength } from '../PasswordStrengthBar.js'; - -describe('RegisterForm', () => { - beforeEach(() => cleanup()); - - it('renders all fields', () => { - render(); - expect(screen.getByTestId('bl-register-name')).toBeDefined(); - expect(screen.getByTestId('bl-register-email')).toBeDefined(); - expect(screen.getByTestId('bl-register-password')).toBeDefined(); - expect(screen.getByTestId('bl-register-confirm')).toBeDefined(); - expect(screen.getByTestId('bl-register-submit')).toBeDefined(); - }); - - it('calls onSubmit with name, email, password', () => { - const onSubmit = vi.fn(); - render(); - - fireEvent.change(screen.getByTestId('bl-register-name'), { target: { value: 'Alice' } }); - fireEvent.change(screen.getByTestId('bl-register-email'), { - target: { value: 'alice@example.com' }, - }); - fireEvent.change(screen.getByTestId('bl-register-password'), { - target: { value: 'Password1!' }, - }); - fireEvent.change(screen.getByTestId('bl-register-confirm'), { - target: { value: 'Password1!' }, - }); - fireEvent.submit(screen.getByTestId('bl-register-submit').closest('form')!); - - expect(onSubmit).toHaveBeenCalledWith({ - name: 'Alice', - email: 'alice@example.com', - password: 'Password1!', - }); - }); - - it('shows password mismatch error', () => { - render(); - fireEvent.change(screen.getByTestId('bl-register-password'), { - target: { value: 'Password1!' }, - }); - fireEvent.change(screen.getByTestId('bl-register-confirm'), { - target: { value: 'Different1!' }, - }); - - expect(screen.getByTestId('bl-register-mismatch')).toBeDefined(); - expect(screen.getByText('Passwords do not match')).toBeDefined(); - }); - - it('displays error message', () => { - render(); - expect(screen.getByTestId('bl-register-error')).toBeDefined(); - expect(screen.getByText('Email already taken')).toBeDefined(); - }); - - it('shows terms checkbox when termsUrl provided', () => { - render(); - expect(screen.getByTestId('bl-register-terms')).toBeDefined(); - expect(screen.getByText('Terms of Service')).toBeDefined(); - }); - - it('renders switch to login link', () => { - const onSwitch = vi.fn(); - render(); - const link = screen.getByTestId('bl-register-switch-login'); - fireEvent.click(link); - expect(onSwitch).toHaveBeenCalledOnce(); - }); - - it('shows loading state', () => { - render(); - expect(screen.getByText('Creating account...')).toBeDefined(); - }); - - it('shows password strength bar when typing', () => { - render(); - fireEvent.change(screen.getByTestId('bl-register-password'), { target: { value: 'ab' } }); - expect(screen.getByTestId('bl-password-strength')).toBeDefined(); - }); -}); - -describe('ForgotPasswordForm', () => { - beforeEach(() => cleanup()); - - it('renders email input and submit', () => { - render(); - expect(screen.getByTestId('bl-forgot-email')).toBeDefined(); - expect(screen.getByTestId('bl-forgot-submit')).toBeDefined(); - }); - - it('calls onSubmit with email', () => { - const onSubmit = vi.fn(); - render(); - fireEvent.change(screen.getByTestId('bl-forgot-email'), { - target: { value: 'test@example.com' }, - }); - fireEvent.submit(screen.getByTestId('bl-forgot-submit').closest('form')!); - expect(onSubmit).toHaveBeenCalledWith('test@example.com'); - }); - - it('displays error message', () => { - render(); - expect(screen.getByTestId('bl-forgot-error')).toBeDefined(); - }); - - it('displays success message', () => { - render(); - expect(screen.getByTestId('bl-forgot-success')).toBeDefined(); - expect(screen.getByText('Check your email')).toBeDefined(); - }); - - it('renders back button and calls onBack', () => { - const onBack = vi.fn(); - render(); - fireEvent.click(screen.getByTestId('bl-forgot-back')); - expect(onBack).toHaveBeenCalledOnce(); - }); - - it('shows loading state', () => { - render(); - expect(screen.getByText('Sending...')).toBeDefined(); - }); -}); - -describe('ResetPasswordForm', () => { - beforeEach(() => cleanup()); - - it('renders password fields and submit', () => { - render(); - expect(screen.getByTestId('bl-reset-password')).toBeDefined(); - expect(screen.getByTestId('bl-reset-confirm')).toBeDefined(); - expect(screen.getByTestId('bl-reset-submit')).toBeDefined(); - }); - - it('calls onSubmit with password', () => { - const onSubmit = vi.fn(); - render(); - fireEvent.change(screen.getByTestId('bl-reset-password'), { target: { value: 'NewPass1!' } }); - fireEvent.change(screen.getByTestId('bl-reset-confirm'), { target: { value: 'NewPass1!' } }); - fireEvent.submit(screen.getByTestId('bl-reset-submit').closest('form')!); - expect(onSubmit).toHaveBeenCalledWith('NewPass1!'); - }); - - it('shows mismatch error', () => { - render(); - fireEvent.change(screen.getByTestId('bl-reset-password'), { target: { value: 'NewPass1!' } }); - fireEvent.change(screen.getByTestId('bl-reset-confirm'), { target: { value: 'Different!' } }); - expect(screen.getByTestId('bl-reset-mismatch')).toBeDefined(); - }); - - it('displays error and success messages', () => { - const { rerender } = render(); - expect(screen.getByTestId('bl-reset-error')).toBeDefined(); - - rerender(); - expect(screen.getByTestId('bl-reset-success')).toBeDefined(); - }); - - it('shows password strength bar', () => { - render(); - fireEvent.change(screen.getByTestId('bl-reset-password'), { target: { value: 'abc' } }); - expect(screen.getByTestId('bl-password-strength')).toBeDefined(); - }); -}); - -describe('VerifyEmailForm', () => { - beforeEach(() => cleanup()); - - it('renders code input and submit', () => { - render(); - expect(screen.getByTestId('bl-verify-code')).toBeDefined(); - expect(screen.getByTestId('bl-verify-submit')).toBeDefined(); - }); - - it('calls onSubmit with code', () => { - const onSubmit = vi.fn(); - render(); - fireEvent.change(screen.getByTestId('bl-verify-code'), { target: { value: '123456' } }); - fireEvent.submit(screen.getByTestId('bl-verify-submit').closest('form')!); - expect(onSubmit).toHaveBeenCalledWith('123456'); - }); - - it('displays email address', () => { - render(); - expect(screen.getByText('test@example.com')).toBeDefined(); - }); - - it('renders resend button', () => { - const onResend = vi.fn(); - render(); - fireEvent.click(screen.getByTestId('bl-verify-resend')); - expect(onResend).toHaveBeenCalledOnce(); - }); - - it('displays error and success messages', () => { - const { rerender } = render(); - expect(screen.getByTestId('bl-verify-error')).toBeDefined(); - - rerender(); - expect(screen.getByTestId('bl-verify-success')).toBeDefined(); - }); - - it('strips non-numeric characters', () => { - render(); - const input = screen.getByTestId('bl-verify-code'); - fireEvent.change(input, { target: { value: 'abc123def456' } }); - expect((input as unknown as { value: string }).value).toBe('123456'); - }); -}); - -describe('OnboardingShell', () => { - beforeEach(() => cleanup()); - - const steps = [ - { key: 'welcome', label: 'Welcome' }, - { key: 'profile', label: 'Profile' }, - { key: 'preferences', label: 'Preferences' }, - ]; - - it('renders steps and content', () => { - render( - -
Step 1 content
-
- ); - expect(screen.getByTestId('bl-onboarding-shell')).toBeDefined(); - expect(screen.getByTestId('bl-onboarding-steps')).toBeDefined(); - expect(screen.getByTestId('bl-onboarding-progress')).toBeDefined(); - expect(screen.getByText('Step 1 content')).toBeDefined(); - expect(screen.getByText('Welcome')).toBeDefined(); - expect(screen.getByText('Profile')).toBeDefined(); - }); - - it('disables Back on first step', () => { - render( - -
- - ); - const back = screen.getByTestId('bl-onboarding-back'); - expect(back.getAttribute('disabled')).toBe(''); - }); - - it('calls onNext on middle step', () => { - const onNext = vi.fn(); - render( - -
- - ); - fireEvent.click(screen.getByTestId('bl-onboarding-next')); - expect(onNext).toHaveBeenCalledOnce(); - }); - - it('shows Complete on last step and calls onComplete', () => { - const onComplete = vi.fn(); - render( - -
- - ); - const btn = screen.getByTestId('bl-onboarding-complete'); - expect(btn.textContent).toBe('Complete'); - fireEvent.click(btn); - expect(onComplete).toHaveBeenCalledOnce(); - }); - - it('calls onBack on non-first step', () => { - const onBack = vi.fn(); - render( - -
- - ); - fireEvent.click(screen.getByTestId('bl-onboarding-back')); - expect(onBack).toHaveBeenCalledOnce(); - }); -}); - -describe('AuthPageLayout', () => { - beforeEach(() => cleanup()); - - it('renders product name and title', () => { - render( - -
Form content
-
- ); - expect(screen.getByTestId('bl-auth-product-name').textContent).toBe('TestApp'); - expect(screen.getByTestId('bl-auth-title').textContent).toBe('Sign In'); - expect(screen.getByText('Form content')).toBeDefined(); - }); - - it('renders subtitle when provided', () => { - render( - -
- - ); - expect(screen.getByTestId('bl-auth-subtitle').textContent).toBe('Welcome back'); - }); - - it('renders logo as element', () => { - render( - Logo} - > -
- - ); - expect(screen.getByTestId('custom-logo')).toBeDefined(); - }); - - it('renders footer', () => { - render( - Footer text}> -
- - ); - expect(screen.getByTestId('bl-auth-footer')).toBeDefined(); - expect(screen.getByText('Footer text')).toBeDefined(); - }); -}); - -describe('PasswordStrengthBar', () => { - beforeEach(() => cleanup()); - - it('returns null for empty password', () => { - const { container } = render(); - expect(container.querySelector('[data-testid="bl-password-strength"]')).toBeNull(); - }); - - it('shows Weak for short password', () => { - render(); - expect(screen.getByTestId('bl-password-strength-label').textContent).toBe('Weak'); - }); - - it('shows Strong for complex password', () => { - render(); - expect(screen.getByTestId('bl-password-strength-label').textContent).toBe('Strong'); - }); -}); - -describe('getPasswordStrength', () => { - it('returns weak for very short passwords', () => { - expect(getPasswordStrength('ab')).toBe('weak'); - }); - - it('returns fair for medium passwords', () => { - expect(getPasswordStrength('abcdefgh1')).toBe('fair'); - }); - - it('returns good for decent passwords', () => { - expect(getPasswordStrength('Abcdefgh1')).toBe('good'); - }); - - it('returns strong for complex passwords', () => { - expect(getPasswordStrength('MyStr0ng!Pass')).toBe('strong'); - }); -}); diff --git a/vendor/bytelyst/auth-ui/src/index.ts b/vendor/bytelyst/auth-ui/src/index.ts deleted file mode 100644 index e24f874..0000000 --- a/vendor/bytelyst/auth-ui/src/index.ts +++ /dev/null @@ -1,24 +0,0 @@ -export { LoginForm } from './LoginForm.js'; -export { RegisterForm } from './RegisterForm.js'; -export { ForgotPasswordForm } from './ForgotPasswordForm.js'; -export { ResetPasswordForm } from './ResetPasswordForm.js'; -export { VerifyEmailForm } from './VerifyEmailForm.js'; -export { MfaChallenge } from './MfaChallenge.js'; -export { SocialButtons } from './SocialButtons.js'; -export { OnboardingShell } from './OnboardingShell.js'; -export { AuthPageLayout } from './AuthPageLayout.js'; -export { PasswordStrengthBar, getPasswordStrength } from './PasswordStrengthBar.js'; -export type { - LoginFormProps, - RegisterFormProps, - ForgotPasswordFormProps, - ResetPasswordFormProps, - VerifyEmailFormProps, - MfaChallengeProps, - SocialButtonsProps, - SocialProvider, - OnboardingShellProps, - OnboardingStep, - AuthPageLayoutProps, - PasswordStrength, -} from './types.js'; diff --git a/vendor/bytelyst/auth-ui/src/types.ts b/vendor/bytelyst/auth-ui/src/types.ts deleted file mode 100644 index 706f44f..0000000 --- a/vendor/bytelyst/auth-ui/src/types.ts +++ /dev/null @@ -1,147 +0,0 @@ -export type SocialProvider = 'google' | 'microsoft' | 'apple'; - -export interface LoginFormProps { - /** Called when user submits email/password. */ - onSubmit: (email: string, password: string) => void; - /** Social providers to display. */ - providers?: SocialProvider[]; - /** Called when user clicks a social login button. */ - onSocialLogin?: (provider: SocialProvider) => void; - /** Whether the form is currently loading. */ - isLoading?: boolean; - /** Error message to display. */ - error?: string | null; - /** Additional CSS class for the root element. */ - className?: string; -} - -export interface MfaChallengeProps { - /** Called when user submits the MFA code. */ - onSubmit: (code: string) => void; - /** Called when user clicks "Use recovery code". */ - onUseRecovery?: () => void; - /** MFA methods available. */ - methods?: string[]; - /** Whether the form is currently loading. */ - isLoading?: boolean; - /** Error message to display. */ - error?: string | null; - /** Additional CSS class for the root element. */ - className?: string; -} - -export interface SocialButtonsProps { - /** Providers to display buttons for. */ - providers: SocialProvider[]; - /** Called when a provider button is clicked. */ - onSelect: (provider: SocialProvider) => void; - /** Whether the buttons are disabled. */ - disabled?: boolean; - /** Additional CSS class for the root element. */ - className?: string; -} - -export interface RegisterFormProps { - /** Called when user submits registration. */ - onSubmit: (data: { name: string; email: string; password: string }) => void; - /** Whether the form is currently loading. */ - isLoading?: boolean; - /** Error message to display. */ - error?: string | null; - /** Terms of service URL (renders checkbox if provided). */ - termsUrl?: string; - /** Privacy policy URL. */ - privacyUrl?: string; - /** Called when user clicks "Already have an account?" */ - onSwitchToLogin?: () => void; - /** Additional CSS class for the root element. */ - className?: string; -} - -export interface ForgotPasswordFormProps { - /** Called when user submits email for password reset. */ - onSubmit: (email: string) => void; - /** Whether the form is currently loading. */ - isLoading?: boolean; - /** Error message to display. */ - error?: string | null; - /** Success message (e.g., "Check your email"). */ - success?: string | null; - /** Called when user clicks "Back to login". */ - onBack?: () => void; - /** Additional CSS class for the root element. */ - className?: string; -} - -export interface ResetPasswordFormProps { - /** Called when user submits new password. */ - onSubmit: (password: string) => void; - /** Whether the form is currently loading. */ - isLoading?: boolean; - /** Error message to display. */ - error?: string | null; - /** Success message (e.g., "Password updated"). */ - success?: string | null; - /** Additional CSS class for the root element. */ - className?: string; -} - -export interface VerifyEmailFormProps { - /** Called when user submits the verification code. */ - onSubmit: (code: string) => void; - /** Called when user clicks "Resend code". */ - onResend?: () => void; - /** Whether the form is currently loading. */ - isLoading?: boolean; - /** Error message to display. */ - error?: string | null; - /** Success message (e.g., "Code resent"). */ - success?: string | null; - /** Email address being verified (for display). */ - email?: string; - /** Additional CSS class for the root element. */ - className?: string; -} - -export interface OnboardingStep { - /** Unique key for the step. */ - key: string; - /** Display label for the step indicator. */ - label: string; -} - -export interface OnboardingShellProps { - /** Ordered list of steps. */ - steps: OnboardingStep[]; - /** Index of the current step (0-based). */ - currentStep: number; - /** Called when user clicks Next. */ - onNext: () => void; - /** Called when user clicks Back. */ - onBack: () => void; - /** Called when the final step completes. */ - onComplete: () => void; - /** Content to render for the current step. */ - children: React.ReactNode; - /** Additional CSS class for the root element. */ - className?: string; -} - -export interface AuthPageLayoutProps { - /** Product name displayed at the top. */ - productName: string; - /** Optional logo element or URL. */ - logo?: React.ReactNode; - /** Page title (e.g., "Sign In", "Create Account"). */ - title: string; - /** Subtitle or description. */ - subtitle?: string; - /** Form content. */ - children: React.ReactNode; - /** Footer content (links, etc.). */ - footer?: React.ReactNode; - /** Additional CSS class for the root element. */ - className?: string; -} - -export type PasswordStrength = 'weak' | 'fair' | 'good' | 'strong'; diff --git a/vendor/bytelyst/auth-ui/tsconfig.json b/vendor/bytelyst/auth-ui/tsconfig.json deleted file mode 100644 index 4447784..0000000 --- a/vendor/bytelyst/auth-ui/tsconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src", - "lib": ["ES2022", "DOM"], - "jsx": "react-jsx" - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"] -} diff --git a/vendor/bytelyst/auth-ui/vitest.config.ts b/vendor/bytelyst/auth-ui/vitest.config.ts deleted file mode 100644 index cf32686..0000000 --- a/vendor/bytelyst/auth-ui/vitest.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - test: { - environment: 'happy-dom', - pool: 'forks', - }, -}); diff --git a/vendor/bytelyst/auth/package.json b/vendor/bytelyst/auth/package.json deleted file mode 100644 index 86164e6..0000000 --- a/vendor/bytelyst/auth/package.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "@bytelyst/auth", - "version": "0.1.5", - "type": "module", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks" - }, - "dependencies": { - "@bytelyst/errors": "workspace:*" - }, - "peerDependencies": { - "jose": ">=5.0.0", - "bcryptjs": ">=2.4.0" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/auth/src/__tests__/auth.test.ts b/vendor/bytelyst/auth/src/__tests__/auth.test.ts deleted file mode 100644 index fbb7384..0000000 --- a/vendor/bytelyst/auth/src/__tests__/auth.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { describe, expect, it, beforeAll, afterAll } from 'vitest'; -import { createJwtUtils, hashPassword, verifyPassword } from '../index.js'; - -describe('JWT utilities', () => { - const SECRET = 'test-jwt-secret-at-least-32-chars-long!!'; - - beforeAll(() => { - process.env.JWT_SECRET = SECRET; - }); - - afterAll(() => { - delete process.env.JWT_SECRET; - }); - - it('creates and verifies an access token', async () => { - const jwt = createJwtUtils({ issuer: 'test-issuer' }); - const token = await jwt.createAccessToken({ - sub: 'user-1', - email: 'test@example.com', - role: 'admin', - }); - - expect(typeof token).toBe('string'); - expect(token.split('.')).toHaveLength(3); - - const payload = await jwt.verifyToken(token); - expect(payload).not.toBeNull(); - expect(payload!.sub).toBe('user-1'); - expect(payload!.email).toBe('test@example.com'); - expect(payload!.role).toBe('admin'); - expect(payload!.type).toBe('access'); - }); - - it('creates and verifies a refresh token', async () => { - const jwt = createJwtUtils({ issuer: 'test-issuer' }); - const token = await jwt.createRefreshToken({ sub: 'user-1' }); - - const payload = await jwt.verifyToken(token); - expect(payload).not.toBeNull(); - expect(payload!.sub).toBe('user-1'); - expect(payload!.type).toBe('refresh'); - }); - - it('returns null for invalid token', async () => { - const jwt = createJwtUtils({ issuer: 'test-issuer' }); - const result = await jwt.verifyToken('garbage.not.valid'); - expect(result).toBeNull(); - }); - - it('returns null for wrong issuer', async () => { - const jwt1 = createJwtUtils({ issuer: 'issuer-a' }); - const jwt2 = createJwtUtils({ issuer: 'issuer-b' }); - - const token = await jwt1.createAccessToken({ - sub: 'u1', - email: 'a@b.com', - role: 'user', - }); - - const result = await jwt2.verifyToken(token); - expect(result).toBeNull(); - }); - - it('sets productId from payload or defaults to issuer', async () => { - const jwt = createJwtUtils({ issuer: 'lysnrai' }); - - const t1 = await jwt.createAccessToken({ - sub: 'u1', - email: 'a@b.com', - role: 'user', - }); - const p1 = await jwt.verifyToken(t1); - expect(p1!.productId).toBe('lysnrai'); - - const t2 = await jwt.createAccessToken({ - sub: 'u1', - email: 'a@b.com', - role: 'user', - productId: 'mindlyst', - }); - const p2 = await jwt.verifyToken(t2); - expect(p2!.productId).toBe('mindlyst'); - }); - - it('respects custom expiry', async () => { - const jwt = createJwtUtils({ - issuer: 'test', - accessTokenExpiry: '2h', - refreshTokenExpiry: '7d', - }); - - const access = await jwt.createAccessToken({ - sub: 'u1', - email: 'a@b.com', - role: 'user', - }); - const refresh = await jwt.createRefreshToken({ sub: 'u1' }); - - expect(typeof access).toBe('string'); - expect(typeof refresh).toBe('string'); - }); - - it('throws when JWT_SECRET is not set', async () => { - const origSecret = process.env.JWT_SECRET; - delete process.env.JWT_SECRET; - - const jwt = createJwtUtils({ issuer: 'test' }); - await expect( - jwt.createAccessToken({ sub: 'u1', email: 'a@b.com', role: 'user' }) - ).rejects.toThrow('JWT_SECRET must be set'); - - process.env.JWT_SECRET = origSecret; - }); -}); - -describe('password hashing', () => { - it('hashes a password and verifies it', async () => { - const hash = await hashPassword('MySecret123!'); - expect(typeof hash).toBe('string'); - expect(hash).not.toBe('MySecret123!'); - - const valid = await verifyPassword('MySecret123!', hash); - expect(valid).toBe(true); - }); - - it('rejects wrong password', async () => { - const hash = await hashPassword('correct-password'); - const valid = await verifyPassword('wrong-password', hash); - expect(valid).toBe(false); - }); - - it('produces different hashes for same input', async () => { - const h1 = await hashPassword('same'); - const h2 = await hashPassword('same'); - expect(h1).not.toBe(h2); // different salts - }); -}); diff --git a/vendor/bytelyst/auth/src/__tests__/e2e-auth-flow.test.ts b/vendor/bytelyst/auth/src/__tests__/e2e-auth-flow.test.ts deleted file mode 100644 index 3d35bbc..0000000 --- a/vendor/bytelyst/auth/src/__tests__/e2e-auth-flow.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -/** - * End-to-end auth flow test: create token → extract auth → require role. - * Exercises the full JWT lifecycle without network calls. - */ -import { describe, expect, it, beforeAll, afterAll } from 'vitest'; -import { - createJwtUtils, - extractAuth, - requireRole, - hashPassword, - verifyPassword, -} from '../index.js'; - -const SECRET = 'e2e-test-jwt-secret-at-least-32-chars!!'; - -describe('E2E auth flow', () => { - beforeAll(() => { - process.env.JWT_SECRET = SECRET; - }); - - afterAll(() => { - delete process.env.JWT_SECRET; - }); - - it('full flow: login credentials → JWT → authenticated request → role check', async () => { - // 1. Simulate password verification (login step) - const storedHash = await hashPassword('SecretPass123!'); - const passwordValid = await verifyPassword('SecretPass123!', storedHash); - expect(passwordValid).toBe(true); - - // 2. Issue access token (platform-service would do this) - const jwt = createJwtUtils({ issuer: 'lysnrai' }); - const accessToken = await jwt.createAccessToken({ - sub: 'user-admin-001', - email: 'admin@lysnrai.com', - role: 'super_admin', - productId: 'lysnrai', - }); - expect(typeof accessToken).toBe('string'); - - // 3. Simulate authenticated request (any service receives this) - const req = { headers: { authorization: `Bearer ${accessToken}` } }; - const auth = await extractAuth(req); - expect(auth.sub).toBe('user-admin-001'); - expect(auth.email).toBe('admin@lysnrai.com'); - expect(auth.role).toBe('super_admin'); - expect(auth.productId).toBe('lysnrai'); - - // 4. Role-gated endpoint check - const adminAuth = await requireRole(req, 'super_admin', 'admin'); - expect(adminAuth.sub).toBe('user-admin-001'); - - // 5. Role rejection for wrong role - await expect(requireRole(req, 'viewer')).rejects.toMatchObject({ - statusCode: 403, - }); - }); - - it('refresh token cannot be used for authenticated requests', async () => { - const jwt = createJwtUtils({ issuer: 'lysnrai' }); - const refreshToken = await jwt.createRefreshToken({ - sub: 'user-001', - productId: 'lysnrai', - }); - - const req = { headers: { authorization: `Bearer ${refreshToken}` } }; - await expect(extractAuth(req)).rejects.toMatchObject({ - statusCode: 401, - message: 'Invalid or expired token', - }); - }); - - it('cross-issuer tokens are rejected by verifyToken but pass extractAuth (no issuer check)', async () => { - // extractAuth only checks type=access via jwtVerify without issuer - // But verifyToken checks issuer — this is the cross-service security model - const jwtA = createJwtUtils({ issuer: 'lysnrai' }); - const jwtB = createJwtUtils({ issuer: 'mindlyst' }); - - const tokenA = await jwtA.createAccessToken({ - sub: 'u1', - email: 'a@b.com', - role: 'user', - }); - - // verifyToken with wrong issuer rejects - const resultB = await jwtB.verifyToken(tokenA); - expect(resultB).toBeNull(); - - // verifyToken with correct issuer passes - const resultA = await jwtA.verifyToken(tokenA); - expect(resultA).not.toBeNull(); - expect(resultA!.sub).toBe('u1'); - }); - - it('wrong password fails login flow before token issuance', async () => { - const storedHash = await hashPassword('CorrectPassword'); - const passwordValid = await verifyPassword('WrongPassword', storedHash); - expect(passwordValid).toBe(false); - // No token should be issued — the flow stops here - }); -}); diff --git a/vendor/bytelyst/auth/src/__tests__/middleware.test.ts b/vendor/bytelyst/auth/src/__tests__/middleware.test.ts deleted file mode 100644 index fb498d3..0000000 --- a/vendor/bytelyst/auth/src/__tests__/middleware.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { describe, expect, it, beforeAll, afterAll } from 'vitest'; -import { createJwtUtils, extractAuth, requireRole } from '../index.js'; - -const SECRET = 'test-jwt-secret-at-least-32-chars-long!!'; -let validAccessToken: string; -let refreshToken: string; - -describe('extractAuth', () => { - beforeAll(async () => { - process.env.JWT_SECRET = SECRET; - const jwt = createJwtUtils({ issuer: 'test-issuer' }); - validAccessToken = await jwt.createAccessToken({ - sub: 'user-1', - email: 'test@example.com', - role: 'admin', - productId: 'lysnrai', - }); - refreshToken = await jwt.createRefreshToken({ sub: 'user-1' }); - }); - - afterAll(() => { - delete process.env.JWT_SECRET; - }); - - it('extracts auth from valid Bearer token', async () => { - const req = { headers: { authorization: `Bearer ${validAccessToken}` } }; - const payload = await extractAuth(req); - expect(payload.sub).toBe('user-1'); - expect(payload.email).toBe('test@example.com'); - expect(payload.role).toBe('admin'); - expect(payload.productId).toBe('lysnrai'); - expect(payload.type).toBe('access'); - }); - - it('throws 401 when no authorization header', async () => { - const req = { headers: {} }; - await expect(extractAuth(req)).rejects.toMatchObject({ - statusCode: 401, - message: 'Unauthorized', - }); - }); - - it('throws 401 when authorization header is not Bearer', async () => { - const req = { headers: { authorization: 'Basic abc123' } }; - await expect(extractAuth(req)).rejects.toMatchObject({ - statusCode: 401, - message: 'Unauthorized', - }); - }); - - it('throws 401 for invalid token', async () => { - const req = { headers: { authorization: 'Bearer garbage.not.valid' } }; - await expect(extractAuth(req)).rejects.toMatchObject({ - statusCode: 401, - message: 'Invalid or expired token', - }); - }); - - it('throws 401 for refresh token (requires access type)', async () => { - const req = { headers: { authorization: `Bearer ${refreshToken}` } }; - await expect(extractAuth(req)).rejects.toMatchObject({ - statusCode: 401, - message: 'Invalid or expired token', - }); - }); - - it('throws 401 for empty Bearer value', async () => { - const req = { headers: { authorization: 'Bearer ' } }; - await expect(extractAuth(req)).rejects.toMatchObject({ - statusCode: 401, - }); - }); -}); - -describe('requireRole', () => { - beforeAll(() => { - process.env.JWT_SECRET = SECRET; - }); - - afterAll(() => { - delete process.env.JWT_SECRET; - }); - - it('passes when role matches', async () => { - const req = { headers: { authorization: `Bearer ${validAccessToken}` } }; - const payload = await requireRole(req, 'admin'); - expect(payload.sub).toBe('user-1'); - expect(payload.role).toBe('admin'); - }); - - it('passes when role is in allowed list', async () => { - const req = { headers: { authorization: `Bearer ${validAccessToken}` } }; - const payload = await requireRole(req, 'viewer', 'admin', 'super_admin'); - expect(payload.role).toBe('admin'); - }); - - it('throws 403 when role does not match', async () => { - const req = { headers: { authorization: `Bearer ${validAccessToken}` } }; - await expect(requireRole(req, 'super_admin')).rejects.toMatchObject({ - statusCode: 403, - message: 'Insufficient permissions', - }); - }); - - it('passes with no roles specified (any authenticated user)', async () => { - const req = { headers: { authorization: `Bearer ${validAccessToken}` } }; - const payload = await requireRole(req); - expect(payload.sub).toBe('user-1'); - }); - - it('throws 401 when no auth header (before checking role)', async () => { - const req = { headers: {} }; - await expect(requireRole(req, 'admin')).rejects.toMatchObject({ - statusCode: 401, - }); - }); -}); diff --git a/vendor/bytelyst/auth/src/__tests__/rs256.test.ts b/vendor/bytelyst/auth/src/__tests__/rs256.test.ts deleted file mode 100644 index a96832c..0000000 --- a/vendor/bytelyst/auth/src/__tests__/rs256.test.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { describe, expect, it, beforeAll, afterAll } from 'vitest'; -import { generateKeyPair } from 'jose'; -import { createJwtUtils } from '../index.js'; - -describe('JWT RS256 support (Phase 4C)', () => { - const SECRET = 'test-jwt-secret-at-least-32-chars-long!!'; - let rsaPrivateKey: string; - let rsaPublicKey: string; - - beforeAll(async () => { - process.env.JWT_SECRET = SECRET; - - // Generate RSA key pair for testing (extractable required for PEM export in jose v6) - const { privateKey, publicKey } = await generateKeyPair('RS256', { extractable: true }); - const { exportPKCS8, exportSPKI } = await import('jose'); - rsaPrivateKey = await exportPKCS8(privateKey); - rsaPublicKey = await exportSPKI(publicKey); - }); - - afterAll(() => { - delete process.env.JWT_SECRET; - }); - - it('signs and verifies tokens with RS256', async () => { - const jwt = createJwtUtils({ - issuer: 'test-rs256', - algorithm: 'RS256', - rsaPrivateKey, - rsaPublicKey, - }); - - const token = await jwt.createAccessToken({ - sub: 'user-1', - email: 'rs256@test.com', - role: 'admin', - }); - - expect(typeof token).toBe('string'); - expect(token.split('.')).toHaveLength(3); - - const payload = await jwt.verifyToken(token); - expect(payload).not.toBeNull(); - expect(payload!.sub).toBe('user-1'); - expect(payload!.email).toBe('rs256@test.com'); - expect(payload!.type).toBe('access'); - }); - - it('dual verify: RS256 verifier can fall back to HS256 tokens', async () => { - // Create an HS256 token (simulates old tokens during migration) - const hs256Jwt = createJwtUtils({ issuer: 'dual-test' }); - const hs256Token = await hs256Jwt.createAccessToken({ - sub: 'u-old', - email: 'old@test.com', - role: 'user', - }); - - // Verify with RS256-configured jwt (should fall back to HS256) - const dualJwt = createJwtUtils({ - issuer: 'dual-test', - algorithm: 'RS256', - rsaPrivateKey, - rsaPublicKey, - }); - - const payload = await dualJwt.verifyToken(hs256Token); - expect(payload).not.toBeNull(); - expect(payload!.sub).toBe('u-old'); - expect(payload!.email).toBe('old@test.com'); - }); - - it('RS256 token is NOT verified by HS256-only verifier with different issuer', async () => { - const rs256Jwt = createJwtUtils({ - issuer: 'rs256-only', - algorithm: 'RS256', - rsaPrivateKey, - rsaPublicKey, - }); - - const token = await rs256Jwt.createAccessToken({ - sub: 'u1', - email: 'a@b.com', - role: 'user', - }); - - // HS256-only verifier with different issuer should reject - const hs256Jwt = createJwtUtils({ issuer: 'different-issuer' }); - const result = await hs256Jwt.verifyToken(token); - expect(result).toBeNull(); - }); - - it('RS256 refresh token works', async () => { - const jwt = createJwtUtils({ - issuer: 'test-rs256', - algorithm: 'RS256', - rsaPrivateKey, - rsaPublicKey, - }); - - const token = await jwt.createRefreshToken({ sub: 'user-1' }); - - const payload = await jwt.verifyToken(token); - expect(payload).not.toBeNull(); - expect(payload!.sub).toBe('user-1'); - expect(payload!.type).toBe('refresh'); - }); - - it('throws when RS256 signing without private key', async () => { - const jwt = createJwtUtils({ - issuer: 'test-no-key', - algorithm: 'RS256', - rsaPublicKey, // public only, no private - }); - - await expect( - jwt.createAccessToken({ sub: 'u1', email: 'a@b.com', role: 'user' }) - ).rejects.toThrow('rsaPrivateKey is required'); - }); - - it('HS256 still works as default (backward compatible)', async () => { - const jwt = createJwtUtils({ issuer: 'hs256-compat' }); - - const token = await jwt.createAccessToken({ - sub: 'u1', - email: 'compat@test.com', - role: 'user', - }); - - const payload = await jwt.verifyToken(token); - expect(payload).not.toBeNull(); - expect(payload!.sub).toBe('u1'); - expect(payload!.email).toBe('compat@test.com'); - }); -}); diff --git a/vendor/bytelyst/auth/src/index.ts b/vendor/bytelyst/auth/src/index.ts deleted file mode 100644 index f00bf95..0000000 --- a/vendor/bytelyst/auth/src/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { createJwtUtils } from './jwt.js'; -export { extractAuth, requireRole } from './middleware.js'; -export { hashPassword, verifyPassword } from './password.js'; -export { getCurrentUser } from './server-auth.js'; -export type { TokenPayload, AuthPayload, JwtUtilsOptions, JwtUtils } from './types.js'; diff --git a/vendor/bytelyst/auth/src/jwt.ts b/vendor/bytelyst/auth/src/jwt.ts deleted file mode 100644 index 57f8bb1..0000000 --- a/vendor/bytelyst/auth/src/jwt.ts +++ /dev/null @@ -1,176 +0,0 @@ -/** - * JWT utilities — configurable issuer, expiry, and algorithm. - * Supports HS256 (symmetric, default) and RS256 (asymmetric) via jose. - * - * RS256 mode (Phase 4C SmartAuth): - * - Sign with RSA private key (PEM) - * - Verify with RSA public key (PEM) or remote JWKS URL - * - Dual verification: tries RS256 first, falls back to HS256 during migration - */ - -import { - SignJWT, - jwtVerify, - importPKCS8, - importSPKI, - createRemoteJWKSet, - type CryptoKey as JoseCryptoKey, -} from 'jose'; -import type { JwtUtils, JwtUtilsOptions, TokenPayload } from './types.js'; - -function getHmacSecret(): Uint8Array { - const secret = process.env.JWT_SECRET; - if (!secret) throw new Error('JWT_SECRET must be set'); - return new TextEncoder().encode(secret); -} - -/** - * Create a JWT utility set with the given issuer and expiry configuration. - * - * @example - * ```ts - * // HS256 (default, backward-compatible) - * const jwt = createJwtUtils({ issuer: "bytelyst-platform" }); - * - * // RS256 (SmartAuth Phase 4C) - * const jwt = createJwtUtils({ - * issuer: "bytelyst-platform", - * algorithm: "RS256", - * rsaPrivateKey: process.env.JWT_PRIVATE_KEY, - * rsaPublicKey: process.env.JWT_PUBLIC_KEY, - * }); - * - * // RS256 verify-only (product backends — no private key) - * const jwt = createJwtUtils({ - * issuer: "bytelyst-platform", - * algorithm: "RS256", - * jwksUrl: "https://api.bytelyst.com/auth/.well-known/jwks.json", - * }); - * ``` - */ -export function createJwtUtils(options: JwtUtilsOptions): JwtUtils { - const { - issuer, - accessTokenExpiry = '1h', - refreshTokenExpiry = '30d', - algorithm = 'HS256', - rsaPrivateKey, - rsaPublicKey, - jwksUrl, - } = options; - - // ── Key caches ──────────────────────────────────── - - let _rsaPrivateKeyObj: JoseCryptoKey | null = null; - let _rsaPublicKeyObj: JoseCryptoKey | null = null; - let _jwksKeySet: ReturnType | null = null; - - async function getRsaPrivateKey(): Promise { - if (_rsaPrivateKeyObj) return _rsaPrivateKeyObj; - if (!rsaPrivateKey) throw new Error('rsaPrivateKey is required for RS256 signing'); - _rsaPrivateKeyObj = (await importPKCS8(rsaPrivateKey, 'RS256')) as JoseCryptoKey; - return _rsaPrivateKeyObj; - } - - async function getRsaPublicKey(): Promise { - if (_rsaPublicKeyObj) return _rsaPublicKeyObj; - if (!rsaPublicKey) throw new Error('rsaPublicKey is required for RS256 local verification'); - _rsaPublicKeyObj = (await importSPKI(rsaPublicKey, 'RS256')) as JoseCryptoKey; - return _rsaPublicKeyObj; - } - - function getJwksKeySet(): ReturnType { - if (_jwksKeySet) return _jwksKeySet; - if (!jwksUrl) throw new Error('jwksUrl is required for remote JWKS verification'); - _jwksKeySet = createRemoteJWKSet(new URL(jwksUrl)); - return _jwksKeySet; - } - - // ── Signing ─────────────────────────────────────── - - async function sign(claims: Record, expiry: string): Promise { - if (algorithm === 'RS256') { - const key = await getRsaPrivateKey(); - return new SignJWT(claims) - .setProtectedHeader({ alg: 'RS256' }) - .setIssuedAt() - .setExpirationTime(expiry) - .setIssuer(issuer) - .sign(key); - } - return new SignJWT(claims) - .setProtectedHeader({ alg: 'HS256' }) - .setIssuedAt() - .setExpirationTime(expiry) - .setIssuer(issuer) - .sign(getHmacSecret()); - } - - // ── Verification (dual: RS256 first, HS256 fallback) ── - - async function verifyWithRS256(token: string): Promise { - try { - if (jwksUrl) { - const keySet = getJwksKeySet(); - const { payload } = await jwtVerify(token, keySet, { issuer }); - return payload as unknown as TokenPayload; - } - if (rsaPublicKey) { - const key = await getRsaPublicKey(); - const { payload } = await jwtVerify(token, key, { issuer }); - return payload as unknown as TokenPayload; - } - return null; - } catch { - return null; - } - } - - async function verifyWithHS256(token: string): Promise { - try { - const secret = getHmacSecret(); - const { payload } = await jwtVerify(token, secret, { issuer }); - return payload as unknown as TokenPayload; - } catch { - return null; - } - } - - return { - async createAccessToken(payload) { - return sign( - { - ...payload, - productId: payload.productId || issuer, - type: 'access', - }, - accessTokenExpiry - ); - }, - - async createRefreshToken(payload) { - return sign( - { - sub: payload.sub, - productId: payload.productId || issuer, - type: 'refresh', - }, - refreshTokenExpiry - ); - }, - - async verifyToken(token: string) { - // Dual verification: try RS256 first (if configured), then HS256 fallback - if (algorithm === 'RS256' || jwksUrl || rsaPublicKey) { - const rs256Result = await verifyWithRS256(token); - if (rs256Result) return rs256Result; - } - // HS256 fallback (safe during migration; removed after full RS256 rollout) - try { - return await verifyWithHS256(token); - } catch { - return null; - } - }, - }; -} diff --git a/vendor/bytelyst/auth/src/middleware.ts b/vendor/bytelyst/auth/src/middleware.ts deleted file mode 100644 index 3cbdf5f..0000000 --- a/vendor/bytelyst/auth/src/middleware.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * Fastify auth middleware — validates JWT tokens from Authorization headers. - */ - -import { jwtVerify } from 'jose'; -import { UnauthorizedError, ForbiddenError } from '@bytelyst/errors'; -import type { AuthPayload } from './types.js'; - -function getSecret(): Uint8Array { - const secret = process.env.JWT_SECRET; - if (!secret) throw new Error('JWT_SECRET must be set'); - return new TextEncoder().encode(secret); -} - -/** - * Extract and verify auth payload from an Authorization header. - * Works with any request-like object that has headers.authorization. - * - * @throws Error with message "Unauthorized" if no valid Bearer token - * @throws Error with message "Invalid or expired token" if verification fails - */ -export async function extractAuth(req: { - headers: { authorization?: string }; -}): Promise { - const auth = req.headers.authorization; - if (!auth?.startsWith('Bearer ')) { - throw new UnauthorizedError(); - } - const token = auth.slice(7); - try { - const { payload } = await jwtVerify(token, getSecret()); - const p = payload as unknown as AuthPayload; - if (p.type !== 'access') throw new Error('Not an access token'); - return p; - } catch { - throw new UnauthorizedError('Invalid or expired token'); - } -} - -/** - * Require specific roles. Extracts auth first, then checks role. - * - * @throws Error with statusCode 403 if role doesn't match - */ -export async function requireRole( - req: { headers: { authorization?: string } }, - ...roles: string[] -): Promise { - const payload = await extractAuth(req); - if (roles.length > 0 && (!payload.role || !roles.includes(payload.role))) { - throw new ForbiddenError('Insufficient permissions'); - } - return payload; -} diff --git a/vendor/bytelyst/auth/src/password.ts b/vendor/bytelyst/auth/src/password.ts deleted file mode 100644 index 296a885..0000000 --- a/vendor/bytelyst/auth/src/password.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Password hashing utilities using bcryptjs. - */ - -import bcrypt from 'bcryptjs'; - -const SALT_ROUNDS = 12; - -export async function hashPassword(plain: string): Promise { - return bcrypt.hash(plain, SALT_ROUNDS); -} - -export async function verifyPassword(plain: string, hash: string): Promise { - return bcrypt.compare(plain, hash); -} diff --git a/vendor/bytelyst/auth/src/server-auth.ts b/vendor/bytelyst/auth/src/server-auth.ts deleted file mode 100644 index 1c4668a..0000000 --- a/vendor/bytelyst/auth/src/server-auth.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Server-side auth helpers for Next.js API routes. - */ - -import type { TokenPayload } from './types.js'; - -/** - * Get the current user from an Authorization header value. - * Pairs with a verifyToken function and a getUserById function. - * - * @param authHeader - The Authorization header value (e.g., "Bearer xxx") - * @param verifyToken - Function to verify the JWT and return a payload - * @param getUserById - Function to look up the user by their ID - * @returns The user object or null if auth fails - */ -export async function getCurrentUser( - authHeader: string | null, - verifyToken: (token: string) => Promise, - getUserById: (id: string) => Promise -): Promise { - if (!authHeader?.startsWith('Bearer ')) return null; - const token = authHeader.slice(7); - const payload = await verifyToken(token); - if (!payload || payload.type !== 'access') return null; - return getUserById(payload.sub); -} diff --git a/vendor/bytelyst/auth/src/types.ts b/vendor/bytelyst/auth/src/types.ts deleted file mode 100644 index 6455fbe..0000000 --- a/vendor/bytelyst/auth/src/types.ts +++ /dev/null @@ -1,41 +0,0 @@ -export interface TokenPayload { - sub: string; - email?: string; - role?: string; - productId?: string; - type?: 'access' | 'refresh'; - [key: string]: unknown; -} - -export interface AuthPayload { - sub: string; - email?: string; - role?: string; - productId?: string; - type?: string; -} - -export interface JwtUtilsOptions { - issuer: string; - accessTokenExpiry?: string; - refreshTokenExpiry?: string; - /** JWT signing algorithm. Default: 'HS256'. Set to 'RS256' for asymmetric. */ - algorithm?: 'HS256' | 'RS256'; - /** RSA private key (PEM) for RS256 signing. Required when algorithm is 'RS256'. */ - rsaPrivateKey?: string; - /** RSA public key (PEM) for RS256 verification. Used when algorithm is 'RS256'. */ - rsaPublicKey?: string; - /** Remote JWKS URL for RS256 verification (e.g. platform-service /.well-known/jwks.json). */ - jwksUrl?: string; -} - -export interface JwtUtils { - createAccessToken(payload: { - sub: string; - email: string; - role: string; - productId?: string; - }): Promise; - createRefreshToken(payload: { sub: string; productId?: string }): Promise; - verifyToken(token: string): Promise; -} diff --git a/vendor/bytelyst/auth/tsconfig.json b/vendor/bytelyst/auth/tsconfig.json deleted file mode 100644 index 5edad81..0000000 --- a/vendor/bytelyst/auth/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/vendor/bytelyst/auth/vitest.config.ts b/vendor/bytelyst/auth/vitest.config.ts deleted file mode 100644 index 03ac558..0000000 --- a/vendor/bytelyst/auth/vitest.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - test: { - pool: 'forks', - testTimeout: 15_000, - }, -}); diff --git a/vendor/bytelyst/backend-config/package.json b/vendor/bytelyst/backend-config/package.json deleted file mode 100644 index fe64461..0000000 --- a/vendor/bytelyst/backend-config/package.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "name": "@bytelyst/backend-config", - "version": "0.1.5", - "description": "Shared Zod config schema base for Fastify product backends", - "type": "module", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" - } - }, - "scripts": { - "build": "tsc", - "typecheck": "tsc --noEmit", - "test": "vitest run --pool forks", - "clean": "rm -rf dist" - }, - "dependencies": { - "zod": "^3.24.2" - }, - "devDependencies": { - "typescript": "^5.7.3", - "vitest": "^3.0.5" - }, - "files": [ - "dist" - ], - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/backend-config/src/index.test.ts b/vendor/bytelyst/backend-config/src/index.test.ts deleted file mode 100644 index 61ab6b5..0000000 --- a/vendor/bytelyst/backend-config/src/index.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { baseBackendConfigSchema, parseBackendConfig } from './index.js'; - -describe('baseBackendConfigSchema', () => { - it('parses minimal valid env', () => { - const config = baseBackendConfigSchema.parse({ - JWT_SECRET: 'test-secret', - }); - expect(config.PORT).toBe(3000); - expect(config.HOST).toBe('0.0.0.0'); - expect(config.NODE_ENV).toBe('development'); - expect(config.DB_PROVIDER).toBe('cosmos'); - expect(config.COSMOS_DATABASE).toBe('lysnrai'); - expect(config.JWT_SECRET).toBe('test-secret'); - expect(config.PLATFORM_JWKS_URL).toBeUndefined(); - }); - - it('applies overrides', () => { - const config = baseBackendConfigSchema.parse({ - PORT: '4010', - NODE_ENV: 'production', - DB_PROVIDER: 'memory', - JWT_SECRET: 'prod-secret', - PLATFORM_JWKS_URL: 'https://example.com/.well-known/jwks.json', - }); - expect(config.PORT).toBe(4010); - expect(config.NODE_ENV).toBe('production'); - expect(config.DB_PROVIDER).toBe('memory'); - expect(config.PLATFORM_JWKS_URL).toBe('https://example.com/.well-known/jwks.json'); - }); - - it('rejects missing JWT_SECRET', () => { - expect(() => baseBackendConfigSchema.parse({})).toThrow(); - }); - - it('rejects invalid NODE_ENV', () => { - expect(() => baseBackendConfigSchema.parse({ JWT_SECRET: 's', NODE_ENV: 'staging' })).toThrow(); - }); - - it('rejects invalid DB_PROVIDER', () => { - expect(() => - baseBackendConfigSchema.parse({ JWT_SECRET: 's', DB_PROVIDER: 'postgres' }) - ).toThrow(); - }); -}); - -describe('baseBackendConfigSchema.extend()', () => { - const extendedSchema = baseBackendConfigSchema.extend({ - PLATFORM_SERVICE_URL: baseBackendConfigSchema.shape.HOST.default('http://localhost:4003'), - CUSTOM_FLAG: baseBackendConfigSchema.shape.NODE_ENV.optional(), - }); - - it('parses extended config with product-specific fields', () => { - const config = extendedSchema.parse({ - JWT_SECRET: 'test-secret', - PORT: '4018', - SERVICE_NAME: 'actiontrail-backend', - }); - expect(config.PORT).toBe(4018); - expect(config.SERVICE_NAME).toBe('actiontrail-backend'); - expect(config.PLATFORM_SERVICE_URL).toBe('http://localhost:4003'); - }); -}); - -describe('parseBackendConfig', () => { - it('parses from explicit env object', () => { - const config = parseBackendConfig(baseBackendConfigSchema, { - JWT_SECRET: 'from-env', - PORT: '9999', - }); - expect(config.JWT_SECRET).toBe('from-env'); - expect(config.PORT).toBe(9999); - }); - - it('works with extended schemas', () => { - const schema = baseBackendConfigSchema.extend({ - WEBHOOK_SECRET: baseBackendConfigSchema.shape.HOST.default('dev-webhook'), - }); - const config = parseBackendConfig(schema, { JWT_SECRET: 'x' }); - expect(config.WEBHOOK_SECRET).toBe('dev-webhook'); - }); -}); diff --git a/vendor/bytelyst/backend-config/src/index.ts b/vendor/bytelyst/backend-config/src/index.ts deleted file mode 100644 index ae8011c..0000000 --- a/vendor/bytelyst/backend-config/src/index.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { z } from 'zod'; - -/** - * Base Zod schema shared by all product backends. - * - * Products extend this with `.extend({...})` to add product-specific fields. - * The base covers: server, CORS, Cosmos DB, JWT auth, DB provider. - */ -export const baseBackendConfigSchema = z.object({ - PORT: z.coerce.number().default(3000), - HOST: z.string().default('0.0.0.0'), - NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), - CORS_ORIGIN: z.string().optional(), - SERVICE_NAME: z.string().default('backend'), - - DB_PROVIDER: z.enum(['cosmos', 'memory']).default('cosmos'), - COSMOS_ENDPOINT: z.string().default(''), - COSMOS_KEY: z.string().default(''), - COSMOS_DATABASE: z.string().default('lysnrai'), - - JWT_SECRET: z.string().min(1, 'JWT_SECRET is required'), - PLATFORM_JWKS_URL: z.string().url().optional(), -}); - -export type BaseBackendConfig = z.infer; - -/** - * Parse and validate backend config from process.env. - * - * @param schema — Zod object schema (typically `baseBackendConfigSchema.extend({...})`) - * @param env — environment object (defaults to process.env) - * @returns Validated, typed config - */ -export function parseBackendConfig( - schema: z.ZodObject, - env: Record = process.env as Record -): z.infer> { - return schema.parse(env); -} diff --git a/vendor/bytelyst/backend-config/tsconfig.json b/vendor/bytelyst/backend-config/tsconfig.json deleted file mode 100644 index 01c4d9a..0000000 --- a/vendor/bytelyst/backend-config/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src", - "declaration": true - }, - "include": ["src"] -} diff --git a/vendor/bytelyst/backend-flags/package.json b/vendor/bytelyst/backend-flags/package.json deleted file mode 100644 index 96f1aff..0000000 --- a/vendor/bytelyst/backend-flags/package.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "@bytelyst/backend-flags", - "version": "0.1.5", - "description": "In-memory feature flag registry for Fastify product backends", - "type": "module", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" - } - }, - "scripts": { - "build": "tsc", - "typecheck": "tsc --noEmit", - "test": "vitest run --pool forks", - "clean": "rm -rf dist" - }, - "devDependencies": { - "typescript": "^5.7.3", - "vitest": "^3.0.5" - }, - "files": [ - "dist" - ], - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/backend-flags/src/index.test.ts b/vendor/bytelyst/backend-flags/src/index.test.ts deleted file mode 100644 index fa5aba5..0000000 --- a/vendor/bytelyst/backend-flags/src/index.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { createFlagRegistry } from './index.js'; - -describe('createFlagRegistry', () => { - it('returns default flag values', () => { - const registry = createFlagRegistry({ - defaults: { 'feature.a': true, 'feature.b': false }, - }); - expect(registry.isFeatureEnabled('feature.a')).toBe(true); - expect(registry.isFeatureEnabled('feature.b')).toBe(false); - }); - - it('returns false for unknown flags', () => { - const registry = createFlagRegistry({ defaults: {} }); - expect(registry.isFeatureEnabled('nonexistent')).toBe(false); - }); - - it('getAllFlags returns all defaults', () => { - const registry = createFlagRegistry({ - defaults: { a: true, b: false, c: true }, - }); - expect(registry.getAllFlags()).toEqual({ a: true, b: false, c: true }); - }); - - it('setFlag overrides a value', () => { - const registry = createFlagRegistry({ defaults: { x: false } }); - expect(registry.isFeatureEnabled('x')).toBe(false); - registry.setFlag('x', true); - expect(registry.isFeatureEnabled('x')).toBe(true); - }); - - it('setFlag creates new flags', () => { - const registry = createFlagRegistry({ defaults: {} }); - registry.setFlag('new.flag', true); - expect(registry.isFeatureEnabled('new.flag')).toBe(true); - expect(registry.getAllFlags()).toEqual({ 'new.flag': true }); - }); - - it('accepts userId parameter without error', () => { - const registry = createFlagRegistry({ defaults: { a: true } }); - expect(registry.isFeatureEnabled('a', 'user-1')).toBe(true); - }); -}); diff --git a/vendor/bytelyst/backend-flags/src/index.ts b/vendor/bytelyst/backend-flags/src/index.ts deleted file mode 100644 index 30ad476..0000000 --- a/vendor/bytelyst/backend-flags/src/index.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * In-memory feature flag registry for product backends. - * - * Products call createFlagRegistry() with their default flags, - * then use isFeatureEnabled/getAllFlags/setFlag as needed. - */ - -export interface FlagRegistry { - isFeatureEnabled(flag: string, userId?: string): boolean; - getAllFlags(): Record; - setFlag(flag: string, value: boolean): void; -} - -export interface FlagRegistryOptions { - /** Default flag values. */ - defaults: Record; - /** Master switch — when false, flags are still resolved from defaults but - * the registry won't attempt remote/dynamic flag resolution (future use). */ - enabled?: boolean; -} - -export function createFlagRegistry(opts: FlagRegistryOptions): FlagRegistry { - const flags: Map = new Map(Object.entries(opts.defaults)); - - return { - isFeatureEnabled(flag: string, _userId?: string): boolean { - return flags.get(flag) ?? false; - }, - - getAllFlags(): Record { - return Object.fromEntries(flags); - }, - - setFlag(flag: string, value: boolean): void { - flags.set(flag, value); - }, - }; -} diff --git a/vendor/bytelyst/backend-flags/tsconfig.json b/vendor/bytelyst/backend-flags/tsconfig.json deleted file mode 100644 index 01c4d9a..0000000 --- a/vendor/bytelyst/backend-flags/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src", - "declaration": true - }, - "include": ["src"] -} diff --git a/vendor/bytelyst/backend-telemetry/package.json b/vendor/bytelyst/backend-telemetry/package.json deleted file mode 100644 index 64e2400..0000000 --- a/vendor/bytelyst/backend-telemetry/package.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "@bytelyst/backend-telemetry", - "version": "0.1.5", - "description": "In-memory telemetry event buffer for Fastify product backends", - "type": "module", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" - } - }, - "scripts": { - "build": "tsc", - "typecheck": "tsc --noEmit", - "test": "vitest run --pool forks", - "clean": "rm -rf dist" - }, - "devDependencies": { - "typescript": "^5.7.3", - "vitest": "^3.0.5" - }, - "files": [ - "dist" - ], - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/backend-telemetry/src/index.test.ts b/vendor/bytelyst/backend-telemetry/src/index.test.ts deleted file mode 100644 index ad15d9e..0000000 --- a/vendor/bytelyst/backend-telemetry/src/index.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { createTelemetryBuffer } from './index.js'; - -describe('createTelemetryBuffer', () => { - it('buffers events when enabled', () => { - const buf = createTelemetryBuffer({ enabled: true }); - buf.trackEvent('test.event', 'user-1', { key: 'val' }); - const events = buf.getBufferedEvents(); - expect(events).toHaveLength(1); - expect(events[0].event).toBe('test.event'); - expect(events[0].userId).toBe('user-1'); - expect(events[0].properties).toEqual({ key: 'val' }); - expect(events[0].timestamp).toBeDefined(); - }); - - it('is a no-op when disabled', () => { - const buf = createTelemetryBuffer({ enabled: false }); - buf.trackEvent('test.event', 'user-1'); - expect(buf.getBufferedEvents()).toHaveLength(0); - }); - - it('flushEvents returns and clears buffer', () => { - const buf = createTelemetryBuffer({ enabled: true }); - buf.trackEvent('a'); - buf.trackEvent('b'); - const flushed = buf.flushEvents(); - expect(flushed).toHaveLength(2); - expect(buf.getBufferedEvents()).toHaveLength(0); - }); - - it('getBufferedEvents returns a copy', () => { - const buf = createTelemetryBuffer({ enabled: true }); - buf.trackEvent('a'); - const copy = buf.getBufferedEvents(); - copy.push({ event: 'fake' }); - expect(buf.getBufferedEvents()).toHaveLength(1); - }); - - it('handles missing optional fields', () => { - const buf = createTelemetryBuffer({ enabled: true }); - buf.trackEvent('minimal'); - const events = buf.getBufferedEvents(); - expect(events[0].userId).toBeUndefined(); - expect(events[0].properties).toBeUndefined(); - }); -}); diff --git a/vendor/bytelyst/backend-telemetry/src/index.ts b/vendor/bytelyst/backend-telemetry/src/index.ts deleted file mode 100644 index ffd9cfa..0000000 --- a/vendor/bytelyst/backend-telemetry/src/index.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * In-memory telemetry event buffer for product backends. - * - * Products call createTelemetryBuffer() with an enabled flag, - * then use trackEvent/getBufferedEvents/flushEvents as needed. - */ - -export interface TelemetryEvent { - event: string; - userId?: string; - properties?: Record; - timestamp?: string; -} - -export interface TelemetryBuffer { - trackEvent(event: string, userId?: string, properties?: Record): void; - getBufferedEvents(): TelemetryEvent[]; - flushEvents(): TelemetryEvent[]; -} - -export interface TelemetryBufferOptions { - /** Master switch — when false, trackEvent is a no-op. */ - enabled: boolean; -} - -export function createTelemetryBuffer(opts: TelemetryBufferOptions): TelemetryBuffer { - const buffer: TelemetryEvent[] = []; - - return { - trackEvent(event: string, userId?: string, properties?: Record): void { - if (!opts.enabled) return; - buffer.push({ - event, - userId, - properties, - timestamp: new Date().toISOString(), - }); - }, - - getBufferedEvents(): TelemetryEvent[] { - return [...buffer]; - }, - - flushEvents(): TelemetryEvent[] { - const flushed = [...buffer]; - buffer.length = 0; - return flushed; - }, - }; -} diff --git a/vendor/bytelyst/backend-telemetry/tsconfig.json b/vendor/bytelyst/backend-telemetry/tsconfig.json deleted file mode 100644 index 01c4d9a..0000000 --- a/vendor/bytelyst/backend-telemetry/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src", - "declaration": true - }, - "include": ["src"] -} diff --git a/vendor/bytelyst/billing-client/.gitignore b/vendor/bytelyst/billing-client/.gitignore deleted file mode 100644 index aa1ec1e..0000000 --- a/vendor/bytelyst/billing-client/.gitignore +++ /dev/null @@ -1 +0,0 @@ -*.tgz diff --git a/vendor/bytelyst/billing-client/package.json b/vendor/bytelyst/billing-client/package.json deleted file mode 100644 index 7b872b6..0000000 --- a/vendor/bytelyst/billing-client/package.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "name": "@bytelyst/billing-client", - "version": "0.1.0", - "type": "module", - "description": "Browser/React Native-safe billing and subscription client for platform-service — plans, subscriptions, payments, and usage", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks" - }, - "devDependencies": { - "typescript": "^5.7.3", - "vitest": "^3.0.0" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/billing-client/src/index.test.ts b/vendor/bytelyst/billing-client/src/index.test.ts deleted file mode 100644 index 8a7c05b..0000000 --- a/vendor/bytelyst/billing-client/src/index.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { createBillingClient, BillingApiError } from './index.js'; -import type { BillingClient } from './index.js'; - -function mockFetch(status: number, body: unknown) { - return vi.fn().mockResolvedValue({ - ok: status >= 200 && status < 300, - status, - json: () => Promise.resolve(body), - }); -} - -describe('createBillingClient', () => { - let client: BillingClient; - const config = { - baseUrl: 'http://localhost:4003/api', - productId: 'notelett', - getAccessToken: () => 'test-token', - }; - - beforeEach(() => { - client = createBillingClient(config); - }); - - it('listPlans — returns plans array', async () => { - const plans = [{ name: 'free', displayName: 'Free', price: 0 }]; - globalThis.fetch = mockFetch(200, { plans }); - const result = await client.listPlans(); - expect(result).toEqual(plans); - expect(globalThis.fetch).toHaveBeenCalledWith( - 'http://localhost:4003/api/plans', - expect.objectContaining({ method: 'GET' }) - ); - }); - - it('getPlan — returns single plan', async () => { - const plan = { name: 'pro', displayName: 'Pro', price: 9.99 }; - globalThis.fetch = mockFetch(200, plan); - const result = await client.getPlan('pro'); - expect(result).toEqual(plan); - }); - - it('getSubscription — returns subscription', async () => { - const sub = { id: 'sub_1', plan: 'free', status: 'active' }; - globalThis.fetch = mockFetch(200, sub); - const result = await client.getSubscription(); - expect(result).toEqual(sub); - }); - - it('getSubscription — returns null on 404', async () => { - globalThis.fetch = mockFetch(404, { message: 'Not found' }); - const result = await client.getSubscription(); - expect(result).toBeNull(); - }); - - it('changePlan — sends plan in body', async () => { - const sub = { id: 'sub_1', plan: 'pro', status: 'active' }; - globalThis.fetch = mockFetch(200, sub); - const result = await client.changePlan('pro'); - expect(result.plan).toBe('pro'); - expect(globalThis.fetch).toHaveBeenCalledWith( - 'http://localhost:4003/api/subscriptions/me/change-plan', - expect.objectContaining({ - method: 'POST', - body: JSON.stringify({ plan: 'pro' }), - }) - ); - }); - - it('cancelSubscription — POST to cancel endpoint', async () => { - const sub = { id: 'sub_1', plan: 'pro', cancelAtPeriodEnd: true }; - globalThis.fetch = mockFetch(200, sub); - const result = await client.cancelSubscription(); - expect(result.cancelAtPeriodEnd).toBe(true); - }); - - it('resumeSubscription — POST to resume endpoint', async () => { - const sub = { id: 'sub_1', plan: 'pro', cancelAtPeriodEnd: false }; - globalThis.fetch = mockFetch(200, sub); - const result = await client.resumeSubscription(); - expect(result.cancelAtPeriodEnd).toBe(false); - }); - - it('listPayments — returns payments array', async () => { - const payments = [{ id: 'pay_1', amount: 999, status: 'succeeded' }]; - globalThis.fetch = mockFetch(200, { payments }); - const result = await client.listPayments(); - expect(result).toEqual(payments); - }); - - it('getUsage — returns usage summary', async () => { - const usage = { tokensUsed: 500, tokensIncluded: 10000, tokensRemaining: 9500, percentUsed: 5 }; - globalThis.fetch = mockFetch(200, usage); - const result = await client.getUsage(); - expect(result.tokensRemaining).toBe(9500); - }); - - it('sends auth header and product-id header', async () => { - globalThis.fetch = mockFetch(200, { plans: [] }); - await client.listPlans(); - const call = (globalThis.fetch as ReturnType).mock.calls[0]; - const headers = call[1].headers; - expect(headers['Authorization']).toBe('Bearer test-token'); - expect(headers['x-product-id']).toBe('notelett'); - }); - - it('throws BillingApiError on non-ok response', async () => { - globalThis.fetch = mockFetch(403, { message: 'Forbidden' }); - await expect(client.changePlan('enterprise')).rejects.toThrow(BillingApiError); - }); - - it('works without access token', async () => { - const noAuthClient = createBillingClient({ ...config, getAccessToken: () => null }); - globalThis.fetch = mockFetch(200, { plans: [] }); - await noAuthClient.listPlans(); - const headers = (globalThis.fetch as ReturnType).mock.calls[0][1].headers; - expect(headers['Authorization']).toBeUndefined(); - }); -}); diff --git a/vendor/bytelyst/billing-client/src/index.ts b/vendor/bytelyst/billing-client/src/index.ts deleted file mode 100644 index 44db5d8..0000000 --- a/vendor/bytelyst/billing-client/src/index.ts +++ /dev/null @@ -1,214 +0,0 @@ -/** - * @bytelyst/billing-client — Browser/React Native-safe billing client - * - * Wraps platform-service /plans, /subscriptions, /payments, /usage - * endpoints with typed methods. Any ByteLyst product can add billing - * with minimal wiring: - * - * @example - * ```ts - * import { createBillingClient } from '@bytelyst/billing-client'; - * - * const billing = createBillingClient({ - * baseUrl: 'http://localhost:4003/api', - * productId: 'notelett', - * getAccessToken: () => localStorage.getItem('notelett_access_token'), - * }); - * - * const plans = await billing.listPlans(); - * const sub = await billing.getSubscription(); - * await billing.changePlan('pro'); - * ``` - */ - -// ── Types ──────────────────────────────────────────────────── - -export type PlanTier = 'free' | 'pro' | 'enterprise'; -export type SubscriptionStatus = 'active' | 'cancelled' | 'past_due' | 'trialing'; -export type PaymentStatus = 'succeeded' | 'pending' | 'failed' | 'refunded'; - -export interface PlanConfig { - id: string; - productId: string; - name: string; - displayName: string; - price: number; - tokens: number; - words: number; - dictations: number; - features: string[]; - stripePriceId?: string; - active: boolean; - createdAt: string; - updatedAt: string; -} - -export interface Subscription { - id: string; - productId: string; - userId: string; - plan: PlanTier; - status: SubscriptionStatus; - currentPeriodStart: string; - currentPeriodEnd: string; - cancelAtPeriodEnd: boolean; - monthlyPrice: number; - tokensIncluded: number; - tokensUsed: number; - stripeCustomerId?: string; - stripeSubscriptionId?: string; - createdAt: string; - updatedAt: string; -} - -export interface Payment { - id: string; - productId: string; - userId: string; - amount: number; - currency: string; - status: PaymentStatus; - description: string; - method: string; - invoiceUrl?: string; - createdAt: string; -} - -export interface UsageSummary { - tokensUsed: number; - tokensIncluded: number; - tokensRemaining: number; - percentUsed: number; - currentPeriodStart: string; - currentPeriodEnd: string; -} - -// ── Config ─────────────────────────────────────────────────── - -export interface BillingClientConfig { - /** Platform-service base URL (e.g. "http://localhost:4003/api"). */ - baseUrl: string; - /** Product identifier. */ - productId: string; - /** Returns current access token, or null if not authenticated. */ - getAccessToken: () => string | null; - /** Request timeout in ms (default: 15000). */ - timeoutMs?: number; -} - -// ── Client Interface ───────────────────────────────────────── - -export interface BillingClient { - /** List available plans for the product. */ - listPlans(): Promise; - /** Get a specific plan by name. */ - getPlan(planName: string): Promise; - /** Get current user's subscription. Returns null if no subscription. */ - getSubscription(): Promise; - /** Change to a different plan (creates or updates subscription). */ - changePlan(plan: PlanTier): Promise; - /** Cancel subscription at period end. */ - cancelSubscription(): Promise; - /** Resume a cancelled subscription (undo cancel-at-period-end). */ - resumeSubscription(): Promise; - /** List payment history. */ - listPayments(): Promise; - /** Get usage summary for current billing period. */ - getUsage(): Promise; -} - -// ── Errors ─────────────────────────────────────────────────── - -export class BillingApiError extends Error { - constructor( - public readonly status: number, - public readonly body: unknown, - message?: string - ) { - super(message ?? `Billing API error ${status}`); - this.name = 'BillingApiError'; - } -} - -// ── Factory ────────────────────────────────────────────────── - -export function createBillingClient(config: BillingClientConfig): BillingClient { - const { baseUrl, productId, getAccessToken, timeoutMs = 15_000 } = config; - - async function request(method: string, path: string, body?: unknown): Promise { - const headers: Record = { - 'Content-Type': 'application/json', - 'x-product-id': productId, - }; - - const token = getAccessToken(); - if (token) headers['Authorization'] = `Bearer ${token}`; - - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - - try { - const res = await globalThis.fetch(`${baseUrl}${path}`, { - method, - headers, - body: body != null ? JSON.stringify(body) : undefined, - signal: controller.signal, - }); - - if (res.status === 204) return undefined as T; - if (res.status === 404) return null as T; - - const json = await res.json().catch(() => ({})); - - if (!res.ok) { - throw new BillingApiError( - res.status, - json, - (json as Record).message ?? `HTTP ${res.status}` - ); - } - - return json as T; - } finally { - clearTimeout(timer); - } - } - - return { - async listPlans() { - const result = await request<{ plans: PlanConfig[] }>('GET', '/plans'); - return result.plans; - }, - - async getPlan(planName: string) { - return request('GET', `/plans/${encodeURIComponent(planName)}`); - }, - - async getSubscription() { - // Platform-service expects userId in path; the userId comes from the JWT. - // We use a convenience endpoint that reads userId from the token. - return request('GET', '/subscriptions/me'); - }, - - async changePlan(plan: PlanTier) { - return request('POST', '/subscriptions/me/change-plan', { plan }); - }, - - async cancelSubscription() { - return request('POST', '/subscriptions/me/cancel'); - }, - - async resumeSubscription() { - return request('POST', '/subscriptions/me/resume'); - }, - - async listPayments() { - const result = await request<{ payments: Payment[] }>('GET', '/payments/me'); - return result.payments; - }, - - async getUsage() { - return request('GET', '/subscriptions/me/usage'); - }, - }; -} diff --git a/vendor/bytelyst/billing-client/tsconfig.json b/vendor/bytelyst/billing-client/tsconfig.json deleted file mode 100644 index 5a24989..0000000 --- a/vendor/bytelyst/billing-client/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src"] -} diff --git a/vendor/bytelyst/blob-client/package.json b/vendor/bytelyst/blob-client/package.json deleted file mode 100644 index 82f8bcd..0000000 --- a/vendor/bytelyst/blob-client/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "@bytelyst/blob-client", - "version": "0.1.5", - "type": "module", - "description": "Browser/React Native-safe blob storage client — SAS URL upload/download via platform-service", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/blob-client/src/index.test.ts b/vendor/bytelyst/blob-client/src/index.test.ts deleted file mode 100644 index 8ddad34..0000000 --- a/vendor/bytelyst/blob-client/src/index.test.ts +++ /dev/null @@ -1,276 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { createBlobClient, BlobApiError, BlobUploadError } from './index.js'; - -const mockFetch = vi.fn(); -globalThis.fetch = mockFetch; - -function jsonResponse(data: unknown, status = 200): Response { - return { - ok: status >= 200 && status < 300, - status, - json: () => Promise.resolve(data), - headers: new Headers(), - } as unknown as Response; -} - -function blobClient(overrides?: Partial[0]>) { - return createBlobClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - getAccessToken: () => 'test-token', - ...overrides, - }); -} - -beforeEach(() => { - mockFetch.mockReset(); -}); - -describe('createBlobClient', () => { - describe('getSasUrl', () => { - it('requests a SAS URL from platform-service', async () => { - const sasResponse = { - sasUrl: 'https://storage.blob.core.windows.net/attachments/blob?sig=abc', - container: 'attachments', - blobName: 'testapp/user1/photo.jpg', - permissions: 'r', - expiresInMinutes: 60, - expiresAt: '2026-03-02T00:00:00.000Z', - }; - mockFetch.mockResolvedValueOnce(jsonResponse(sasResponse)); - - const client = blobClient(); - const result = await client.getSasUrl('attachments', 'testapp/user1/photo.jpg'); - - expect(result).toEqual(sasResponse); - expect(mockFetch).toHaveBeenCalledOnce(); - - const [url, init] = mockFetch.mock.calls[0]; - expect(url).toBe('http://localhost:4003/api/blob/sas'); - expect(init.method).toBe('POST'); - expect(JSON.parse(init.body)).toEqual({ - container: 'attachments', - blobName: 'testapp/user1/photo.jpg', - permissions: 'r', - expiresInMinutes: 60, - }); - }); - - it('sends auth and product headers', async () => { - mockFetch.mockResolvedValueOnce(jsonResponse({ sasUrl: 'https://x' })); - - const client = blobClient(); - await client.getSasUrl('attachments', 'blob.jpg'); - - const headers = mockFetch.mock.calls[0][1].headers; - expect(headers['Authorization']).toBe('Bearer test-token'); - expect(headers['x-product-id']).toBe('testapp'); - expect(headers['x-request-id']).toBeDefined(); - }); - - it('omits auth header when no token', async () => { - mockFetch.mockResolvedValueOnce(jsonResponse({ sasUrl: 'https://x' })); - - const client = blobClient({ getAccessToken: () => null }); - await client.getSasUrl('attachments', 'blob.jpg'); - - const headers = mockFetch.mock.calls[0][1].headers; - expect(headers['Authorization']).toBeUndefined(); - }); - - it('throws BlobApiError on non-ok response', async () => { - mockFetch.mockResolvedValueOnce(jsonResponse({ message: 'Unauthorized' }, 401)); - - const client = blobClient(); - await expect(client.getSasUrl('releases', 'secret.bin')).rejects.toThrow(BlobApiError); - }); - }); - - describe('upload', () => { - it('requests SAS then uploads directly to Azure', async () => { - const sasResponse = { - sasUrl: 'https://storage.blob.core.windows.net/attachments/testapp/user1/photo.jpg?sig=abc', - container: 'attachments', - blobName: 'testapp/user1/photo.jpg', - permissions: 'w', - expiresInMinutes: 30, - expiresAt: '2026-03-02T00:00:00.000Z', - }; - mockFetch - .mockResolvedValueOnce(jsonResponse(sasResponse)) // SAS request - .mockResolvedValueOnce({ ok: true, status: 201 } as Response); // Azure upload - - const client = blobClient(); - const result = await client.upload('attachments', 'file-data', { - contentType: 'image/jpeg', - blobName: 'testapp/user1/photo.jpg', - }); - - expect(result.container).toBe('attachments'); - expect(result.blobName).toBe('testapp/user1/photo.jpg'); - expect(result.sasUrl).toBe( - 'https://storage.blob.core.windows.net/attachments/testapp/user1/photo.jpg' - ); - - // Verify Azure upload call - const [uploadUrl, uploadInit] = mockFetch.mock.calls[1]; - expect(uploadUrl).toBe(sasResponse.sasUrl); - expect(uploadInit.method).toBe('PUT'); - expect(uploadInit.headers['x-ms-blob-type']).toBe('BlockBlob'); - expect(uploadInit.headers['Content-Type']).toBe('image/jpeg'); - expect(uploadInit.body).toBe('file-data'); - }); - - it('auto-generates blobName when not provided', async () => { - mockFetch - .mockResolvedValueOnce( - jsonResponse({ - sasUrl: 'https://storage.blob.core.windows.net/attachments/testapp/123-abc?sig=x', - container: 'attachments', - blobName: 'testapp/123-abc', - permissions: 'w', - expiresInMinutes: 30, - expiresAt: '2026-03-02T00:00:00.000Z', - }) - ) - .mockResolvedValueOnce({ ok: true, status: 201 } as Response); - - const client = blobClient(); - const result = await client.upload('attachments', 'data', { - contentType: 'text/plain', - }); - - // Verify the SAS request included a generated blobName starting with productId - const sasBody = JSON.parse(mockFetch.mock.calls[0][1].body); - expect(sasBody.blobName).toMatch(/^testapp\//); - expect(result.container).toBe('attachments'); - }); - - it('throws BlobUploadError when Azure returns non-ok', async () => { - mockFetch - .mockResolvedValueOnce( - jsonResponse({ - sasUrl: 'https://storage.blob.core.windows.net/attachments/blob?sig=abc', - container: 'attachments', - blobName: 'blob', - permissions: 'w', - expiresInMinutes: 30, - expiresAt: '2026-03-02T00:00:00.000Z', - }) - ) - .mockResolvedValueOnce({ ok: false, status: 403 } as Response); - - const client = blobClient(); - await expect( - client.upload('attachments', 'data', { contentType: 'text/plain', blobName: 'blob' }) - ).rejects.toThrow(BlobUploadError); - }); - }); - - describe('download', () => { - it('requests read SAS then fetches the blob', async () => { - mockFetch - .mockResolvedValueOnce( - jsonResponse({ - sasUrl: 'https://storage.blob.core.windows.net/attachments/blob?sig=read', - container: 'attachments', - blobName: 'blob', - permissions: 'r', - expiresInMinutes: 15, - expiresAt: '2026-03-02T00:00:00.000Z', - }) - ) - .mockResolvedValueOnce({ - ok: true, - status: 200, - blob: () => Promise.resolve(new Blob()), - } as unknown as Response); - - const client = blobClient(); - const res = await client.download('attachments', 'blob'); - - expect(res.ok).toBe(true); - expect(mockFetch).toHaveBeenCalledTimes(2); - - // Second call should be to the SAS URL - expect(mockFetch.mock.calls[1][0]).toBe( - 'https://storage.blob.core.windows.net/attachments/blob?sig=read' - ); - }); - - it('throws BlobApiError on download failure', async () => { - mockFetch - .mockResolvedValueOnce( - jsonResponse({ - sasUrl: 'https://storage.blob.core.windows.net/attachments/blob?sig=read', - container: 'attachments', - blobName: 'blob', - permissions: 'r', - expiresInMinutes: 15, - expiresAt: '2026-03-02T00:00:00.000Z', - }) - ) - .mockResolvedValueOnce({ ok: false, status: 404 } as Response); - - const client = blobClient(); - await expect(client.download('attachments', 'blob')).rejects.toThrow(BlobApiError); - }); - }); - - describe('list', () => { - it('lists blobs with prefix and limit', async () => { - const listResponse = { - blobs: [{ name: 'testapp/user1/photo.jpg', container: 'attachments', size: 1024 }], - count: 1, - container: 'attachments', - prefix: 'testapp/user1/', - }; - mockFetch.mockResolvedValueOnce(jsonResponse(listResponse)); - - const client = blobClient(); - const result = await client.list('attachments', { prefix: 'testapp/user1/', limit: 10 }); - - expect(result).toEqual(listResponse); - const url = mockFetch.mock.calls[0][0] as string; - expect(url).toContain('container=attachments'); - expect(url).toContain('prefix=testapp%2Fuser1%2F'); - expect(url).toContain('limit=10'); - }); - - it('works without options', async () => { - mockFetch.mockResolvedValueOnce( - jsonResponse({ blobs: [], count: 0, container: 'audio', prefix: null }) - ); - - const client = blobClient(); - await client.list('audio'); - - const url = mockFetch.mock.calls[0][0] as string; - expect(url).toContain('container=audio'); - expect(url).not.toContain('prefix='); - expect(url).not.toContain('limit='); - }); - }); - - describe('info', () => { - it('fetches blob metadata', async () => { - const infoResponse = { - name: 'testapp/user1/photo.jpg', - container: 'attachments', - contentType: 'image/jpeg', - size: 2048, - lastModified: '2026-03-01T00:00:00.000Z', - url: 'https://storage.blob.core.windows.net/attachments/testapp/user1/photo.jpg', - metadata: {}, - }; - mockFetch.mockResolvedValueOnce(jsonResponse(infoResponse)); - - const client = blobClient(); - const result = await client.info('attachments', 'testapp/user1/photo.jpg'); - - expect(result).toEqual(infoResponse); - const url = mockFetch.mock.calls[0][0] as string; - expect(url).toContain('/blob/info/attachments/testapp%2Fuser1%2Fphoto.jpg'); - }); - }); -}); diff --git a/vendor/bytelyst/blob-client/src/index.ts b/vendor/bytelyst/blob-client/src/index.ts deleted file mode 100644 index f0bf9a9..0000000 --- a/vendor/bytelyst/blob-client/src/index.ts +++ /dev/null @@ -1,289 +0,0 @@ -/** - * Browser/React Native-safe blob storage client. - * - * Wraps the platform-service blob endpoints to provide: - * - SAS URL generation for direct upload/download - * - Direct blob upload via SAS URL (PUT with raw body) - * - Direct blob download via SAS URL - * - Blob listing and metadata - * - * Requires a fetch-compatible environment (browser, React Native, Node 18+). - * - * @example - * ```ts - * import { createBlobClient } from '@bytelyst/blob-client'; - * - * const blob = createBlobClient({ - * baseUrl: 'http://localhost:4003/api', - * productId: 'nomgap', - * getAccessToken: () => authClient.getAccessToken(), - * }); - * - * // Upload a file - * const { url } = await blob.upload('attachments', file, { - * contentType: 'image/jpeg', - * blobName: 'nomgap/user123/photos/meal.jpg', - * }); - * - * // Download a file - * const data = await blob.download('attachments', 'nomgap/user123/photos/meal.jpg'); - * - * // List blobs - * const { blobs } = await blob.list('attachments', { prefix: 'nomgap/user123/' }); - * ``` - */ - -// ── Types ──────────────────────────────────────────────────── - -export interface BlobClientConfig { - /** Platform-service base URL (e.g. "http://localhost:4003/api"). */ - baseUrl: string; - - /** Product identifier sent as x-product-id header. */ - productId: string; - - /** Function that returns the current access token, or null. */ - getAccessToken: () => string | null; - - /** Request timeout in milliseconds for API calls. Default: 15000. */ - timeoutMs?: number; - - /** Upload timeout in milliseconds for direct blob uploads. Default: 120000. */ - uploadTimeoutMs?: number; -} - -export interface SasUrlResponse { - sasUrl: string; - container: string; - blobName: string; - permissions: string; - expiresInMinutes: number; - expiresAt: string; -} - -export interface BlobInfo { - name: string; - container: string; - contentType?: string; - size: number; - lastModified?: string; - url: string; - metadata: Record; -} - -export interface ListBlobsResponse { - blobs: BlobInfo[]; - count: number; - container: string; - prefix: string | null; -} - -export interface UploadOptions { - /** Content-Type of the blob (e.g. "image/jpeg", "application/pdf"). */ - contentType: string; - - /** Full blob path. If omitted, auto-generated as `//-`. */ - blobName?: string; - - /** SAS token expiry in minutes. Default: 30. */ - expiresInMinutes?: number; -} - -export interface UploadResult { - sasUrl: string; - container: string; - blobName: string; -} - -export interface BlobClient { - /** Get a SAS URL for direct upload or download. */ - getSasUrl( - container: string, - blobName: string, - permissions?: 'r' | 'w' | 'rw' | 'rwc', - expiresInMinutes?: number - ): Promise; - - /** Upload a blob via SAS URL (requests SAS, then PUTs directly to Azure). */ - upload( - container: string, - data: Blob | ArrayBuffer | Uint8Array | string, - options: UploadOptions - ): Promise; - - /** Download a blob via SAS URL. Returns the Response for streaming. */ - download(container: string, blobName: string): Promise; - - /** List blobs in a container. */ - list( - container: string, - options?: { prefix?: string; limit?: number } - ): Promise; - - /** Get blob metadata/info. */ - info(container: string, blobName: string): Promise; -} - -// ── Errors ─────────────────────────────────────────────────── - -export class BlobApiError extends Error { - constructor( - public readonly status: number, - public readonly body: unknown, - message?: string - ) { - super(message ?? `Blob API error ${status}`); - this.name = 'BlobApiError'; - } -} - -export class BlobUploadError extends Error { - constructor( - public readonly status: number, - message?: string - ) { - super(message ?? `Blob upload failed with status ${status}`); - this.name = 'BlobUploadError'; - } -} - -// ── UUID helper ────────────────────────────────────────────── - -function uuid(): string { - if (typeof globalThis.crypto?.randomUUID === 'function') { - return globalThis.crypto.randomUUID(); - } - return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { - const r = (Math.random() * 16) | 0; - return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16); - }); -} - -// ── Factory ────────────────────────────────────────────────── - -export function createBlobClient(config: BlobClientConfig): BlobClient { - const { - baseUrl, - productId, - getAccessToken, - timeoutMs = 15_000, - uploadTimeoutMs = 120_000, - } = config; - - function authHeaders(): Record { - const headers: Record = { - 'Content-Type': 'application/json', - 'x-product-id': productId, - 'x-request-id': uuid(), - }; - const token = getAccessToken(); - if (token) headers['Authorization'] = `Bearer ${token}`; - return headers; - } - - async function apiRequest(method: string, path: string, body?: unknown): Promise { - const url = `${baseUrl}${path}`; - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - - try { - const res = await globalThis.fetch(url, { - method, - headers: authHeaders(), - body: body != null ? JSON.stringify(body) : undefined, - signal: controller.signal, - }); - - const json = await res.json().catch(() => ({})); - - if (!res.ok) { - throw new BlobApiError( - res.status, - json, - (json as Record).message ?? `HTTP ${res.status}` - ); - } - - return json as T; - } finally { - clearTimeout(timer); - } - } - - async function getSasUrl( - container: string, - blobName: string, - permissions: 'r' | 'w' | 'rw' | 'rwc' = 'r', - expiresInMinutes = 60 - ): Promise { - return apiRequest('POST', '/blob/sas', { - container, - blobName, - permissions, - expiresInMinutes, - }); - } - - async function upload( - container: string, - data: Blob | ArrayBuffer | Uint8Array | string, - options: UploadOptions - ): Promise { - const blobName = options.blobName ?? `${productId}/${Date.now()}-${uuid().slice(0, 8)}`; - - const sas = await getSasUrl(container, blobName, 'w', options.expiresInMinutes ?? 30); - - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), uploadTimeoutMs); - - try { - const res = await globalThis.fetch(sas.sasUrl, { - method: 'PUT', - headers: { - 'x-ms-blob-type': 'BlockBlob', - 'Content-Type': options.contentType, - }, - body: data as BodyInit, - signal: controller.signal, - }); - - if (!res.ok) { - throw new BlobUploadError(res.status, `Upload to Azure failed: HTTP ${res.status}`); - } - - return { sasUrl: sas.sasUrl.split('?')[0], container, blobName }; - } finally { - clearTimeout(timer); - } - } - - async function download(container: string, blobName: string): Promise { - const sas = await getSasUrl(container, blobName, 'r', 15); - - const res = await globalThis.fetch(sas.sasUrl); - if (!res.ok) { - throw new BlobApiError(res.status, null, `Download failed: HTTP ${res.status}`); - } - return res; - } - - async function list( - container: string, - options?: { prefix?: string; limit?: number } - ): Promise { - const params = new URLSearchParams({ container }); - if (options?.prefix) params.set('prefix', options.prefix); - if (options?.limit) params.set('limit', String(options.limit)); - - return apiRequest('GET', `/blob/list?${params.toString()}`); - } - - async function info(container: string, blobName: string): Promise { - return apiRequest( - 'GET', - `/blob/info/${encodeURIComponent(container)}/${encodeURIComponent(blobName)}` - ); - } - - return { getSasUrl, upload, download, list, info }; -} diff --git a/vendor/bytelyst/blob-client/tsconfig.json b/vendor/bytelyst/blob-client/tsconfig.json deleted file mode 100644 index 318c075..0000000 --- a/vendor/bytelyst/blob-client/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src", - "lib": ["ES2022", "DOM"] - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/vendor/bytelyst/blob/package.json b/vendor/bytelyst/blob/package.json deleted file mode 100644 index 8df2583..0000000 --- a/vendor/bytelyst/blob/package.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "name": "@bytelyst/blob", - "version": "0.2.5", - "type": "module", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "pretest": "pnpm --dir ../.. --filter @bytelyst/storage build", - "build": "tsc", - "test": "vitest run --pool forks" - }, - "dependencies": { - "@bytelyst/storage": "workspace:*" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/blob/src/__tests__/blob.test.ts b/vendor/bytelyst/blob/src/__tests__/blob.test.ts deleted file mode 100644 index c735c96..0000000 --- a/vendor/bytelyst/blob/src/__tests__/blob.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { setStorage, MemoryStorageProvider } from '@bytelyst/storage'; - -import { - _resetBlobClient, - generateSasUrl, - getBucket, - getStorageProvider, - isBlobStorageConfigured, - BLOB_CONTAINERS, -} from '../index.js'; - -describe('blob', () => { - beforeEach(() => { - _resetBlobClient(); - delete process.env.AZURE_BLOB_CONNECTION_STRING; - delete process.env.AZURE_BLOB_ACCOUNT_NAME; - delete process.env.AZURE_BLOB_ACCOUNT_KEY; - delete process.env.STORAGE_PROVIDER; - }); - - afterEach(() => { - _resetBlobClient(); - delete process.env.AZURE_BLOB_CONNECTION_STRING; - delete process.env.AZURE_BLOB_ACCOUNT_NAME; - delete process.env.AZURE_BLOB_ACCOUNT_KEY; - delete process.env.STORAGE_PROVIDER; - }); - - describe('isBlobStorageConfigured', () => { - it('is false when unset', () => { - expect(isBlobStorageConfigured()).toBe(false); - }); - - it('is true when connection string is set', () => { - process.env.AZURE_BLOB_CONNECTION_STRING = 'AccountName=x;AccountKey=y;'; - expect(isBlobStorageConfigured()).toBe(true); - }); - - it('is true when account name + key are set', () => { - process.env.AZURE_BLOB_ACCOUNT_NAME = 'acc'; - process.env.AZURE_BLOB_ACCOUNT_KEY = 'key=='; - expect(isBlobStorageConfigured()).toBe(true); - }); - - it('is true when provider is memory', () => { - process.env.STORAGE_PROVIDER = 'memory'; - expect(isBlobStorageConfigured()).toBe(true); - }); - }); - - describe('with memory provider', () => { - let memoryProvider: MemoryStorageProvider; - - beforeEach(() => { - memoryProvider = new MemoryStorageProvider(); - setStorage(memoryProvider); - }); - - it('getStorageProvider returns the provider', async () => { - const provider = await getStorageProvider(); - expect(provider).toBe(memoryProvider); - }); - - it('getBucket returns a bucket by name', async () => { - const bucket = await getBucket('audio'); - expect(bucket).toBeDefined(); - // Upload and download to verify it works - await bucket.upload('test.wav', Buffer.from('hello')); - const data = await bucket.download('test.wav'); - expect(data.toString()).toBe('hello'); - }); - - it('generateSasUrl returns a signed URL', async () => { - const url = await generateSasUrl('audio', 'path/file.wav', 'r', 10); - expect(url).toContain('audio'); - expect(url).toContain('path/file.wav'); - expect(url).toContain('signed=true'); - }); - - it('generateSasUrl defaults to read permissions', async () => { - const url = await generateSasUrl('audio', 'file.wav'); - expect(url).toContain('signed=true'); - }); - - it('_resetBlobClient resets the storage singleton', async () => { - const p1 = await getStorageProvider(); - _resetBlobClient(); - // After reset, inject a new provider - const newProvider = new MemoryStorageProvider(); - setStorage(newProvider); - const p2 = await getStorageProvider(); - expect(p1).not.toBe(p2); - }); - }); - - describe('BLOB_CONTAINERS', () => { - it('has expected container names', () => { - expect(BLOB_CONTAINERS.audio).toBe('audio'); - expect(BLOB_CONTAINERS.transcripts).toBe('transcripts'); - expect(BLOB_CONTAINERS.attachments).toBe('attachments'); - expect(BLOB_CONTAINERS.avatars).toBe('avatars'); - expect(BLOB_CONTAINERS.releases).toBe('releases'); - expect(BLOB_CONTAINERS.backups).toBe('backups'); - expect(BLOB_CONTAINERS.feedbackScreenshots).toBe('feedback-screenshots'); - }); - }); -}); diff --git a/vendor/bytelyst/blob/src/blob.ts b/vendor/bytelyst/blob/src/blob.ts deleted file mode 100644 index 71e0d82..0000000 --- a/vendor/bytelyst/blob/src/blob.ts +++ /dev/null @@ -1,94 +0,0 @@ -/** - * Shared Blob Storage utilities. - * - * Delegates to @bytelyst/storage for provider-agnostic blob operations. - * Keeps the same exported API surface for backward compatibility. - * - * Expected env vars: - * STORAGE_PROVIDER — 'azure' (default) | 'memory' - * AZURE_BLOB_CONNECTION_STRING — full connection string (preferred, when provider=azure) - * — OR — - * AZURE_BLOB_ACCOUNT_NAME + AZURE_BLOB_ACCOUNT_KEY - */ - -import { - getStorage, - _resetStorage, - type StorageProvider, - type StorageBucket, -} from '@bytelyst/storage'; - -/** - * Known blob containers and their purposes. - * - * Note: This is a convenience list (not enforced). Products can add their own - * containers as needed. - */ -export const BLOB_CONTAINERS = { - audio: 'audio', // Dictation audio recordings - transcripts: 'transcripts', // Exported transcript files (PDF, DOCX, TXT) - attachments: 'attachments', // Tracker item attachments (screenshots, docs) - avatars: 'avatars', // User profile images - releases: 'releases', // Desktop app update binaries - backups: 'backups', // Cosmos DB JSON backups - feedbackScreenshots: 'feedback-screenshots', // User feedback screenshot attachments -} as const; - -export type BlobContainerName = (typeof BLOB_CONTAINERS)[keyof typeof BLOB_CONTAINERS]; - -/** - * Get the storage provider singleton. - */ -export async function getStorageProvider(): Promise { - return getStorage(); -} - -/** - * Get a bucket (container) by name. - */ -export async function getBucket(containerName: string): Promise { - const storage = await getStorage(); - return storage.getBucket(containerName); -} - -/** - * Generate a signed URL for direct browser upload (or download). - * - * @param containerName - Target container - * @param blobName - Full blob path (e.g., "product/user123/audio/recording.wav") - * @param permissions - SAS permissions (default: read) - * @param expiresInMinutes - Token lifetime (default: 60) - * @returns Full signed URL for the blob - */ -export async function generateSasUrl( - containerName: string, - blobName: string, - permissions: 'r' | 'w' | 'rw' | 'rwc' | 'rwd' = 'r', - expiresInMinutes = 60 -): Promise { - const bucket = await getBucket(containerName); - const perm = permissions.includes('w') ? ('write' as const) : ('read' as const); - return bucket.getSignedUrl(blobName, { - permissions: perm, - expiresIn: expiresInMinutes * 60, - }); -} - -/** - * Check if blob storage is configured. - */ -export function isBlobStorageConfigured(): boolean { - const provider = process.env.STORAGE_PROVIDER || 'azure'; - if (provider === 'memory') return true; - return !!( - process.env.AZURE_BLOB_CONNECTION_STRING || - (process.env.AZURE_BLOB_ACCOUNT_NAME && process.env.AZURE_BLOB_ACCOUNT_KEY) - ); -} - -/** - * Test helper: reset module singletons/caches. - */ -export function _resetBlobClient(): void { - _resetStorage(); -} diff --git a/vendor/bytelyst/blob/src/index.ts b/vendor/bytelyst/blob/src/index.ts deleted file mode 100644 index 10b6d7c..0000000 --- a/vendor/bytelyst/blob/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './blob.js'; diff --git a/vendor/bytelyst/blob/tsconfig.json b/vendor/bytelyst/blob/tsconfig.json deleted file mode 100644 index 5edad81..0000000 --- a/vendor/bytelyst/blob/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/vendor/bytelyst/broadcast-client/README.md b/vendor/bytelyst/broadcast-client/README.md deleted file mode 100644 index 3227c51..0000000 --- a/vendor/bytelyst/broadcast-client/README.md +++ /dev/null @@ -1,227 +0,0 @@ -# @bytelyst/broadcast-client - -TypeScript client for the ByteLyst Broadcast & Messaging platform. Provides in-app message polling, read receipts, and push notification token management. - -## Installation - -```bash -npm install @bytelyst/broadcast-client -# or -pnpm add @bytelyst/broadcast-client -``` - -## Quick Start - -```typescript -import { createBroadcastClient } from '@bytelyst/broadcast-client'; - -const client = createBroadcastClient({ - baseURL: 'https://api.bytelyst.io/v1', - productId: 'lysnrai', - getAuthToken: async () => { - // Return your JWT token - return localStorage.getItem('token'); - } -}); - -// Start polling for messages (every 60 seconds) -client.startPolling(60000, (messages) => { - console.log('New messages:', messages); -}); -``` - -## API Reference - -### `createBroadcastClient(config)` - -Creates a new broadcast client instance. - -**Config:** -| Option | Type | Required | Description | -|--------|------|----------|-------------| -| `baseURL` | string | Yes | API base URL | -| `productId` | string | Yes | Product identifier | -| `getAuthToken` | () => Promise | Yes | Function to retrieve JWT token | - -### Methods - -#### `getMessages()` -Fetch active in-app messages for the current user. - -```typescript -const { data, error } = await client.getMessages(); -// Returns: { messages: InAppMessage[] } -``` - -#### `markRead(messageId: string)` -Mark a message as read. - -```typescript -await client.markRead('msg_123'); -``` - -#### `markDismissed(messageId: string)` -Dismiss a message. - -```typescript -await client.markDismissed('msg_123'); -``` - -#### `trackClick(messageId: string)` -Track when user clicks/taps a message CTA. - -```typescript -await client.trackClick('msg_123'); -``` - -#### `startPolling(intervalMs: number, callback: (messages) => void)` -Start polling for new messages. - -```typescript -client.startPolling(60000, (messages) => { - // Called every 60 seconds with current messages -}); -``` - -#### `stopPolling()` -Stop message polling. - -```typescript -client.stopPolling(); -``` - -#### `registerDeviceToken(token: string, platform: 'ios' | 'android' | 'web')` -Register push notification device token. - -```typescript -await client.registerDeviceToken('fcm_token_xyz', 'android'); -``` - -#### `unregisterDeviceToken(token: string)` -Unregister device token (e.g., on logout). - -```typescript -await client.unregisterDeviceToken('fcm_token_xyz'); -``` - -## React Integration - -### Hook Usage - -```typescript -import { useBroadcastClient } from './hooks/useBroadcastClient'; - -function App() { - const { messages, unreadCount, markRead, markDismissed } = useBroadcastClient({ - pollingInterval: 60000 - }); - - return ( -
- {messages.map(msg => ( - markDismissed(msg.id)} - onClick={() => markRead(msg.id)} - /> - ))} -
- ); -} -``` - -### Provider Pattern - -```typescript -// BroadcastProvider.tsx -import { createContext, useContext, useEffect, useState } from 'react'; -import { createBroadcastClient, BroadcastClient, InAppMessage } from '@bytelyst/broadcast-client'; - -const BroadcastContext = createContext(null); - -export function BroadcastProvider({ children, config }: { children: React.ReactNode; config: BroadcastConfig }) { - const [client] = useState(() => createBroadcastClient(config)); - const [messages, setMessages] = useState([]); - - useEffect(() => { - client.startPolling(60000, setMessages); - return () => client.stopPolling(); - }, [client]); - - const value = { - messages, - unreadCount: messages.filter(m => m.status === 'unread').length, - markRead: client.markRead.bind(client), - markDismissed: client.markDismissed.bind(client), - trackClick: client.trackClick.bind(client), - }; - - return ( - - {children} - - ); -} - -export const useBroadcast = () => { - const ctx = useContext(BroadcastContext); - if (!ctx) throw new Error('useBroadcast must be used within BroadcastProvider'); - return ctx; -}; -``` - -## Types - -```typescript -interface InAppMessage { - id: string; - broadcastId: string; - title: string; - body?: string; - style: 'banner' | 'modal' | 'fullscreen' | 'toast'; - priority: 'low' | 'normal' | 'high' | 'urgent'; - ctaText?: string; - ctaUrl?: string; - imageUrl?: string; - deepLink?: { - screen: string; - params: Record; - }; - status: 'unread' | 'read' | 'dismissed'; - createdAt: string; -} - -interface BroadcastConfig { - baseURL: string; - productId: string; - getAuthToken: () => Promise; -} -``` - -## Error Handling - -All methods return a result tuple `[data, error]`: - -```typescript -const [data, error] = await client.getMessages(); - -if (error) { - console.error('Failed to fetch messages:', error.message); - return; -} - -// Use data.messages -``` - -## Browser Support - -- Chrome 90+ -- Firefox 88+ -- Safari 14+ -- Edge 90+ - -## License - -MIT © ByteLyst diff --git a/vendor/bytelyst/broadcast-client/package.json b/vendor/bytelyst/broadcast-client/package.json deleted file mode 100644 index 202a9f8..0000000 --- a/vendor/bytelyst/broadcast-client/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "@bytelyst/broadcast-client", - "version": "0.1.5", - "type": "module", - "description": "Browser/React Native-safe broadcast messaging client for platform-service", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/broadcast-client/src/deep-link.ts b/vendor/bytelyst/broadcast-client/src/deep-link.ts deleted file mode 100644 index ee4ecc8..0000000 --- a/vendor/bytelyst/broadcast-client/src/deep-link.ts +++ /dev/null @@ -1,165 +0,0 @@ -/** - * Deep Link Router — TypeScript - * Handles routing from push notification deep links to app screens - */ - -export interface DeepLinkRoute { - screen: string; - params?: Record; -} - -export type DeepLinkHandler = (route: DeepLinkRoute) => void; - -/** - * Deep Link Router class - */ -export class DeepLinkRouter { - private handlers = new Map(); - private fallbackHandler?: DeepLinkHandler; - - /** - * Register a handler for a specific screen - */ - register(screen: string, handler: DeepLinkHandler): void { - this.handlers.set(screen, handler); - } - - /** - * Set a fallback handler for unregistered screens - */ - setFallback(handler: DeepLinkHandler): void { - this.fallbackHandler = handler; - } - - /** - * Parse a deep link URL and extract route - */ - parseDeepLink(url: string): DeepLinkRoute | null { - try { - const urlObj = new URL(url); - - // Handle app-specific URLs: myapp://screen/params - if (urlObj.protocol !== 'http:' && urlObj.protocol !== 'https:') { - const routeParts = [urlObj.host, ...urlObj.pathname.split('/').filter(Boolean)].filter( - Boolean - ); - const screen = routeParts[0] || 'home'; - const params: Record = {}; - - // Parse query params - urlObj.searchParams.forEach((value, key) => { - params[key] = value; - }); - - return { screen, params }; - } - - // Handle web URLs with deep link params - const deepLinkParam = urlObj.searchParams.get('dl'); - if (deepLinkParam) { - return this.parseDeepLink(deepLinkParam); - } - - // Handle path-based routing: /screen/params - const pathParts = urlObj.pathname.split('/').filter(Boolean); - if (pathParts.length > 0) { - const screen = pathParts[0]; - const params: Record = {}; - - urlObj.searchParams.forEach((value, key) => { - params[key] = value; - }); - - return { screen, params }; - } - - return null; - } catch { - return null; - } - } - - /** - * Handle a deep link route - */ - handle(route: DeepLinkRoute): boolean { - const handler = this.handlers.get(route.screen); - - if (handler) { - handler(route); - return true; - } - - if (this.fallbackHandler) { - this.fallbackHandler(route); - return true; - } - - // eslint-disable-next-line no-console -- Deep-link consumers need an opt-in diagnostic when a route is dropped. - console.warn(`[DeepLink] No handler for screen: ${route.screen}`); - return false; - } - - /** - * Process a deep link URL end-to-end - */ - process(url: string): boolean { - const route = this.parseDeepLink(url); - if (!route) { - // eslint-disable-next-line no-console -- Parse failures are intentionally surfaced to host apps during integration. - console.warn(`[DeepLink] Failed to parse: ${url}`); - return false; - } - return this.handle(route); - } -} - -/** - * Create a broadcast deep link URL - */ -export function createBroadcastDeepLink( - baseUrl: string, - screen: string, - params?: Record, - broadcastId?: string -): string { - const url = new URL(baseUrl); - url.pathname = `/${screen}`; - - if (params) { - Object.entries(params).forEach(([key, value]) => { - url.searchParams.set(key, value); - }); - } - - if (broadcastId) { - url.searchParams.set('broadcastId', broadcastId); - } - - return url.toString(); -} - -/** - * Common deep link screens for broadcast/survey flows - */ -export const DeepLinkScreens = { - // Broadcasts - BROADCAST_DETAIL: 'broadcast', - ANNOUNCEMENTS: 'announcements', - - // Surveys - SURVEY: 'survey', - SURVEY_LIST: 'surveys', - - // Product-specific (examples) - SETTINGS: 'settings', - PROFILE: 'profile', - UPGRADE: 'upgrade', - SUPPORT: 'support', - - // Fallback - HOME: 'home', -} as const; - -// Singleton instance for app-wide use -export const deepLinkRouter = new DeepLinkRouter(); diff --git a/vendor/bytelyst/broadcast-client/src/index.test.ts b/vendor/bytelyst/broadcast-client/src/index.test.ts deleted file mode 100644 index f4464dd..0000000 --- a/vendor/bytelyst/broadcast-client/src/index.test.ts +++ /dev/null @@ -1,219 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { - DeepLinkRouter, - DeepLinkScreens, - createBroadcastClient, - createBroadcastDeepLink, - createUseBroadcast, -} from './index.js'; - -const fetchMock = vi.fn(); - -function jsonResponse(body: unknown, status = 200) { - return { - ok: status >= 200 && status < 300, - status, - json: () => Promise.resolve(body), - text: () => Promise.resolve(JSON.stringify(body)), - }; -} - -describe('createBroadcastClient', () => { - beforeEach(() => { - fetchMock.mockReset(); - vi.stubGlobal('fetch', fetchMock); - }); - - afterEach(() => { - vi.useRealTimers(); - vi.restoreAllMocks(); - }); - - it('lists messages with default segment and auth headers', async () => { - fetchMock.mockResolvedValueOnce(jsonResponse({ messages: [{ id: 'm1' }] })); - - const client = createBroadcastClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - getAuthToken: () => 'token-123', - platform: 'web', - appVersion: '1.2.3', - osVersion: 'macOS 15', - }); - - const result = await client.listMessages(); - - expect(result).toEqual({ messages: [{ id: 'm1' }] }); - expect(fetchMock).toHaveBeenCalledWith( - 'http://localhost:4003/api/broadcasts', - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: 'Bearer token-123', - 'x-product-id': 'testapp', - 'x-platform': 'web', - 'x-app-version': '1.2.3', - 'x-os-version': 'macOS 15', - 'x-user-segments': 'free', - }), - }) - ); - }); - - it('supports async auth token resolution and optional headers', async () => { - fetchMock.mockResolvedValueOnce(jsonResponse({ messages: [] })); - - const client = createBroadcastClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - getAuthToken: async () => 'async-token', - platform: 'ios', - appVersion: '2.0.0', - osVersion: '18.0', - countryCode: 'US', - regionCode: 'CA', - userSegments: ['pro', 'beta'], - }); - - await client.listMessages(); - - expect(fetchMock).toHaveBeenCalledWith( - 'http://localhost:4003/api/broadcasts', - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: 'Bearer async-token', - 'x-country-code': 'US', - 'x-region-code': 'CA', - 'x-user-segments': 'pro,beta', - }), - }) - ); - }); - - it('throws a descriptive error when the API fails', async () => { - fetchMock.mockResolvedValueOnce({ - ok: false, - status: 500, - text: () => Promise.resolve('boom'), - }); - - const client = createBroadcastClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - getAuthToken: () => 'token-123', - platform: 'web', - appVersion: '1.2.3', - osVersion: 'macOS 15', - }); - - await expect(client.markRead('message-1')).rejects.toThrow('Broadcast API error: 500 boom'); - }); - - it('polls messages and returns a cleanup function', async () => { - vi.useFakeTimers(); - fetchMock.mockResolvedValue(jsonResponse({ messages: [] })); - - const client = createBroadcastClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - getAuthToken: () => 'token-123', - platform: 'web', - appVersion: '1.2.3', - osVersion: 'macOS 15', - }); - - const stopPolling = client.pollMessages(1000); - await vi.advanceTimersByTimeAsync(3000); - - expect(fetchMock).toHaveBeenCalledTimes(3); - - stopPolling(); - await vi.advanceTimersByTimeAsync(2000); - - expect(fetchMock).toHaveBeenCalledTimes(3); - }); - - it('returns the same client from createUseBroadcast', () => { - const client = createBroadcastClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - getAuthToken: () => 'token-123', - platform: 'web', - appVersion: '1.2.3', - osVersion: 'macOS 15', - }); - - const useBroadcast = createUseBroadcast(client); - - expect(useBroadcast()).toEqual({ client }); - }); -}); - -describe('DeepLinkRouter', () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('parses custom app deep links using the host as the screen', () => { - const router = new DeepLinkRouter(); - - expect(router.parseDeepLink('myapp://broadcast?broadcastId=b1&variant=test')).toEqual({ - screen: 'broadcast', - params: { - broadcastId: 'b1', - variant: 'test', - }, - }); - }); - - it('parses nested deep links from web URLs', () => { - const router = new DeepLinkRouter(); - - expect( - router.parseDeepLink('https://app.bytelyst.dev/open?dl=myapp%3A%2F%2Fsurvey%3Fid%3Ds1') - ).toEqual({ - screen: 'survey', - params: { id: 's1' }, - }); - }); - - it('dispatches to registered handlers and falls back when needed', () => { - const router = new DeepLinkRouter(); - const primaryHandler = vi.fn(); - const fallbackHandler = vi.fn(); - - router.register(DeepLinkScreens.BROADCAST_DETAIL, primaryHandler); - router.setFallback(fallbackHandler); - - expect(router.handle({ screen: DeepLinkScreens.BROADCAST_DETAIL, params: { id: 'b1' } })).toBe( - true - ); - expect(primaryHandler).toHaveBeenCalledWith({ - screen: DeepLinkScreens.BROADCAST_DETAIL, - params: { id: 'b1' }, - }); - - expect(router.handle({ screen: 'unknown' })).toBe(true); - expect(fallbackHandler).toHaveBeenCalledWith({ screen: 'unknown' }); - }); - - it('returns false and warns when processing an invalid URL without a fallback', () => { - const router = new DeepLinkRouter(); - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - expect(router.process('not-a-url')).toBe(false); - expect(warnSpy).toHaveBeenCalledWith('[DeepLink] Failed to parse: not-a-url'); - }); -}); - -describe('createBroadcastDeepLink', () => { - it('builds deep links with params and broadcastId', () => { - expect( - createBroadcastDeepLink( - 'https://app.bytelyst.dev', - DeepLinkScreens.ANNOUNCEMENTS, - { tab: 'latest' }, - 'b42' - ) - ).toBe('https://app.bytelyst.dev/announcements?tab=latest&broadcastId=b42'); - }); -}); diff --git a/vendor/bytelyst/broadcast-client/src/index.ts b/vendor/bytelyst/broadcast-client/src/index.ts deleted file mode 100644 index 2824e2b..0000000 --- a/vendor/bytelyst/broadcast-client/src/index.ts +++ /dev/null @@ -1,185 +0,0 @@ -/** - * Broadcast Client — Browser/React Native-safe broadcast messaging client - * @module @bytelyst/broadcast-client - */ - -// ============================================================================= -// Types -// ============================================================================= - -export interface Broadcast { - id: string; - productId: string; - title: string; - body: string; - bodyMarkdown?: string; - ctaText?: string; - ctaUrl?: string; - imageUrl?: string; - channels: ('push' | 'in_app' | 'email')[]; - status: 'draft' | 'scheduled' | 'sending' | 'sent' | 'paused'; - scheduledAt?: string; - sentAt?: string; - variant?: 'control' | 'treatment'; - experimentId?: string; - parentBroadcastId?: string; - metrics: BroadcastMetrics; - createdAt: string; - updatedAt: string; - createdBy: string; -} - -export interface BroadcastMetrics { - targetedCount: number; - sentCount: number; - deliveredCount: number; - openedCount: number; - clickedCount: number; - dismissedCount: number; - convertedCount: number; -} - -export interface InAppMessage { - id: string; - userId: string; - productId: string; - broadcastId: string; - title: string; - body: string; - bodyMarkdown?: string; - ctaText?: string; - ctaUrl?: string; - priority: 'low' | 'normal' | 'high' | 'urgent'; - style: 'banner' | 'modal' | 'toast' | 'fullscreen'; - dismissible: boolean; - expiresAt?: string; - status: 'unread' | 'read' | 'dismissed'; - createdAt: string; - updatedAt: string; -} - -export interface BroadcastClientConfig { - /** Platform service base URL */ - baseUrl: string; - /** Product ID */ - productId: string; - /** Auth token provider (async or sync) */ - getAuthToken: (() => string) | (() => Promise); - /** Platform identifier */ - platform: 'web' | 'ios' | 'android' | 'macos' | 'windows'; - /** App version */ - appVersion: string; - /** OS version */ - osVersion: string; - /** Optional country code */ - countryCode?: string; - /** Optional region code */ - regionCode?: string; - /** User segments (default: ['free']) */ - userSegments?: string[]; -} - -// ============================================================================= -// Client Factory -// ============================================================================= - -export interface BroadcastClient { - /** List active in-app messages for current user */ - listMessages(): Promise<{ messages: InAppMessage[] }>; - /** Mark message as read */ - markRead(messageId: string): Promise; - /** Mark message as dismissed */ - markDismissed(messageId: string): Promise; - /** Track CTA click */ - trackClick(messageId: string): Promise<{ redirectUrl?: string }>; - /** Poll for new messages (use with setInterval) */ - pollMessages(intervalMs?: number): () => void; -} - -export function createBroadcastClient(config: BroadcastClientConfig): BroadcastClient { - const headers = async () => ({ - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${await Promise.resolve(config.getAuthToken())}`, - 'x-product-id': config.productId, - 'x-platform': config.platform, - 'x-app-version': config.appVersion, - 'x-os-version': config.osVersion, - ...(config.countryCode && { 'x-country-code': config.countryCode }), - ...(config.regionCode && { 'x-region-code': config.regionCode }), - 'x-user-segments': (config.userSegments ?? ['free']).join(','), - }); - - const request = async (path: string, options?: RequestInit): Promise => { - const res = await fetch(`${config.baseUrl}${path}`, { - ...options, - headers: { - ...(await headers()), - ...(options?.headers || {}), - }, - }); - if (!res.ok) { - const err = await res.text(); - throw new Error(`Broadcast API error: ${res.status} ${err}`); - } - return res.json() as Promise; - }; - - let pollInterval: ReturnType | null = null; - - return { - async listMessages() { - return request<{ messages: InAppMessage[] }>('/broadcasts'); - }, - - async markRead(messageId: string) { - await request(`/broadcasts/${messageId}/read`, { method: 'POST' }); - }, - - async markDismissed(messageId: string) { - await request(`/broadcasts/${messageId}/dismiss`, { method: 'POST' }); - }, - - async trackClick(messageId: string) { - return request<{ redirectUrl?: string }>(`/broadcasts/${messageId}/click`, { - method: 'POST', - }); - }, - - pollMessages(intervalMs = 60000) { - if (pollInterval) clearInterval(pollInterval); - pollInterval = setInterval(() => { - this.listMessages().catch(() => {}); - }, intervalMs); - return () => { - if (pollInterval) { - clearInterval(pollInterval); - pollInterval = null; - } - }; - }, - }; -} - -// ============================================================================= -// React Hook (optional) -// ============================================================================= - -export function createUseBroadcast(client: BroadcastClient) { - return function useBroadcast() { - return { client }; - }; -} - -// ============================================================================= -// Deep Link Router -// ============================================================================= - -export { - DeepLinkRouter, - deepLinkRouter, - DeepLinkScreens, - createBroadcastDeepLink, - type DeepLinkRoute, - type DeepLinkHandler, -} from './deep-link.js'; - diff --git a/vendor/bytelyst/broadcast-client/tsconfig.json b/vendor/bytelyst/broadcast-client/tsconfig.json deleted file mode 100644 index 3686f56..0000000 --- a/vendor/bytelyst/broadcast-client/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "./dist", - "rootDir": "./src", - "declaration": true - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] -} diff --git a/vendor/bytelyst/celebrations/package.json b/vendor/bytelyst/celebrations/package.json deleted file mode 100644 index 9a150c5..0000000 --- a/vendor/bytelyst/celebrations/package.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "@bytelyst/celebrations", - "version": "0.1.5", - "type": "module", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "scripts": { - "build": "tsc" - }, - "devDependencies": { - "typescript": "^5.7.3" - } -} diff --git a/vendor/bytelyst/celebrations/src/index.ts b/vendor/bytelyst/celebrations/src/index.ts deleted file mode 100644 index 0170132..0000000 --- a/vendor/bytelyst/celebrations/src/index.ts +++ /dev/null @@ -1,25 +0,0 @@ -export interface Celebration { - emoji: string; - title: string; -} - -const DEFAULT_CELEBRATION: Celebration = { - emoji: '👏', - title: 'Great Job!', -}; - -const BY_TYPE: Record = { - session_completed: { emoji: '🎉', title: 'Fast Complete!' }, - task_completed: { emoji: '✅', title: 'Well Done!' }, - streak_milestone: { emoji: '🔥', title: 'Streak Milestone!' }, - achievement_unlocked: { emoji: '🏆', title: 'Achievement Unlocked!' }, - level_up: { emoji: '⬆️', title: 'Level Up!' }, -}; - -export function createCelebrationEngine() { - return { - getCelebration(type: string): Celebration { - return BY_TYPE[type] ?? DEFAULT_CELEBRATION; - }, - }; -} diff --git a/vendor/bytelyst/celebrations/tsconfig.json b/vendor/bytelyst/celebrations/tsconfig.json deleted file mode 100644 index 01c4d9a..0000000 --- a/vendor/bytelyst/celebrations/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src", - "declaration": true - }, - "include": ["src"] -} diff --git a/vendor/bytelyst/client-encrypt/package.json b/vendor/bytelyst/client-encrypt/package.json deleted file mode 100644 index a1d2937..0000000 --- a/vendor/bytelyst/client-encrypt/package.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "name": "@bytelyst/client-encrypt", - "version": "0.1.5", - "type": "module", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks" - }, - "devDependencies": { - "vitest": "^3.0.0" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/client-encrypt/src/aes-gcm.test.ts b/vendor/bytelyst/client-encrypt/src/aes-gcm.test.ts deleted file mode 100644 index 0c7d044..0000000 --- a/vendor/bytelyst/client-encrypt/src/aes-gcm.test.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - encryptField, - decryptField, - generateKey, - keyFromHex, - keyToHex, - deriveKey, -} from './aes-gcm.js'; -import { isEncryptedField } from './guards.js'; -import { toHex, fromHex } from './hex.js'; - -describe('encryptField / decryptField', () => { - it('roundtrip', async () => { - const key = await generateKey(); - const encrypted = await encryptField('Hello, World!', key, 'dek_test'); - const decrypted = await decryptField(encrypted, key); - expect(decrypted).toBe('Hello, World!'); - }); - - it('empty string', async () => { - const key = await generateKey(); - const encrypted = await encryptField('', key, 'dek_test'); - const decrypted = await decryptField(encrypted, key); - expect(decrypted).toBe(''); - }); - - it('unicode', async () => { - const key = await generateKey(); - const text = 'こんにちは世界 🌍 مرحبا Ñoño'; - const encrypted = await encryptField(text, key, 'dek_test'); - const decrypted = await decryptField(encrypted, key); - expect(decrypted).toBe(text); - }); - - it('large payload', async () => { - const key = await generateKey(); - const text = 'A'.repeat(100_000); - const encrypted = await encryptField(text, key, 'dek_test'); - const decrypted = await decryptField(encrypted, key); - expect(decrypted).toBe(text); - }); -}); - -describe('EncryptedField structure', () => { - it('has correct sentinel fields', async () => { - const key = await generateKey(); - const encrypted = await encryptField('test', key, 'dek_test'); - expect(encrypted.__encrypted).toBe(true); - expect(encrypted.v).toBe(1); - expect(encrypted.alg).toBe('aes-256-gcm'); - expect(encrypted.dekId).toBe('dek_test'); - }); - - it('has correct hex lengths', async () => { - const key = await generateKey(); - const encrypted = await encryptField('test', key, 'dek_test'); - expect(encrypted.iv.length).toBe(24); // 12 bytes = 24 hex - expect(encrypted.tag.length).toBe(32); // 16 bytes = 32 hex - expect(encrypted.ct.length).toBeGreaterThan(0); - }); - - it('unique IVs per encryption', async () => { - const key = await generateKey(); - const a = await encryptField('same', key, 'dek_test'); - const b = await encryptField('same', key, 'dek_test'); - expect(a.iv).not.toBe(b.iv); - expect(a.ct).not.toBe(b.ct); - }); -}); - -describe('AAD (Additional Authenticated Data)', () => { - it('roundtrip with AAD', async () => { - const key = await generateKey(); - const encrypted = await encryptField('secret', key, 'dek_test', 'user:ctx'); - const decrypted = await decryptField(encrypted, key, 'user:ctx'); - expect(decrypted).toBe('secret'); - }); - - it('wrong AAD fails', async () => { - const key = await generateKey(); - const encrypted = await encryptField('secret', key, 'dek_test', 'correct'); - await expect(decryptField(encrypted, key, 'wrong')).rejects.toThrow(); - }); - - it('missing AAD fails', async () => { - const key = await generateKey(); - const encrypted = await encryptField('secret', key, 'dek_test', 'some-aad'); - await expect(decryptField(encrypted, key)).rejects.toThrow(); - }); -}); - -describe('wrong key', () => { - it('decrypt with wrong key fails', async () => { - const key = await generateKey(); - const wrongKey = await generateKey(); - const encrypted = await encryptField('secret', key, 'dek_test'); - await expect(decryptField(encrypted, wrongKey)).rejects.toThrow(); - }); -}); - -describe('keyFromHex / keyToHex', () => { - it('roundtrip', async () => { - const key = await generateKey(); - const hex = await keyToHex(key); - expect(hex.length).toBe(64); // 32 bytes = 64 hex chars - const restored = await keyFromHex(hex); - const encrypted = await encryptField('test', key, 'dek_test'); - const decrypted = await decryptField(encrypted, restored); - expect(decrypted).toBe('test'); - }); - - it('rejects invalid length', async () => { - await expect(keyFromHex('aabb')).rejects.toThrow('32-byte key'); - }); -}); - -describe('deriveKey', () => { - it('derives consistent key from passphrase + salt', async () => { - const salt = new Uint8Array(16); - globalThis.crypto.getRandomValues(salt); - const key1 = await deriveKey('my-passphrase', salt, 1000, true); - const key2 = await deriveKey('my-passphrase', salt, 1000, true); - const hex1 = await keyToHex(key1); - const hex2 = await keyToHex(key2); - expect(hex1).toBe(hex2); - }); - - it('different passphrases produce different keys', async () => { - const salt = new Uint8Array(16); - globalThis.crypto.getRandomValues(salt); - const key1 = await deriveKey('pass-1', salt, 1000, true); - const key2 = await deriveKey('pass-2', salt, 1000, true); - const hex1 = await keyToHex(key1); - const hex2 = await keyToHex(key2); - expect(hex1).not.toBe(hex2); - }); - - it('derived key can encrypt/decrypt', async () => { - const salt = new Uint8Array(16); - globalThis.crypto.getRandomValues(salt); - const key = await deriveKey('test', salt, 1000, true); - const encrypted = await encryptField('hello', key, 'dek_test'); - const decrypted = await decryptField(encrypted, key); - expect(decrypted).toBe('hello'); - }); -}); - -describe('isEncryptedField', () => { - it('true for valid EncryptedField', async () => { - const key = await generateKey(); - const encrypted = await encryptField('test', key, 'dek_test'); - expect(isEncryptedField(encrypted)).toBe(true); - }); - - it('false for plain string', () => { - expect(isEncryptedField('just a string')).toBe(false); - }); - - it('false for null', () => { - expect(isEncryptedField(null)).toBe(false); - }); - - it('false for incomplete object', () => { - expect(isEncryptedField({ __encrypted: true, v: 1 })).toBe(false); - }); -}); - -describe('hex utilities', () => { - it('toHex / fromHex roundtrip', () => { - const bytes = new Uint8Array([0x00, 0x0f, 0xff, 0xab, 0xcd]); - const hex = toHex(bytes); - expect(hex).toBe('000fffabcd'); - const restored = fromHex(hex); - expect(restored).toEqual(bytes); - }); - - it('fromHex rejects odd length', () => { - expect(() => fromHex('a')).toThrow('even length'); - }); -}); diff --git a/vendor/bytelyst/client-encrypt/src/aes-gcm.ts b/vendor/bytelyst/client-encrypt/src/aes-gcm.ts deleted file mode 100644 index 764d932..0000000 --- a/vendor/bytelyst/client-encrypt/src/aes-gcm.ts +++ /dev/null @@ -1,215 +0,0 @@ -/** - * @bytelyst/client-encrypt — AES-256-GCM via Web Crypto API - * - * Works in browsers (window.crypto.subtle) and React Native (expo-crypto polyfill). - * Produces EncryptedField objects wire-compatible with: - * - @bytelyst/field-encrypt (Node.js server) - * - BLFieldEncrypt (Swift CryptoKit / Kotlin javax.crypto) - */ - -import type { EncryptedField } from './types.js'; -import { toHex, fromHex } from './hex.js'; - -const ALGORITHM = 'AES-GCM'; -const KEY_SIZE_BITS = 256; -const IV_BYTES = 12; -const TAG_BITS = 128; - -/** Get the SubtleCrypto instance (browser or globalThis). */ -function getSubtle(): SubtleCrypto { - if (typeof globalThis !== 'undefined' && globalThis.crypto?.subtle) { - return globalThis.crypto.subtle; - } - throw new Error( - '@bytelyst/client-encrypt requires Web Crypto API (SubtleCrypto). ' + - 'Use a polyfill in React Native (e.g., expo-crypto).' - ); -} - -/** Get the crypto object for random bytes. */ -function getCrypto(): Crypto { - if (typeof globalThis !== 'undefined' && globalThis.crypto) { - return globalThis.crypto; - } - throw new Error('@bytelyst/client-encrypt requires globalThis.crypto for random bytes.'); -} - -/** - * Encrypt a plaintext string with AES-256-GCM using Web Crypto API. - * - * @param plaintext - UTF-8 string to encrypt - * @param key - CryptoKey (AES-GCM, 256-bit) - * @param dekId - DEK identifier stored in the output - * @param aad - Optional additional authenticated data - * @returns EncryptedField with hex-encoded ciphertext, IV, and tag - */ -export async function encryptField( - plaintext: string, - key: CryptoKey, - dekId: string, - aad?: string -): Promise { - const subtle = getSubtle(); - const crypto = getCrypto(); - - const iv = new Uint8Array(IV_BYTES); - crypto.getRandomValues(iv); - - const encoder = new TextEncoder(); - const plaintextBytes = encoder.encode(plaintext); - - const params: AesGcmParams = { - name: ALGORITHM, - iv: iv.buffer as ArrayBuffer, - tagLength: TAG_BITS, - }; - - if (aad) { - params.additionalData = encoder.encode(aad).buffer as ArrayBuffer; - } - - // Web Crypto returns ciphertext || tag concatenated - const ciphertextWithTag = new Uint8Array( - await subtle.encrypt(params, key, plaintextBytes.buffer as ArrayBuffer) - ); - - const tagOffset = ciphertextWithTag.length - TAG_BITS / 8; - const ct = ciphertextWithTag.slice(0, tagOffset); - const tag = ciphertextWithTag.slice(tagOffset); - - return { - __encrypted: true, - v: 1, - alg: 'aes-256-gcm', - ct: toHex(ct), - iv: toHex(iv), - tag: toHex(tag), - dekId, - }; -} - -/** - * Decrypt an EncryptedField back to plaintext. - * - * @param field - EncryptedField object - * @param key - CryptoKey (must match the key used to encrypt) - * @param aad - Optional AAD (must match the AAD used during encryption) - * @returns Decrypted UTF-8 string - * @throws DOMException if authentication tag verification fails - */ -export async function decryptField( - field: EncryptedField, - key: CryptoKey, - aad?: string -): Promise { - const subtle = getSubtle(); - - const iv = fromHex(field.iv); - const ct = fromHex(field.ct); - const tag = fromHex(field.tag); - - // Web Crypto expects ciphertext || tag concatenated - const ciphertextWithTag = new Uint8Array(ct.length + tag.length); - ciphertextWithTag.set(ct, 0); - ciphertextWithTag.set(tag, ct.length); - - const params: AesGcmParams = { - name: ALGORITHM, - iv: iv.buffer as ArrayBuffer, - tagLength: TAG_BITS, - }; - - if (aad) { - params.additionalData = new TextEncoder().encode(aad).buffer as ArrayBuffer; - } - - const plaintextBytes = new Uint8Array( - await subtle.decrypt(params, key, ciphertextWithTag.buffer as ArrayBuffer) - ); - - return new TextDecoder().decode(plaintextBytes); -} - -/** - * Generate a random AES-256-GCM CryptoKey. - * - * @param extractable - Whether the key material can be exported (default: true). - * Set to `false` for non-extractable keys stored in IndexedDB. - */ -export async function generateKey(extractable = true): Promise { - const subtle = getSubtle(); - return subtle.generateKey({ name: ALGORITHM, length: KEY_SIZE_BITS }, extractable, [ - 'encrypt', - 'decrypt', - ]); -} - -/** - * Import a hex-encoded key string as a CryptoKey. - * - * @param hex - 64 hex chars = 32 bytes - * @param extractable - Whether the imported key can be exported (default: true) - */ -export async function keyFromHex(hex: string, extractable = true): Promise { - const subtle = getSubtle(); - const keyBytes = fromHex(hex); - if (keyBytes.length !== KEY_SIZE_BITS / 8) { - throw new Error(`AES-256-GCM requires a 32-byte key, got ${keyBytes.length}`); - } - return subtle.importKey( - 'raw', - keyBytes.buffer as ArrayBuffer, - { name: ALGORITHM, length: KEY_SIZE_BITS }, - extractable, - ['encrypt', 'decrypt'] - ); -} - -/** - * Export a CryptoKey to a hex-encoded string. - * Only works if the key was created with `extractable: true`. - */ -export async function keyToHex(key: CryptoKey): Promise { - const subtle = getSubtle(); - const raw = new Uint8Array(await subtle.exportKey('raw', key)); - return toHex(raw); -} - -/** - * Derive an AES-256 key from a passphrase using PBKDF2. - * - * @param passphrase - User passphrase - * @param salt - Random salt (at least 16 bytes recommended) - * @param iterations - PBKDF2 iterations (default: 600,000 per OWASP 2023) - * @param extractable - Whether derived key can be exported (default: false) - */ -export async function deriveKey( - passphrase: string, - salt: Uint8Array, - iterations = 600_000, - extractable = false -): Promise { - const subtle = getSubtle(); - const encoder = new TextEncoder(); - - const baseKey = await subtle.importKey( - 'raw', - encoder.encode(passphrase).buffer as ArrayBuffer, - 'PBKDF2', - false, - ['deriveKey'] - ); - - return subtle.deriveKey( - { - name: 'PBKDF2', - salt: salt.buffer as ArrayBuffer, - iterations, - hash: 'SHA-256', - }, - baseKey, - { name: ALGORITHM, length: KEY_SIZE_BITS }, - extractable, - ['encrypt', 'decrypt'] - ); -} diff --git a/vendor/bytelyst/client-encrypt/src/guards.ts b/vendor/bytelyst/client-encrypt/src/guards.ts deleted file mode 100644 index 7f7a5f9..0000000 --- a/vendor/bytelyst/client-encrypt/src/guards.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * @bytelyst/client-encrypt — Type guards - * - * Compatible with @bytelyst/field-encrypt isEncryptedField() on the server. - */ - -import type { EncryptedField } from './types.js'; - -/** Check if a value is an EncryptedField object. */ -export function isEncryptedField(value: unknown): value is EncryptedField { - if (typeof value !== 'object' || value === null) return false; - const obj = value as Record; - return ( - obj.__encrypted === true && - obj.v !== undefined && - obj.alg !== undefined && - typeof obj.ct === 'string' && - typeof obj.iv === 'string' && - typeof obj.tag === 'string' && - typeof obj.dekId === 'string' - ); -} diff --git a/vendor/bytelyst/client-encrypt/src/hex.ts b/vendor/bytelyst/client-encrypt/src/hex.ts deleted file mode 100644 index 9caf472..0000000 --- a/vendor/bytelyst/client-encrypt/src/hex.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * @bytelyst/client-encrypt — Hex encoding utilities - * - * Converts between Uint8Array and hex strings. - * Compatible with the hex encoding used by @bytelyst/field-encrypt (Node.js) - * and BLFieldEncrypt (Swift/Kotlin). - */ - -/** Encode a Uint8Array to a lowercase hex string. */ -export function toHex(bytes: Uint8Array): string { - const parts: string[] = new Array(bytes.length); - for (let i = 0; i < bytes.length; i++) { - parts[i] = bytes[i].toString(16).padStart(2, '0'); - } - return parts.join(''); -} - -/** Decode a hex string to a Uint8Array. */ -export function fromHex(hex: string): Uint8Array { - if (hex.length % 2 !== 0) { - throw new Error(`Hex string must have even length, got ${hex.length}`); - } - const bytes = new Uint8Array(hex.length / 2); - for (let i = 0; i < bytes.length; i++) { - bytes[i] = parseInt(hex.substring(i * 2, i * 2 + 2), 16); - } - return bytes; -} diff --git a/vendor/bytelyst/client-encrypt/src/index.ts b/vendor/bytelyst/client-encrypt/src/index.ts deleted file mode 100644 index 951c981..0000000 --- a/vendor/bytelyst/client-encrypt/src/index.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * @bytelyst/client-encrypt - * - * Client-side AES-256-GCM field encryption using Web Crypto API. - * Works in browsers and React Native (with SubtleCrypto polyfill). - * Wire-compatible with @bytelyst/field-encrypt (server) and - * BLFieldEncrypt (Swift/Kotlin native SDKs). - * - * @example - * ```typescript - * import { generateKey, encryptField, decryptField } from '@bytelyst/client-encrypt'; - * - * const key = await generateKey(); - * const encrypted = await encryptField('sensitive data', key, 'dek_user1_notes'); - * const plaintext = await decryptField(encrypted, key); - * ``` - */ - -// ── Main API ──────────────────────────────────────── -export { - encryptField, - decryptField, - generateKey, - keyFromHex, - keyToHex, - deriveKey, -} from './aes-gcm.js'; - -// ── Type guards ───────────────────────────────────── -export { isEncryptedField } from './guards.js'; - -// ── Hex utilities ─────────────────────────────────── -export { toHex, fromHex } from './hex.js'; - -// ── Types ─────────────────────────────────────────── -export type { EncryptedField, ClientEncryptContext } from './types.js'; diff --git a/vendor/bytelyst/client-encrypt/src/types.ts b/vendor/bytelyst/client-encrypt/src/types.ts deleted file mode 100644 index 59ea370..0000000 --- a/vendor/bytelyst/client-encrypt/src/types.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * @bytelyst/client-encrypt — Types - * - * Shared type definitions for client-side field encryption. - * Wire-compatible with @bytelyst/field-encrypt (server) and - * BLFieldEncrypt (Swift/Kotlin native SDKs). - */ - -/** Encrypted field stored in Cosmos DB or API responses. */ -export interface EncryptedField { - /** Sentinel — always true for encrypted fields. */ - readonly __encrypted: true; - /** Schema version for future algorithm changes. */ - readonly v: 1; - /** Algorithm identifier. */ - readonly alg: 'aes-256-gcm'; - /** Ciphertext (hex-encoded). */ - readonly ct: string; - /** Initialization vector (hex-encoded, 12 bytes / 24 hex chars). */ - readonly iv: string; - /** GCM authentication tag (hex-encoded, 16 bytes / 32 hex chars). */ - readonly tag: string; - /** DEK identifier — identifies which key to use for decryption. */ - readonly dekId: string; -} - -/** Options for encrypt/decrypt operations. */ -export interface ClientEncryptContext { - /** Scope for DEK isolation (typically userId). */ - readonly userId: string; - /** Additional context for DEK naming and AAD (e.g., 'transcripts', 'notes'). */ - readonly context: string; -} diff --git a/vendor/bytelyst/client-encrypt/tsconfig.json b/vendor/bytelyst/client-encrypt/tsconfig.json deleted file mode 100644 index 318c075..0000000 --- a/vendor/bytelyst/client-encrypt/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src", - "lib": ["ES2022", "DOM"] - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/vendor/bytelyst/config/package.json b/vendor/bytelyst/config/package.json deleted file mode 100644 index 433cbda..0000000 --- a/vendor/bytelyst/config/package.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "name": "@bytelyst/config", - "version": "0.1.5", - "type": "module", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - }, - "./keyvault": { - "import": "./dist/keyvault.js", - "types": "./dist/keyvault.d.ts" - }, - "./product-identity": { - "import": "./dist/product-identity.js", - "types": "./dist/product-identity.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks" - }, - "peerDependencies": { - "@azure/identity": ">=4.0.0", - "@azure/keyvault-secrets": ">=4.8.0", - "zod": ">=3.20.0" - }, - "peerDependenciesMeta": { - "@azure/identity": { - "optional": true - }, - "@azure/keyvault-secrets": { - "optional": true - } - }, - "devDependencies": { - "@azure/identity": "^4.13.0", - "@azure/keyvault-secrets": "^4.10.0" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/config/src/__tests__/config.test.ts b/vendor/bytelyst/config/src/__tests__/config.test.ts deleted file mode 100644 index ae64c6c..0000000 --- a/vendor/bytelyst/config/src/__tests__/config.test.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { describe, expect, it, beforeEach, afterEach } from 'vitest'; -import { - baseEnvSchema, - loadConfig, - loadProductIdentity, - getProductId, - _resetProductIdentity, -} from '../index.js'; - -describe('baseEnvSchema', () => { - it('provides defaults for PORT, HOST, NODE_ENV, COSMOS_DATABASE', () => { - const result = baseEnvSchema.parse({ - SERVICE_NAME: 'test-svc', - COSMOS_ENDPOINT: 'https://test.cosmos.azure.com:443/', - COSMOS_KEY: 'key==', - }); - expect(result.PORT).toBe(3000); - expect(result.HOST).toBe('0.0.0.0'); - expect(result.NODE_ENV).toBe('development'); - expect(result.COSMOS_DATABASE).toBe('lysnrai'); - }); - - it('rejects missing SERVICE_NAME', () => { - expect(() => - baseEnvSchema.parse({ - COSMOS_ENDPOINT: 'https://test.cosmos.azure.com:443/', - COSMOS_KEY: 'key==', - }) - ).toThrow(); - }); - - it('rejects missing COSMOS_ENDPOINT', () => { - expect(() => - baseEnvSchema.parse({ - SERVICE_NAME: 'svc', - COSMOS_KEY: 'key==', - }) - ).toThrow(); - }); - - it('rejects missing COSMOS_KEY', () => { - expect(() => - baseEnvSchema.parse({ - SERVICE_NAME: 'svc', - COSMOS_ENDPOINT: 'https://test.cosmos.azure.com:443/', - }) - ).toThrow(); - }); - - it('coerces PORT from string', () => { - const result = baseEnvSchema.parse({ - PORT: '4003', - SERVICE_NAME: 'svc', - COSMOS_ENDPOINT: 'https://test.cosmos.azure.com:443/', - COSMOS_KEY: 'key==', - }); - expect(result.PORT).toBe(4003); - }); - - it('accepts valid NODE_ENV values', () => { - for (const env of ['development', 'production', 'test']) { - const result = baseEnvSchema.parse({ - NODE_ENV: env, - SERVICE_NAME: 'svc', - COSMOS_ENDPOINT: 'https://test.cosmos.azure.com:443/', - COSMOS_KEY: 'key==', - }); - expect(result.NODE_ENV).toBe(env); - } - }); - - it('rejects invalid NODE_ENV', () => { - expect(() => - baseEnvSchema.parse({ - NODE_ENV: 'staging', - SERVICE_NAME: 'svc', - COSMOS_ENDPOINT: 'https://test.cosmos.azure.com:443/', - COSMOS_KEY: 'key==', - }) - ).toThrow(); - }); -}); - -describe('loadConfig', () => { - const origEnv = process.env; - - beforeEach(() => { - process.env = { - ...origEnv, - SERVICE_NAME: 'test-svc', - COSMOS_ENDPOINT: 'https://test.cosmos.azure.com:443/', - COSMOS_KEY: 'key==', - }; - }); - - afterEach(() => { - process.env = origEnv; - }); - - it('parses base env without extension', () => { - const config = loadConfig(); - expect(config.SERVICE_NAME).toBe('test-svc'); - expect(config.PORT).toBe(3000); - }); - - it('extends with additional fields', async () => { - process.env.STRIPE_KEY = 'sk_test_123'; - const { z } = await import('zod'); - const config = loadConfig({ STRIPE_KEY: z.string().min(1) }); - expect(config.STRIPE_KEY).toBe('sk_test_123'); - expect(config.SERVICE_NAME).toBe('test-svc'); - }); - - it('throws on missing required extension field', async () => { - const { z } = await import('zod'); - expect(() => loadConfig({ MISSING_FIELD: z.string().min(1) })).toThrow(); - }); -}); - -describe('productIdentity', () => { - beforeEach(() => { - _resetProductIdentity(); - }); - - it('falls back to env vars', () => { - process.env.PRODUCT_ID = 'testprod'; - process.env.DISPLAY_NAME = 'TestProd'; - const identity = loadProductIdentity(); - expect(identity.productId).toBe('testprod'); - expect(identity.displayName).toBe('TestProd'); - delete process.env.PRODUCT_ID; - delete process.env.DISPLAY_NAME; - }); - - it('defaults to lysnrai when no env or file', () => { - delete process.env.PRODUCT_ID; - const identity = loadProductIdentity(); - expect(identity.productId).toBe('lysnrai'); - expect(identity.displayName).toBe('LysnrAI'); - expect(identity.licensePrefix).toBe('LYSNR'); - }); - - it('getProductId returns just the ID', () => { - _resetProductIdentity(); - delete process.env.PRODUCT_ID; - expect(getProductId()).toBe('lysnrai'); - }); - - it('caches identity after first load', () => { - delete process.env.PRODUCT_ID; - const id1 = loadProductIdentity(); - process.env.PRODUCT_ID = 'changed'; - const id2 = loadProductIdentity(); - expect(id1).toBe(id2); // same cached object - delete process.env.PRODUCT_ID; - }); - - it('_resetProductIdentity clears cache', () => { - delete process.env.PRODUCT_ID; - loadProductIdentity(); - _resetProductIdentity(); - process.env.PRODUCT_ID = 'newprod'; - const fresh = loadProductIdentity(); - expect(fresh.productId).toBe('newprod'); - delete process.env.PRODUCT_ID; - }); -}); diff --git a/vendor/bytelyst/config/src/__tests__/keyvault.test.ts b/vendor/bytelyst/config/src/__tests__/keyvault.test.ts deleted file mode 100644 index 659f193..0000000 --- a/vendor/bytelyst/config/src/__tests__/keyvault.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -/** - * Tests for Azure Key Vault secret resolution. - */ - -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { resolveKeyVaultSecrets, LYSNR_SECRETS } from '../keyvault.js'; -import type { SecretMapping } from '../keyvault.js'; - -// Mock Azure SDK dynamic imports to prevent test timeouts -const { mockGetSecret } = vi.hoisted(() => { - const mockGetSecret = vi.fn(); - return { mockGetSecret }; -}); - -vi.mock('@azure/identity', () => ({ - DefaultAzureCredential: vi.fn(), -})); - -vi.mock('@azure/keyvault-secrets', () => ({ - SecretClient: vi.fn().mockImplementation(() => ({ - getSecret: mockGetSecret, - })), -})); - -describe('resolveKeyVaultSecrets', () => { - const originalEnv = { ...process.env }; - - beforeEach(() => { - // Clean env vars used in tests - delete process.env.AZURE_KEYVAULT_URL; - delete process.env.TEST_SECRET_A; - delete process.env.TEST_SECRET_B; - mockGetSecret.mockReset(); - }); - - afterEach(() => { - process.env = { ...originalEnv }; - vi.clearAllMocks(); - }); - - it('skips entirely when AZURE_KEYVAULT_URL is not set', async () => { - const secrets: SecretMapping[] = [{ kvName: 'test-secret', envVar: 'TEST_SECRET_A' }]; - - await resolveKeyVaultSecrets(secrets); - - // Should not have set the env var (no KV to resolve from) - expect(process.env.TEST_SECRET_A).toBeUndefined(); - }); - - it('skips secrets that already exist in env', async () => { - process.env.AZURE_KEYVAULT_URL = 'https://kv-test.vault.azure.net'; - process.env.TEST_SECRET_A = 'already-set'; - - const secrets: SecretMapping[] = [{ kvName: 'test-secret-a', envVar: 'TEST_SECRET_A' }]; - - // Should not attempt KV call since all secrets are present - await resolveKeyVaultSecrets(secrets); - - expect(process.env.TEST_SECRET_A).toBe('already-set'); - }); - - it('accepts custom vaultUrl via opts', async () => { - mockGetSecret.mockResolvedValue({ value: 'resolved-value' }); - - const secrets: SecretMapping[] = [{ kvName: 'test-secret', envVar: 'TEST_SECRET_A' }]; - - await resolveKeyVaultSecrets(secrets, { vaultUrl: 'https://kv-test.vault.azure.net' }); - - expect(process.env.TEST_SECRET_A).toBe('resolved-value'); - expect(mockGetSecret).toHaveBeenCalledWith('test-secret'); - }); - - it('handles empty secrets array', async () => { - process.env.AZURE_KEYVAULT_URL = 'https://kv-test.vault.azure.net'; - - await expect(resolveKeyVaultSecrets([])).resolves.not.toThrow(); - }); - - it('resolves multiple missing secrets from Key Vault', async () => { - process.env.AZURE_KEYVAULT_URL = 'https://kv-test.vault.azure.net'; - mockGetSecret - .mockResolvedValueOnce({ value: 'secret-a-val' }) - .mockResolvedValueOnce({ value: 'secret-b-val' }); - - const secrets: SecretMapping[] = [ - { kvName: 'secret-a', envVar: 'TEST_SECRET_A' }, - { kvName: 'secret-b', envVar: 'TEST_SECRET_B' }, - ]; - - await resolveKeyVaultSecrets(secrets); - - expect(process.env.TEST_SECRET_A).toBe('secret-a-val'); - expect(process.env.TEST_SECRET_B).toBe('secret-b-val'); - }); - - it('warns but does not throw when getSecret fails', async () => { - process.env.AZURE_KEYVAULT_URL = 'https://kv-test.vault.azure.net'; - mockGetSecret.mockRejectedValue(new Error('SecretNotFound')); - - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - const secrets: SecretMapping[] = [{ kvName: 'bad-secret', envVar: 'TEST_SECRET_A' }]; - - await resolveKeyVaultSecrets(secrets); - - expect(process.env.TEST_SECRET_A).toBeUndefined(); - expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('1/1 secrets failed')); - - warnSpy.mockRestore(); - }); - - it('filters to only missing secrets — skips already-present', async () => { - process.env.AZURE_KEYVAULT_URL = 'https://kv-test.vault.azure.net'; - process.env.TEST_SECRET_A = 'present'; - mockGetSecret.mockResolvedValue({ value: 'from-kv' }); - - const secrets: SecretMapping[] = [ - { kvName: 'secret-a', envVar: 'TEST_SECRET_A' }, - { kvName: 'secret-b', envVar: 'TEST_SECRET_B' }, - ]; - - await resolveKeyVaultSecrets(secrets); - - // TEST_SECRET_A should remain unchanged (already present) - expect(process.env.TEST_SECRET_A).toBe('present'); - // TEST_SECRET_B should be resolved from KV - expect(process.env.TEST_SECRET_B).toBe('from-kv'); - // getSecret should only be called for the missing secret - expect(mockGetSecret).toHaveBeenCalledTimes(1); - expect(mockGetSecret).toHaveBeenCalledWith('secret-b'); - }); -}); - -describe('LYSNR_SECRETS', () => { - it('exports all expected secret mappings', () => { - const expectedKeys = [ - 'COSMOS_KEY', - 'COSMOS_ENDPOINT', - 'JWT_SECRET', - 'STRIPE_SECRET_KEY', - 'STRIPE_WEBHOOK_SECRET', - 'BILLING_INTERNAL_KEY', - 'AZURE_BLOB_CONNECTION_STRING', - 'AZURE_BLOB_ACCOUNT_KEY', - 'GEMINI_API_KEY', - 'SEED_SECRET', - 'AZURE_SPEECH_KEY', - 'AZURE_OPENAI_KEY', - 'AZURE_OPENAI_ENDPOINT', - ]; - - for (const key of expectedKeys) { - expect(LYSNR_SECRETS).toHaveProperty(key); - } - }); - - it('each mapping has kvName and envVar', () => { - for (const [key, mapping] of Object.entries(LYSNR_SECRETS)) { - expect(mapping.kvName).toBeDefined(); - expect(typeof mapping.kvName).toBe('string'); - expect(mapping.kvName.length).toBeGreaterThan(0); - - expect(mapping.envVar).toBeDefined(); - expect(typeof mapping.envVar).toBe('string'); - expect(mapping.envVar).toBe(key); - } - }); - - it('kvNames follow lysnr-* naming convention', () => { - for (const mapping of Object.values(LYSNR_SECRETS)) { - expect(mapping.kvName).toMatch(/^lysnr-/); - } - }); - - it('envVars are UPPER_SNAKE_CASE', () => { - for (const mapping of Object.values(LYSNR_SECRETS)) { - expect(mapping.envVar).toMatch(/^[A-Z][A-Z0-9_]*$/); - } - }); -}); diff --git a/vendor/bytelyst/config/src/__tests__/product-manifest.test.ts b/vendor/bytelyst/config/src/__tests__/product-manifest.test.ts deleted file mode 100644 index b1abd84..0000000 --- a/vendor/bytelyst/config/src/__tests__/product-manifest.test.ts +++ /dev/null @@ -1,467 +0,0 @@ -import { describe, expect, it, beforeEach, afterEach } from 'vitest'; -import { writeFileSync, mkdirSync, rmSync } from 'node:fs'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; -import { - ProductManifestSchema, - ExtendedProductManifestSchema, - PlatformSchema, - ThemeSchema, - ContainerDefSchema, - FeatureFlagSchema, - BundleIdSchema, - DEFAULT_THEME, - loadProductManifest, - loadProductManifestSync, - resolveTheme, - validateProductManifest, - safeValidateProductManifest, -} from '../index.js'; - -// ── Minimal valid manifest ────────────────────────────────────────────────── - -const MINIMAL = { - productId: 'testprod', - displayName: 'TestProd', -}; - -// ── Full manifest (matches FlowMonk-style) ────────────────────────────────── - -const FULL = { - productId: 'flowmonk', - displayName: 'FlowMonk', - name: 'FlowMonk', - tagline: 'Agent-first planning and execution', - domain: 'flowmonk.app', - backendPort: 4017, - primarySurface: 'web', - mobileCompanion: true, - platforms: ['web', 'ios', 'android'], - bundleIds: { - ios: 'com.saravana.flowmonk', - android: 'com.saravana.flowmonk', - web: 'flowmonk.app', - }, - appStore: { - category: 'Productivity', - subcategory: 'Task Management', - ageRating: '4+', - privacyUrl: 'https://flowmonk.app/privacy', - termsUrl: 'https://flowmonk.app/terms', - supportUrl: 'https://flowmonk.app/support', - }, - cosmos: { - containers: [ - { name: 'zones', partitionKey: '/userId' }, - { name: 'flows', partitionKey: '/userId' }, - { name: 'tasks', partitionKey: '/userId' }, - ], - }, - flags: [{ key: 'new-scheduler', defaultValue: false, description: 'Enable v2 scheduler' }], - ports: { service: 4017 }, - version: '0.1.0', -}; - -// ── Tests ──────────────────────────────────────────────────────────────────── - -describe('ProductManifestSchema', () => { - it('parses a minimal manifest (just productId + displayName)', () => { - const result = ProductManifestSchema.parse(MINIMAL); - expect(result.productId).toBe('testprod'); - expect(result.displayName).toBe('TestProd'); - expect(result.platforms).toEqual(['web']); // default - expect(result.flags).toEqual([]); // default - }); - - it('parses a full manifest', () => { - const result = ProductManifestSchema.parse(FULL); - expect(result.productId).toBe('flowmonk'); - expect(result.backendPort).toBe(4017); - expect(result.cosmos?.containers).toHaveLength(3); - expect(result.flags).toHaveLength(1); - expect(result.appStore?.category).toBe('Productivity'); - }); - - it('rejects missing productId', () => { - expect(() => ProductManifestSchema.parse({ displayName: 'X' })).toThrow(); - }); - - it('rejects missing displayName', () => { - expect(() => ProductManifestSchema.parse({ productId: 'x' })).toThrow(); - }); - - it('rejects invalid productId (uppercase)', () => { - expect(() => ProductManifestSchema.parse({ productId: 'BadId', displayName: 'X' })).toThrow( - /lowercase/ - ); - }); - - it('rejects productId starting with number', () => { - expect(() => ProductManifestSchema.parse({ productId: '1bad', displayName: 'X' })).toThrow(); - }); - - it('allows hyphens in productId', () => { - const result = ProductManifestSchema.parse({ productId: 'my-app', displayName: 'My App' }); - expect(result.productId).toBe('my-app'); - }); - - it('rejects backendPort below 1024', () => { - expect(() => ProductManifestSchema.parse({ ...MINIMAL, backendPort: 80 })).toThrow(); - }); - - it('rejects backendPort above 65535', () => { - expect(() => ProductManifestSchema.parse({ ...MINIMAL, backendPort: 70000 })).toThrow(); - }); - - it('accepts valid backendPort', () => { - const result = ProductManifestSchema.parse({ ...MINIMAL, backendPort: 4016 }); - expect(result.backendPort).toBe(4016); - }); - - it('parses LysnrAI-style manifest (legacy fields)', () => { - const lysnrai = { - displayName: 'LysnrAI', - productId: 'lysnrai', - licensePrefix: 'LYSNR', - configDirName: '.LysnrAI', - envVarPrefix: 'LYSNR', - bundleIdSuffix: 'LysnrAI', - packageName: 'lysnrai', - }; - const result = ProductManifestSchema.parse(lysnrai); - expect(result.licensePrefix).toBe('LYSNR'); - expect(result.envVarPrefix).toBe('LYSNR'); - }); - - it('parses NoteLett-style manifest (bundleId as object)', () => { - const notelett = { - productId: 'notelett', - displayName: 'NoteLett', - bundleId: { ios: 'com.bytelyst.notelett', android: 'com.notelett.app' }, - backendPort: 4016, - appGroup: 'group.com.bytelyst.notelett', - }; - const result = ProductManifestSchema.parse(notelett); - expect(result.bundleId).toEqual({ ios: 'com.bytelyst.notelett', android: 'com.notelett.app' }); - expect(result.appGroup).toBe('group.com.bytelyst.notelett'); - }); - - it('parses NomGap-style manifest (bundleId as string)', () => { - const nomgap = { - productId: 'nomgap', - displayName: 'NomGap', - bundleId: 'com.saravana.nomgap', - domain: 'nomgap.app', - }; - const result = ProductManifestSchema.parse(nomgap); - expect(result.bundleId).toBe('com.saravana.nomgap'); - }); - - it('parses ActionTrail-style manifest (no mobile)', () => { - const actiontrail = { - productId: 'actiontrail', - displayName: 'ActionTrail', - tagline: 'AI Activity Oversight', - domain: 'actiontrail.dev', - backendPort: 4018, - primarySurface: 'web', - mobileCompanion: false, - }; - const result = ProductManifestSchema.parse(actiontrail); - expect(result.mobileCompanion).toBe(false); - expect(result.primarySurface).toBe('web'); - }); -}); - -describe('duplicate container validation', () => { - it('rejects duplicate container names', () => { - const manifest = { - ...MINIMAL, - cosmos: { - containers: [ - { name: 'users', partitionKey: '/userId' }, - { name: 'users', partitionKey: '/email' }, - ], - }, - }; - expect(() => ProductManifestSchema.parse(manifest)).toThrow(/Duplicate container name: users/); - }); - - it('allows unique container names', () => { - const manifest = { - ...MINIMAL, - cosmos: { - containers: [ - { name: 'users', partitionKey: '/userId' }, - { name: 'sessions', partitionKey: '/userId' }, - ], - }, - }; - const result = ProductManifestSchema.parse(manifest); - expect(result.cosmos?.containers).toHaveLength(2); - }); - - it('allows single container (no duplicate check needed)', () => { - const manifest = { - ...MINIMAL, - cosmos: { containers: [{ name: 'users', partitionKey: '/userId' }] }, - }; - const result = ProductManifestSchema.parse(manifest); - expect(result.cosmos?.containers).toHaveLength(1); - }); -}); - -describe('ExtendedProductManifestSchema', () => { - it('allows unknown keys (passthrough)', () => { - const result = ExtendedProductManifestSchema.parse({ - ...MINIMAL, - customField: 'hello', - nestedCustom: { x: 1 }, - }); - expect((result as Record).customField).toBe('hello'); - }); -}); - -describe('PlatformSchema', () => { - it('accepts valid platforms', () => { - for (const p of ['web', 'ios', 'android', 'desktop', 'watch', 'mac']) { - expect(PlatformSchema.parse(p)).toBe(p); - } - }); - - it('rejects invalid platform', () => { - expect(() => PlatformSchema.parse('windows')).toThrow(); - }); -}); - -describe('ThemeSchema', () => { - it('validates hex colors', () => { - const result = ThemeSchema.parse(DEFAULT_THEME); - expect(result.primary).toBe('#5AE68C'); - }); - - it('rejects non-hex colors', () => { - expect(() => ThemeSchema.parse({ ...DEFAULT_THEME, primary: 'red' })).toThrow(/hex/); - }); - - it('rejects 3-digit hex', () => { - expect(() => ThemeSchema.parse({ ...DEFAULT_THEME, primary: '#FFF' })).toThrow(); - }); -}); - -describe('ContainerDefSchema', () => { - it('parses valid container', () => { - const result = ContainerDefSchema.parse({ name: 'users', partitionKey: '/userId' }); - expect(result.name).toBe('users'); - }); - - it('accepts optional ttlSeconds and uniqueKeys', () => { - const result = ContainerDefSchema.parse({ - name: 'sessions', - partitionKey: '/userId', - ttlSeconds: 86400, - uniqueKeys: ['/email'], - }); - expect(result.ttlSeconds).toBe(86400); - expect(result.uniqueKeys).toEqual(['/email']); - }); - - it('rejects empty name', () => { - expect(() => ContainerDefSchema.parse({ name: '', partitionKey: '/x' })).toThrow(); - }); - - it('rejects negative ttlSeconds', () => { - expect(() => - ContainerDefSchema.parse({ name: 'x', partitionKey: '/x', ttlSeconds: -1 }) - ).toThrow(); - }); -}); - -describe('FeatureFlagSchema', () => { - it('accepts boolean default', () => { - const result = FeatureFlagSchema.parse({ key: 'beta', defaultValue: false }); - expect(result.defaultValue).toBe(false); - }); - - it('accepts string default', () => { - const result = FeatureFlagSchema.parse({ key: 'tier', defaultValue: 'free' }); - expect(result.defaultValue).toBe('free'); - }); - - it('accepts number default', () => { - const result = FeatureFlagSchema.parse({ key: 'max-items', defaultValue: 100 }); - expect(result.defaultValue).toBe(100); - }); - - it('rejects empty key', () => { - expect(() => FeatureFlagSchema.parse({ key: '', defaultValue: true })).toThrow(); - }); -}); - -describe('BundleIdSchema', () => { - it('accepts string bundle ID', () => { - expect(BundleIdSchema.parse('com.example.app')).toBe('com.example.app'); - }); - - it('accepts per-platform bundle IDs', () => { - const result = BundleIdSchema.parse({ ios: 'com.ios.app', android: 'com.android.app' }); - expect(result).toEqual({ ios: 'com.ios.app', android: 'com.android.app' }); - }); - - it('rejects empty string', () => { - expect(() => BundleIdSchema.parse('')).toThrow(); - }); -}); - -describe('resolveTheme', () => { - it('returns defaults when no theme specified', () => { - const manifest = ProductManifestSchema.parse(MINIMAL); - const theme = resolveTheme(manifest); - expect(theme).toEqual(DEFAULT_THEME); - }); - - it('merges partial theme with defaults', () => { - const manifest = ProductManifestSchema.parse({ - ...MINIMAL, - theme: { primary: '#FF0000' }, - }); - const theme = resolveTheme(manifest); - expect(theme.primary).toBe('#FF0000'); - expect(theme.secondary).toBe(DEFAULT_THEME.secondary); // default preserved - }); -}); - -describe('validateProductManifest', () => { - it('validates a valid object', () => { - const result = validateProductManifest(MINIMAL); - expect(result.productId).toBe('testprod'); - }); - - it('throws on invalid object', () => { - expect(() => validateProductManifest({ bad: true })).toThrow(); - }); -}); - -describe('safeValidateProductManifest', () => { - it('returns manifest on valid input', () => { - const result = safeValidateProductManifest(MINIMAL); - expect(result).not.toBeNull(); - expect(result!.productId).toBe('testprod'); - }); - - it('returns null on invalid input', () => { - const result = safeValidateProductManifest({ bad: true }); - expect(result).toBeNull(); - }); -}); - -describe('loadProductManifest / loadProductManifestSync', () => { - const tmpDir = join(tmpdir(), `manifest-test-${Date.now()}`); - const validPath = join(tmpDir, 'valid.json'); - const invalidPath = join(tmpDir, 'invalid.json'); - - beforeEach(() => { - mkdirSync(tmpDir, { recursive: true }); - writeFileSync(validPath, JSON.stringify(FULL)); - writeFileSync(invalidPath, JSON.stringify({ bad: true })); - }); - - afterEach(() => { - rmSync(tmpDir, { recursive: true, force: true }); - }); - - it('loads and validates from file (async)', async () => { - const result = await loadProductManifest(validPath); - expect(result.productId).toBe('flowmonk'); - expect(result.cosmos?.containers).toHaveLength(3); - }); - - it('loads and validates from file (sync)', () => { - const result = loadProductManifestSync(validPath); - expect(result.productId).toBe('flowmonk'); - }); - - it('throws on invalid file (async)', async () => { - await expect(loadProductManifest(invalidPath)).rejects.toThrow(); - }); - - it('throws on invalid file (sync)', () => { - expect(() => loadProductManifestSync(invalidPath)).toThrow(); - }); - - it('throws on missing file (async)', async () => { - await expect(loadProductManifest('/nonexistent/path.json')).rejects.toThrow(); - }); - - it('throws on missing file (sync)', () => { - expect(() => loadProductManifestSync('/nonexistent/path.json')).toThrow(); - }); -}); - -describe('real-world product.json files parse cleanly', () => { - it('parses LysnrAI product.json', () => { - const json = { - displayName: 'LysnrAI', - productId: 'lysnrai', - licensePrefix: 'LYSNR', - configDirName: '.LysnrAI', - envVarPrefix: 'LYSNR', - bundleIdSuffix: 'LysnrAI', - packageName: 'lysnrai', - }; - expect(() => ProductManifestSchema.parse(json)).not.toThrow(); - }); - - it('parses NomGap product.json', () => { - const json = { - productId: 'nomgap', - displayName: 'NomGap', - bundleId: 'com.saravana.nomgap', - domain: 'nomgap.app', - }; - expect(() => ProductManifestSchema.parse(json)).not.toThrow(); - }); - - it('parses NoteLett product.json', () => { - const json = { - productId: 'notelett', - displayName: 'NoteLett', - licensePrefix: 'NOTELETT', - configDirName: '.NoteLett', - envVarPrefix: 'NOTELETT', - bundleIdSuffix: 'notelett', - packageName: 'notelett', - domain: 'notelett.app', - bundleId: { ios: 'com.bytelyst.notelett', android: 'com.notelett.app' }, - appGroup: 'group.com.bytelyst.notelett', - backendPort: 4016, - }; - expect(() => ProductManifestSchema.parse(json)).not.toThrow(); - }); - - it('parses FlowMonk product.json', () => { - expect(() => ProductManifestSchema.parse(FULL)).not.toThrow(); - }); - - it('parses ActionTrail product.json', () => { - const json = { - productId: 'actiontrail', - displayName: 'ActionTrail', - tagline: 'AI Activity Oversight', - domain: 'actiontrail.dev', - backendPort: 4018, - primarySurface: 'web', - mobileCompanion: false, - bundleIds: { web: 'actiontrail.dev' }, - appStore: { - category: 'Developer Tools', - subcategory: 'AI Monitoring', - privacyUrl: 'https://actiontrail.dev/privacy', - termsUrl: 'https://actiontrail.dev/terms', - supportUrl: 'https://actiontrail.dev/support', - }, - version: '0.1.0', - }; - expect(() => ProductManifestSchema.parse(json)).not.toThrow(); - }); -}); diff --git a/vendor/bytelyst/config/src/base-schema.ts b/vendor/bytelyst/config/src/base-schema.ts deleted file mode 100644 index 89bf02a..0000000 --- a/vendor/bytelyst/config/src/base-schema.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Base environment schema shared by all Fastify microservices. - * Each service extends this with its own fields via loadConfig(). - */ - -import { z } from 'zod'; - -export const baseEnvSchema = z.object({ - PORT: z.coerce.number().default(3000), - HOST: z.string().default('0.0.0.0'), - NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), - CORS_ORIGIN: z.string().optional(), - SERVICE_NAME: z.string(), - COSMOS_ENDPOINT: z.string().min(1, 'COSMOS_ENDPOINT is required'), - COSMOS_KEY: z.string().min(1, 'COSMOS_KEY is required'), - COSMOS_DATABASE: z.string().default('lysnrai'), -}); - -export type BaseEnv = z.infer; diff --git a/vendor/bytelyst/config/src/index.ts b/vendor/bytelyst/config/src/index.ts deleted file mode 100644 index 1184ecb..0000000 --- a/vendor/bytelyst/config/src/index.ts +++ /dev/null @@ -1,38 +0,0 @@ -export { baseEnvSchema, type BaseEnv } from './base-schema.js'; -export { loadConfig } from './loader.js'; -export { - loadProductIdentity, - getProductId, - _resetProductIdentity, - type ProductIdentity, -} from './product-identity.js'; -export { - resolveSecrets, - resolveKeyVaultSecrets, - LYSNR_SECRETS, - type SecretMapping, - type SecretsProviderType, -} from './keyvault.js'; -export { - ProductManifestSchema, - PlatformSchema, - ThemeSchema, - ContainerDefSchema, - FeatureFlagSchema, - PortConfigSchema, - BundleIdSchema, - AppStoreSchema, - ExtendedProductManifestSchema, - DEFAULT_THEME, - loadProductManifest, - loadProductManifestSync, - resolveTheme, - validateProductManifest, - safeValidateProductManifest, - type ProductManifest, - type Platform, - type Theme, - type ContainerDef, - type FeatureFlag, - type PortConfig, -} from './product-manifest.js'; diff --git a/vendor/bytelyst/config/src/keyvault.ts b/vendor/bytelyst/config/src/keyvault.ts deleted file mode 100644 index 18db0c9..0000000 --- a/vendor/bytelyst/config/src/keyvault.ts +++ /dev/null @@ -1,136 +0,0 @@ -/** - * Cloud-agnostic secret resolution for Node.js services + dashboards. - * - * Call resolveSecrets() BEFORE Zod config parsing to populate - * process.env from a secrets provider. Falls back gracefully when unavailable. - * - * Provider selection via SECRETS_PROVIDER env var: - * - 'azure-keyvault' (default if AZURE_KEYVAULT_URL is set) — Azure Key Vault - * - 'env' (default if no vault URL) — do nothing, use .env values as-is - * - * Backward compatible: resolveKeyVaultSecrets() still works identically. - */ - -export type SecretsProviderType = 'azure-keyvault' | 'env'; - -export interface SecretMapping { - /** Provider-specific secret name (e.g. 'lysnr-cosmos-key' for AKV) */ - kvName: string; - /** Environment variable name to populate (e.g. 'COSMOS_KEY') */ - envVar: string; -} - -/** - * Resolve which secrets provider to use. - */ -function resolveSecretsProvider(): SecretsProviderType { - const explicit = (process.env.SECRETS_PROVIDER || '').toLowerCase(); - if (explicit === 'azure-keyvault' || explicit === 'azure') return 'azure-keyvault'; - if (explicit === 'env') return 'env'; - - // Auto-detect: use AKV if AZURE_KEYVAULT_URL is set - if (process.env.AZURE_KEYVAULT_URL) return 'azure-keyvault'; - return 'env'; -} - -/** - * Cloud-agnostic secret resolution into process.env. - * - * - Only fetches secrets whose env var is empty/unset (env takes precedence). - * - Skips entirely if provider is 'env' or no vault is configured. - * - Logs warnings but does NOT throw — services fall back to .env values. - * - * @param secrets - Array of {kvName, envVar} mappings - * @param opts - Optional overrides - */ -export async function resolveSecrets( - secrets: SecretMapping[], - opts?: { vaultUrl?: string; provider?: SecretsProviderType } -): Promise { - const provider = opts?.provider ?? resolveSecretsProvider(); - - if (provider === 'env') return; // Nothing to resolve — use env vars as-is - - if (provider === 'azure-keyvault') { - return resolveAzureKeyVaultSecrets(secrets, opts); - } -} - -/** - * Resolve secrets from Azure Key Vault into process.env. - * @deprecated Use resolveSecrets() instead — this is kept for backward compatibility. - */ -export async function resolveKeyVaultSecrets( - secrets: SecretMapping[], - opts?: { vaultUrl?: string } -): Promise { - return resolveAzureKeyVaultSecrets(secrets, opts); -} - -/** - * Azure Key Vault implementation. - */ -async function resolveAzureKeyVaultSecrets( - secrets: SecretMapping[], - opts?: { vaultUrl?: string } -): Promise { - const vaultUrl = opts?.vaultUrl || process.env.AZURE_KEYVAULT_URL; - if (!vaultUrl) return; // No KV configured — use env vars as-is - - // Filter to only secrets that are missing from env - const missing = secrets.filter(s => !process.env[s.envVar]); - if (missing.length === 0) return; // All secrets already in env - - try { - const { DefaultAzureCredential } = await import('@azure/identity'); - const { SecretClient } = await import('@azure/keyvault-secrets'); - - const client = new SecretClient(vaultUrl, new DefaultAzureCredential()); - - const results = await Promise.allSettled( - missing.map(async s => { - const secret = await client.getSecret(s.kvName); - if (secret.value) { - process.env[s.envVar] = secret.value; - } - }) - ); - - const failures = results.filter(r => r.status === 'rejected'); - if (failures.length > 0) { - // eslint-disable-next-line no-console -- Startup secret-resolution diagnostics must remain visible before app loggers exist. - console.warn( - `[secrets] ${failures.length}/${missing.length} secrets failed to resolve — falling back to env vars` - ); - } - } catch { - // eslint-disable-next-line no-console -- Startup secret-resolution diagnostics must remain visible before app loggers exist. - console.warn(`[secrets] Unable to connect to Key Vault at ${vaultUrl} — using env vars`); - } -} - -/** - * Standard secret mappings used across all LysnrAI services. - * Services pick the subset they need. - */ -export const LYSNR_SECRETS = { - COSMOS_KEY: { kvName: 'lysnr-cosmos-key', envVar: 'COSMOS_KEY' }, - COSMOS_ENDPOINT: { kvName: 'lysnr-cosmos-endpoint', envVar: 'COSMOS_ENDPOINT' }, - JWT_SECRET: { kvName: 'lysnr-jwt-secret', envVar: 'JWT_SECRET' }, - STRIPE_SECRET_KEY: { kvName: 'lysnr-stripe-secret-key', envVar: 'STRIPE_SECRET_KEY' }, - STRIPE_WEBHOOK_SECRET: { kvName: 'lysnr-stripe-webhook-secret', envVar: 'STRIPE_WEBHOOK_SECRET' }, - BILLING_INTERNAL_KEY: { kvName: 'lysnr-billing-internal-key', envVar: 'BILLING_INTERNAL_KEY' }, - AZURE_BLOB_CONNECTION_STRING: { - kvName: 'lysnr-blob-connection-string', - envVar: 'AZURE_BLOB_CONNECTION_STRING', - }, - AZURE_BLOB_ACCOUNT_KEY: { kvName: 'lysnr-blob-account-key', envVar: 'AZURE_BLOB_ACCOUNT_KEY' }, - GEMINI_API_KEY: { kvName: 'lysnr-gemini-api-key', envVar: 'GEMINI_API_KEY' }, - SEED_SECRET: { kvName: 'lysnr-seed-secret', envVar: 'SEED_SECRET' }, - AZURE_SPEECH_KEY: { kvName: 'lysnr-azure-speech-key', envVar: 'AZURE_SPEECH_KEY' }, - AZURE_OPENAI_KEY: { kvName: 'lysnr-azure-openai-key', envVar: 'AZURE_OPENAI_KEY' }, - AZURE_OPENAI_ENDPOINT: { - kvName: 'lysnr-azure-openai-endpoint', - envVar: 'AZURE_OPENAI_ENDPOINT', - }, -} as const satisfies Record; diff --git a/vendor/bytelyst/config/src/loader.ts b/vendor/bytelyst/config/src/loader.ts deleted file mode 100644 index 3ede866..0000000 --- a/vendor/bytelyst/config/src/loader.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Config loader — parses process.env against the base schema + any extensions. - */ - -import { type ZodRawShape, z } from 'zod'; -import { baseEnvSchema } from './base-schema.js'; - -/** - * Load and validate environment configuration. - * - * @param extension - Additional Zod fields specific to this service - * @returns Parsed and validated config object - * - * @example - * ```ts - * const config = loadConfig({ - * STRIPE_SECRET_KEY: z.string().min(1), - * BILLING_INTERNAL_KEY: z.string().optional(), - * }); - * ``` - */ -export function loadConfig(extension?: T) { - const schema = extension ? baseEnvSchema.extend(extension) : baseEnvSchema; - return schema.parse(process.env) as z.infer & - (T extends ZodRawShape ? z.infer> : Record); -} diff --git a/vendor/bytelyst/config/src/product-identity.ts b/vendor/bytelyst/config/src/product-identity.ts deleted file mode 100644 index 1b961cc..0000000 --- a/vendor/bytelyst/config/src/product-identity.ts +++ /dev/null @@ -1,75 +0,0 @@ -/** - * Product identity — reads from a product.json file or falls back to env vars. - * Eliminates the need for hardcoded product-config.ts files in every service. - */ - -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; - -export interface ProductIdentity { - productId: string; - displayName: string; - licensePrefix: string; - configDirName: string; - envVarPrefix: string; - bundleIdSuffix: string; - packageName: string; -} - -let _cached: ProductIdentity | null = null; - -/** - * Load product identity from a JSON file or environment variables. - * - * @param jsonPath - Path to product.json (optional, tries common locations) - * @returns Product identity object - */ -export function loadProductIdentity(jsonPath?: string): ProductIdentity { - if (_cached) return _cached; - - // Try loading from file - const paths = jsonPath - ? [jsonPath] - : [ - resolve('shared/product.json'), - resolve('../shared/product.json'), - resolve('../../shared/product.json'), - ]; - - for (const p of paths) { - try { - const raw = readFileSync(p, 'utf-8'); - _cached = JSON.parse(raw) as ProductIdentity; - return _cached; - } catch { - // Try next path - } - } - - // Fallback to env vars / defaults - _cached = { - productId: process.env.PRODUCT_ID || 'lysnrai', - displayName: process.env.DISPLAY_NAME || 'LysnrAI', - licensePrefix: process.env.LICENSE_PREFIX || 'LYSNR', - configDirName: process.env.CONFIG_DIR_NAME || '.LysnrAI', - envVarPrefix: process.env.ENV_VAR_PREFIX || 'LYSNR', - bundleIdSuffix: process.env.BUNDLE_ID_SUFFIX || 'LysnrAI', - packageName: process.env.PACKAGE_NAME || 'lysnrai', - }; - return _cached; -} - -/** - * Convenience: get just the product ID string. - */ -export function getProductId(): string { - return loadProductIdentity().productId; -} - -/** - * Reset the cache (useful for testing). - * @internal - */ -export function _resetProductIdentity(): void { - _cached = null; -} diff --git a/vendor/bytelyst/config/src/product-manifest.ts b/vendor/bytelyst/config/src/product-manifest.ts deleted file mode 100644 index 3598204..0000000 --- a/vendor/bytelyst/config/src/product-manifest.ts +++ /dev/null @@ -1,305 +0,0 @@ -/** - * Product Manifest Specification - * - * Defines the JSON schema for product.json files that capture everything - * about a ByteLyst product — identity, theme, features, containers, flags, ports. - * - * @module @bytelyst/config/product-manifest - */ - -import { readFileSync } from 'node:fs'; -import { readFile } from 'node:fs/promises'; -import { z } from 'zod'; - -/** - * Platform identifiers - */ -export const PlatformSchema = z.enum(['web', 'ios', 'android', 'desktop', 'watch', 'mac']); - -/** - * Theme color token - */ -export const ColorTokenSchema = z.string().regex(/^#[0-9A-Fa-f]{6}$/, { - message: 'Color must be a hex value like #5AE68C', -}); - -/** - * Theme specification - */ -export const ThemeSchema = z.object({ - primary: ColorTokenSchema, - secondary: ColorTokenSchema, - accent: ColorTokenSchema, - background: ColorTokenSchema, - surface: ColorTokenSchema, - text: ColorTokenSchema, - error: ColorTokenSchema, - warning: ColorTokenSchema, - success: ColorTokenSchema, -}); - -/** - * Cosmos container definition - */ -export const ContainerDefSchema = z.object({ - name: z.string().min(1), - partitionKey: z.string().min(1), - ttlSeconds: z.number().positive().optional(), - uniqueKeys: z.array(z.string()).optional(), -}); - -/** - * Feature flag default - */ -export const FeatureFlagSchema = z.object({ - key: z.string().min(1), - defaultValue: z.union([z.boolean(), z.string(), z.number()]), - description: z.string().optional(), -}); - -/** - * Port configuration - */ -export const PortConfigSchema = z.object({ - service: z.number().optional(), - dashboard: z.number().optional(), - web: z.number().optional(), -}); - -/** - * Bundle ID — either a single reverse-DNS string or per-platform object - */ -export const BundleIdSchema = z.union([ - z.string().min(1), - z.object({ - ios: z.string().optional(), - android: z.string().optional(), - web: z.string().optional(), - }), -]); - -/** - * App store metadata (optional) - */ -export const AppStoreSchema = z - .object({ - category: z.string().optional(), - subcategory: z.string().optional(), - ageRating: z.string().optional(), - privacyUrl: z.string().url().optional(), - termsUrl: z.string().url().optional(), - supportUrl: z.string().url().optional(), - }) - .optional(); - -/** - * Product manifest schema (Zod) - * - * Designed to accommodate the real-world variety of product.json files - * across the ByteLyst ecosystem. All products use `productId` as the - * primary identifier. - * - * Example product.json: - * ```json - * { - * "productId": "lysnrai", - * "displayName": "LysnrAI", - * "bundleId": "com.saravana.lysnrai", - * "domain": "lysnrai.app", - * "description": "Voice-to-text dictation platform", - * "backendPort": 4015, - * "platforms": ["web", "ios", "android", "desktop"], - * "cosmos": { - * "containers": [ - * { "name": "transcripts", "partitionKey": "/userId" }, - * { "name": "sessions", "partitionKey": "/userId" } - * ] - * }, - * "ports": { - * "service": 4015, - * "dashboard": 3002 - * } - * } - * ``` - */ -const BaseManifestSchema = z.object({ - // Identity (productId required, rest optional for minimal manifests) - productId: z.string().regex(/^[a-z][a-z0-9-]*$/, { - message: 'Product ID must be lowercase alphanumeric/hyphens, starting with letter', - }), - displayName: z.string().min(1).max(50), - - // Optional identity fields - name: z.string().min(1).max(50).optional(), - bundleId: BundleIdSchema.optional(), - domain: z.string().optional(), - tagline: z.string().max(200).optional(), - description: z.string().max(500).optional(), - version: z - .string() - .regex(/^\d+\.\d+\.\d+$/) - .optional(), - - // Platforms (defaults to web) - platforms: z.array(PlatformSchema).default(['web']), - primarySurface: PlatformSchema.optional(), - mobileCompanion: z.boolean().optional(), - - // Backend port (convenience — also available in ports.service) - backendPort: z.number().min(1024).max(65535).optional(), - - // Legacy identity fields from older product.json files - licensePrefix: z.string().optional(), - configDirName: z.string().optional(), - envVarPrefix: z.string().optional(), - bundleIdSuffix: z.string().optional(), - packageName: z.string().optional(), - appGroup: z.string().optional(), - - // Per-platform bundle IDs (alternative to bundleId) - bundleIds: z.record(z.string(), z.string()).optional(), - - // App store metadata - appStore: AppStoreSchema, - - // Theming (optional, uses defaults if not specified) - theme: ThemeSchema.partial().optional(), - - // Feature map (key → boolean/string/number) - features: z.record(z.string(), z.boolean().or(z.string()).or(z.number())).optional(), - - // Cosmos containers - cosmos: z - .object({ - containers: z.array(ContainerDefSchema).default([]), - }) - .optional(), - - // Feature flags with defaults - flags: z.array(FeatureFlagSchema).default([]), - - // Port configuration - ports: PortConfigSchema.optional(), - - // Agent/AI fields (used by FlowMonk, ActionTrail) - backendAuthority: z.string().optional(), - planningEngine: z.string().optional(), - aiRole: z.array(z.string()).optional(), -}); - -export const ProductManifestSchema = BaseManifestSchema.superRefine((data, ctx) => { - // Validate no duplicate container names - const containers = data.cosmos?.containers; - if (containers && containers.length > 1) { - const names = containers.map(c => c.name); - const seen = new Set(); - for (let i = 0; i < names.length; i++) { - if (seen.has(names[i])) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `Duplicate container name: ${names[i]}`, - path: ['cosmos', 'containers', i, 'name'], - }); - } - seen.add(names[i]); - } - } -}); - -/** - * Extended manifest that allows additional unknown keys. - * Use this when you need to access custom fields not in the schema. - */ -export const ExtendedProductManifestSchema = BaseManifestSchema.passthrough(); - -/** - * Inferred TypeScript type for ProductManifest - */ -export type ProductManifest = z.infer; -export type Platform = z.infer; -export type Theme = z.infer; -export type ContainerDef = z.infer; -export type FeatureFlag = z.infer; -export type PortConfig = z.infer; - -/** - * Default theme colors (ByteLyst brand palette) - */ -export const DEFAULT_THEME: Theme = { - primary: '#5AE68C', - secondary: '#5A8CFF', - accent: '#2EE6D6', - background: '#06070A', - surface: '#121725', - text: '#EFF4FF', - error: '#FF6E6E', - warning: '#F59E0B', - success: '#34D399', -}; - -/** - * Load and validate a product manifest from a file path - * - * @param path Path to product.json file - * @returns Validated ProductManifest - * @throws ZodError if validation fails - * - * @example - * ```ts - * const manifest = await loadProductManifest('./product.json'); - * console.log(manifest.productId); // 'lysnrai' - * ``` - */ -export async function loadProductManifest(path: string): Promise { - const content = await readFile(path, 'utf-8'); - const json = JSON.parse(content); - return ProductManifestSchema.parse(json); -} - -/** - * Synchronous version of loadProductManifest (for startup use) - * - * @param path Path to product.json file - * @returns Validated ProductManifest - * @throws ZodError if validation fails - */ -export function loadProductManifestSync(path: string): ProductManifest { - const content = readFileSync(path, 'utf-8'); - const json = JSON.parse(content); - return ProductManifestSchema.parse(json); -} - -/** - * Merge manifest theme with defaults - * - * @param manifest Product manifest - * @returns Complete theme with defaults filled in - */ -export function resolveTheme(manifest: ProductManifest): Theme { - return { - ...DEFAULT_THEME, - ...manifest.theme, - }; -} - -/** - * Validate a product manifest object without loading from file - * - * @param json Parsed JSON object - * @returns Validated ProductManifest - * @throws ZodError if validation fails - */ -export function validateProductManifest(json: unknown): ProductManifest { - return ProductManifestSchema.parse(json); -} - -/** - * Safe validation that returns null on failure - * - * @param json Parsed JSON object - * @returns Validated ProductManifest or null - */ -export function safeValidateProductManifest(json: unknown): ProductManifest | null { - const result = ProductManifestSchema.safeParse(json); - return result.success ? result.data : null; -} diff --git a/vendor/bytelyst/config/tsconfig.json b/vendor/bytelyst/config/tsconfig.json deleted file mode 100644 index 5edad81..0000000 --- a/vendor/bytelyst/config/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/vendor/bytelyst/cosmos/package.json b/vendor/bytelyst/cosmos/package.json deleted file mode 100644 index 838e959..0000000 --- a/vendor/bytelyst/cosmos/package.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "name": "@bytelyst/cosmos", - "version": "0.1.5", - "type": "module", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks" - }, - "peerDependencies": { - "@azure/cosmos": ">=4.0.0" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/cosmos/src/__tests__/cosmos.test.ts b/vendor/bytelyst/cosmos/src/__tests__/cosmos.test.ts deleted file mode 100644 index 46107e5..0000000 --- a/vendor/bytelyst/cosmos/src/__tests__/cosmos.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; - -// Must hoist mocks so they're available when vi.mock factory runs -const { mockDatabase, mockDatabases, MockCosmosClient } = vi.hoisted(() => { - const mockContainer = { id: 'test-container' }; - const mockDatabase = { - container: vi.fn(() => mockContainer), - containers: { createIfNotExists: vi.fn() }, - }; - const mockDatabases = { createIfNotExists: vi.fn(() => ({ database: mockDatabase })) }; - const MockCosmosClient = vi.fn(() => ({ - database: vi.fn(() => mockDatabase), - databases: mockDatabases, - })); - return { mockDatabase, mockDatabases, MockCosmosClient }; -}); - -vi.mock('@azure/cosmos', () => ({ - CosmosClient: MockCosmosClient, - PartitionKeyDefinition: class {}, -})); - -import { - getCosmosClient, - getDatabase, - getContainer, - _resetClient, - registerContainers, - getRegisteredContainer, - initializeAllContainers, - _resetRegistry, -} from '../index.js'; - -describe('cosmos client', () => { - beforeEach(() => { - _resetClient(); - _resetRegistry(); - MockCosmosClient.mockClear(); - process.env.COSMOS_ENDPOINT = 'https://test.documents.azure.com:443/'; - process.env.COSMOS_KEY = 'test-key=='; - process.env.COSMOS_DATABASE = 'testdb'; - }); - - afterEach(() => { - delete process.env.COSMOS_ENDPOINT; - delete process.env.COSMOS_KEY; - delete process.env.COSMOS_DATABASE; - }); - - it('getCosmosClient creates singleton', () => { - const c1 = getCosmosClient(); - const c2 = getCosmosClient(); - expect(c1).toBe(c2); - expect(MockCosmosClient).toHaveBeenCalledTimes(1); - expect(MockCosmosClient).toHaveBeenCalledWith({ - endpoint: 'https://test.documents.azure.com:443/', - key: 'test-key==', - }); - }); - - it('getCosmosClient throws without COSMOS_ENDPOINT', () => { - delete process.env.COSMOS_ENDPOINT; - expect(() => getCosmosClient()).toThrow('COSMOS_ENDPOINT is required'); - }); - - it('getCosmosClient throws without COSMOS_KEY', () => { - delete process.env.COSMOS_KEY; - expect(() => getCosmosClient()).toThrow('COSMOS_KEY is required'); - }); - - it('getDatabase uses COSMOS_DATABASE env var', () => { - const db = getDatabase(); - expect(db).toBeDefined(); - }); - - it('getDatabase defaults to lysnrai', () => { - _resetClient(); - delete process.env.COSMOS_DATABASE; - getDatabase(); - // Client was called, database accessed - expect(MockCosmosClient).toHaveBeenCalled(); - }); - - it('getContainer returns container by name', () => { - const c = getContainer('users'); - expect(c).toBeDefined(); - }); - - it('_resetClient clears singleton', () => { - getCosmosClient(); - expect(MockCosmosClient).toHaveBeenCalledTimes(1); - _resetClient(); - getCosmosClient(); - expect(MockCosmosClient).toHaveBeenCalledTimes(2); - }); -}); - -describe('container registry', () => { - beforeEach(() => { - _resetClient(); - _resetRegistry(); - MockCosmosClient.mockClear(); - process.env.COSMOS_ENDPOINT = 'https://test.documents.azure.com:443/'; - process.env.COSMOS_KEY = 'test-key=='; - process.env.COSMOS_DATABASE = 'testdb'; - }); - - afterEach(() => { - delete process.env.COSMOS_ENDPOINT; - delete process.env.COSMOS_KEY; - delete process.env.COSMOS_DATABASE; - }); - - it('registerContainers stores definitions', () => { - registerContainers({ - users: { partitionKeyPath: '/productId' }, - tokens: { partitionKeyPath: '/userId' }, - }); - // Should not throw - const c = getRegisteredContainer('users'); - expect(c).toBeDefined(); - }); - - it('getRegisteredContainer throws for unknown name', () => { - expect(() => getRegisteredContainer('nope')).toThrow("Unknown container 'nope'"); - }); - - it('getRegisteredContainer caches container instances', () => { - registerContainers({ items: { partitionKeyPath: '/id' } }); - const c1 = getRegisteredContainer('items'); - const c2 = getRegisteredContainer('items'); - expect(c1).toBe(c2); - }); - - it('initializeAllContainers creates database and containers', async () => { - registerContainers({ - users: { partitionKeyPath: '/productId' }, - audit: { partitionKeyPath: '/productId', defaultTtl: 86400 }, - }); - - await initializeAllContainers(); - - expect(mockDatabases.createIfNotExists).toHaveBeenCalledWith({ id: 'testdb' }); - expect(mockDatabase.containers.createIfNotExists).toHaveBeenCalledTimes(2); - }); - - it('_resetRegistry clears all', () => { - registerContainers({ x: { partitionKeyPath: '/id' } }); - _resetRegistry(); - expect(() => getRegisteredContainer('x')).toThrow("Unknown container 'x'"); - }); -}); diff --git a/vendor/bytelyst/cosmos/src/client.ts b/vendor/bytelyst/cosmos/src/client.ts deleted file mode 100644 index fdd68e1..0000000 --- a/vendor/bytelyst/cosmos/src/client.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * Azure Cosmos DB client singleton. - * - * Reads COSMOS_ENDPOINT, COSMOS_KEY, and COSMOS_DATABASE from process.env. - * Provides getCosmosClient(), getDatabase(), and getContainer() for simple usage. - */ - -import { Container, CosmosClient, Database } from '@azure/cosmos'; - -let _client: CosmosClient | null = null; -let _database: Database | null = null; - -function getEnvOrThrow(name: string): string { - const value = process.env[name]; - if (!value) { - throw new Error(`Environment variable ${name} is required`); - } - return value; -} - -export function getCosmosClient(): CosmosClient { - if (!_client) { - _client = new CosmosClient({ - endpoint: getEnvOrThrow('COSMOS_ENDPOINT'), - key: getEnvOrThrow('COSMOS_KEY'), - }); - } - return _client; -} - -export function getDatabase(): Database { - if (!_database) { - const dbId = process.env.COSMOS_DATABASE || 'lysnrai'; - _database = getCosmosClient().database(dbId); - } - return _database; -} - -/** - * Get a container by name. Uses the singleton database. - * For simple services that don't need a container registry. - */ -export function getContainer(name: string): Container { - return getDatabase().container(name); -} - -/** - * Reset the singleton (useful for testing). - * @internal - */ -export function _resetClient(): void { - _client = null; - _database = null; -} diff --git a/vendor/bytelyst/cosmos/src/containers.ts b/vendor/bytelyst/cosmos/src/containers.ts deleted file mode 100644 index acd009d..0000000 --- a/vendor/bytelyst/cosmos/src/containers.ts +++ /dev/null @@ -1,125 +0,0 @@ -/** - * Container registry for dashboards that need partition key validation - * and createIfNotExists support. - */ - -import { Container, PartitionKeyDefinition, type Database } from '@azure/cosmos'; -import { getCosmosClient, getDatabase } from './client.js'; -import type { ContainerConfig } from './types.js'; - -const _registry: Map = new Map(); -const _containerCache: Map = new Map(); - -/** - * Register containers with their partition key configuration. - * Call once at app startup before any getRegisteredContainer() calls. - */ -export function registerContainers(definitions: Record): void { - for (const [name, config] of Object.entries(definitions)) { - _registry.set(name, config); - } -} - -/** - * Get a container that was previously registered. - * Throws if the container name is unknown. - */ -export function getRegisteredContainer(name: string): Container { - if (!_registry.has(name)) { - throw new Error(`Unknown container '${name}'. Valid: ${[..._registry.keys()].join(', ')}`); - } - - let container = _containerCache.get(name); - if (!container) { - container = getDatabase().container(name); - _containerCache.set(name, container); - } - return container; -} - -/** - * Create all registered containers if they don't exist. - * Call from a seed script or on first deploy. - */ -export async function initializeAllContainers(): Promise { - const client = getCosmosClient(); - const dbId = process.env.COSMOS_DATABASE || 'lysnrai'; - const database = await createDatabaseSafe(client, dbId); - - for (const [name, config] of _registry.entries()) { - await createContainerSafe(database, name, config); - } -} - -function sleep(ms: number): Promise { - return new Promise(resolve => globalThis.setTimeout(resolve, ms)); -} - -function isCosmosConflict(err: unknown): boolean { - const e = err as { code?: number; statusCode?: number; message?: string } | null; - if (!e) return false; - if (e.code === 409 || e.statusCode === 409) return true; - return (e.message || '').toLowerCase().includes('already exists'); -} - -function isCosmosNotFound(err: unknown): boolean { - const e = err as { code?: number; statusCode?: number; message?: string } | null; - if (!e) return false; - if (e.code === 404 || e.statusCode === 404) return true; - return (e.message || '').toLowerCase().includes('not found'); -} - -async function createDatabaseSafe( - client: ReturnType, - dbId: string -): Promise { - try { - const { database } = await client.databases.createIfNotExists({ id: dbId }); - return database; - } catch (err) { - // createIfNotExists is not atomic; concurrent create can race and throw a conflict. - if (isCosmosConflict(err)) return client.database(dbId); - throw err; - } -} - -async function createContainerSafe( - database: Database, - name: string, - config: ContainerConfig -): Promise { - const payload = { - id: name, - partitionKey: { - paths: [config.partitionKeyPath], - kind: 'Hash', - } as PartitionKeyDefinition, - ...(config.defaultTtl != null && { defaultTtl: config.defaultTtl }), - }; - - for (let attempt = 0; attempt < 3; attempt += 1) { - try { - await database.containers.createIfNotExists(payload); - return; - } catch (err) { - if (isCosmosConflict(err)) return; // Container was created by another process. - - // Sometimes the database/container metadata isn't immediately visible after creation. - if (isCosmosNotFound(err) && attempt < 2) { - await sleep(250 * (attempt + 1)); - continue; - } - - throw err; - } - } -} - -/** - * Reset the registry (useful for testing). - * @internal - */ -export function _resetRegistry(): void { - _registry.clear(); - _containerCache.clear(); -} diff --git a/vendor/bytelyst/cosmos/src/index.ts b/vendor/bytelyst/cosmos/src/index.ts deleted file mode 100644 index 49e2fce..0000000 --- a/vendor/bytelyst/cosmos/src/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { getCosmosClient, getDatabase, getContainer, _resetClient } from './client.js'; -export { - registerContainers, - getRegisteredContainer, - initializeAllContainers, - _resetRegistry, -} from './containers.js'; -export type { ContainerConfig } from './types.js'; diff --git a/vendor/bytelyst/cosmos/src/types.ts b/vendor/bytelyst/cosmos/src/types.ts deleted file mode 100644 index 59658f6..0000000 --- a/vendor/bytelyst/cosmos/src/types.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface ContainerConfig { - partitionKeyPath: string; - defaultTtl?: number | null; -} diff --git a/vendor/bytelyst/cosmos/tsconfig.json b/vendor/bytelyst/cosmos/tsconfig.json deleted file mode 100644 index 5edad81..0000000 --- a/vendor/bytelyst/cosmos/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/vendor/bytelyst/create-app/package.json b/vendor/bytelyst/create-app/package.json deleted file mode 100644 index e731df4..0000000 --- a/vendor/bytelyst/create-app/package.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "name": "@bytelyst/create-app", - "version": "0.1.3", - "description": "CLI tools for scaffolding ByteLyst product repos and code", - "type": "module", - "bin": { - "create-app": "./dist/scaffolder.js", - "gen-api-route": "./dist/generators/api-routes.js", - "gen-agents-md": "./dist/generators/agents-md.js" - }, - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks", - "create-app": "tsx src/scaffolder.ts", - "gen:api-route": "tsx src/generators/api-routes.ts", - "gen:agents-md": "tsx src/generators/agents-md.ts" - }, - "devDependencies": { - "tsx": "^4.19.2", - "typescript": "^5.7.3", - "vitest": "^3.0.5" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/create-app/src/__tests__/scaffolder.test.ts b/vendor/bytelyst/create-app/src/__tests__/scaffolder.test.ts deleted file mode 100644 index 7d5eac7..0000000 --- a/vendor/bytelyst/create-app/src/__tests__/scaffolder.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { generateFiles, type ProductManifest } from '../scaffolder.js'; - -function makeManifest(overrides: Partial = {}): ProductManifest { - return { - productId: 'testapp', - displayName: 'TestApp', - tagline: 'A test application', - domain: 'testapp.dev', - backendPort: 4050, - primarySurface: 'web', - platforms: ['web'], - features: ['auth', 'telemetry'], - ...overrides, - }; -} - -describe('generateFiles', () => { - it('generates root files', () => { - const files = generateFiles(makeManifest()); - const paths = files.map(f => f.path); - - expect(paths).toContain('shared/product.json'); - expect(paths).toContain('.gitignore'); - expect(paths).toContain('.env.example'); - expect(paths).toContain('README.md'); - }); - - it('always generates backend files', () => { - const files = generateFiles(makeManifest()); - const paths = files.map(f => f.path); - - expect(paths).toContain('backend/package.json'); - expect(paths).toContain('backend/src/server.ts'); - expect(paths).toContain('backend/src/lib/config.ts'); - expect(paths).toContain('backend/src/lib/auth.ts'); - expect(paths).toContain('backend/src/lib/datastore.ts'); - }); - - it('generates web files when platform includes web', () => { - const files = generateFiles(makeManifest({ platforms: ['web'] })); - const paths = files.map(f => f.path); - - expect(paths).toContain('web/package.json'); - expect(paths).toContain('web/next.config.ts'); - expect(paths).toContain('web/src/app/layout.tsx'); - expect(paths).toContain('web/src/app/page.tsx'); - expect(paths).toContain('web/src/lib/product-config.ts'); - }); - - it('does not generate web files when platform excludes web', () => { - const files = generateFiles(makeManifest({ platforms: ['mobile'] })); - const paths = files.map(f => f.path); - - expect(paths).not.toContain('web/package.json'); - expect(paths).not.toContain('web/src/app/page.tsx'); - }); - - it('generates mobile files when platform includes mobile', () => { - const files = generateFiles(makeManifest({ platforms: ['mobile'] })); - const paths = files.map(f => f.path); - - expect(paths).toContain('mobile/package.json'); - expect(paths).toContain('mobile/app.json'); - expect(paths).toContain('mobile/src/app/index.tsx'); - }); - - it('does not generate mobile files when platform excludes mobile', () => { - const files = generateFiles(makeManifest({ platforms: ['web'] })); - const paths = files.map(f => f.path); - - expect(paths).not.toContain('mobile/package.json'); - }); - - it('replaces product ID in generated content', () => { - const files = generateFiles(makeManifest({ productId: 'myproduct' })); - const productJson = files.find(f => f.path === 'shared/product.json')!; - expect(productJson.content).toContain('"productId": "myproduct"'); - }); - - it('replaces display name in generated content', () => { - const files = generateFiles(makeManifest({ displayName: 'AwesomeApp' })); - const readme = files.find(f => f.path === 'README.md')!; - expect(readme.content).toContain('# AwesomeApp'); - }); - - it('replaces backend port in config', () => { - const files = generateFiles(makeManifest({ backendPort: 4099 })); - const config = files.find(f => f.path === 'backend/src/lib/config.ts')!; - expect(config.content).toContain('4099'); - }); - - it('includes ios bundle ID when ios platform selected', () => { - const files = generateFiles(makeManifest({ platforms: ['web', 'ios'], productId: 'myapp' })); - const productJson = files.find(f => f.path === 'shared/product.json')!; - expect(productJson.content).toContain('com.bytelyst.myapp'); - }); - - it('includes android bundle ID when android platform selected', () => { - const files = generateFiles( - makeManifest({ platforms: ['web', 'android'], productId: 'myapp' }) - ); - const productJson = files.find(f => f.path === 'shared/product.json')!; - expect(productJson.content).toContain('com.myapp.app'); - }); - - it('includes backend env vars in .env.example', () => { - const files = generateFiles(makeManifest({ backendPort: 4050 })); - const env = files.find(f => f.path === '.env.example')!; - expect(env.content).toContain('PORT=4050'); - expect(env.content).toContain('JWT_SECRET'); - }); - - it('generates correct web product-config', () => { - const files = generateFiles(makeManifest({ productId: 'testprod', backendPort: 4077 })); - const webConfig = files.find(f => f.path === 'web/src/lib/product-config.ts')!; - expect(webConfig.content).toContain("productId: 'testprod'"); - expect(webConfig.content).toContain('4077'); - }); - - it('generates all platforms when all selected', () => { - const files = generateFiles(makeManifest({ platforms: ['web', 'mobile', 'ios', 'android'] })); - const paths = files.map(f => f.path); - - expect(paths).toContain('web/package.json'); - expect(paths).toContain('mobile/package.json'); - // Backend is always included - expect(paths).toContain('backend/package.json'); - }); - - it('server.ts includes display name in log message', () => { - const files = generateFiles(makeManifest({ displayName: 'CoolProduct' })); - const server = files.find(f => f.path === 'backend/src/server.ts')!; - expect(server.content).toContain('CoolProduct backend listening'); - }); -}); diff --git a/vendor/bytelyst/create-app/src/__tests__/template-engine.test.ts b/vendor/bytelyst/create-app/src/__tests__/template-engine.test.ts deleted file mode 100644 index 2321866..0000000 --- a/vendor/bytelyst/create-app/src/__tests__/template-engine.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { renderTemplate } from '../lib/template-engine.js'; - -describe('renderTemplate', () => { - it('replaces simple variables', () => { - const result = renderTemplate('Hello {{NAME}}!', { NAME: 'World' }); - expect(result).toBe('Hello World!'); - }); - - it('replaces multiple variables', () => { - const result = renderTemplate('{{A}} and {{B}}', { A: 'foo', B: 'bar' }); - expect(result).toBe('foo and bar'); - }); - - it('replaces numeric variables', () => { - const result = renderTemplate('Port: {{PORT}}', { PORT: 4017 }); - expect(result).toBe('Port: 4017'); - }); - - it('leaves unknown variables intact', () => { - const result = renderTemplate('{{KNOWN}} {{UNKNOWN}}', { KNOWN: 'yes' }); - expect(result).toBe('yes {{UNKNOWN}}'); - }); - - it('includes IF block when truthy', () => { - const result = renderTemplate('{{#IF HAS_WEB}}web here{{/IF HAS_WEB}}', { HAS_WEB: true }); - expect(result).toBe('web here'); - }); - - it('excludes IF block when falsy', () => { - const result = renderTemplate('before{{#IF HAS_WEB}}web here{{/IF HAS_WEB}}after', { - HAS_WEB: false, - }); - expect(result).toBe('beforeafter'); - }); - - it('includes UNLESS block when falsy', () => { - const result = renderTemplate('{{#UNLESS HAS_WEB}}no web{{/UNLESS HAS_WEB}}', { - HAS_WEB: false, - }); - expect(result).toBe('no web'); - }); - - it('excludes UNLESS block when truthy', () => { - const result = renderTemplate('{{#UNLESS HAS_WEB}}no web{{/UNLESS HAS_WEB}}', { - HAS_WEB: true, - }); - expect(result).toBe(''); - }); - - it('handles nested variables inside IF blocks', () => { - const result = renderTemplate('{{#IF HAS_WEB}}Port: {{PORT}}{{/IF HAS_WEB}}', { - HAS_WEB: true, - PORT: 3000, - }); - expect(result).toBe('Port: 3000'); - }); - - it('handles multiline IF blocks', () => { - const tmpl = `start -{{#IF HAS_BACKEND}}backend line 1 -backend line 2 -{{/IF HAS_BACKEND}}end`; - const result = renderTemplate(tmpl, { HAS_BACKEND: true }); - expect(result).toContain('backend line 1'); - expect(result).toContain('backend line 2'); - expect(result).toContain('start'); - expect(result).toContain('end'); - }); - - it('handles multiple IF blocks', () => { - const tmpl = '{{#IF A}}aaa{{/IF A}}|{{#IF B}}bbb{{/IF B}}'; - expect(renderTemplate(tmpl, { A: true, B: false })).toBe('aaa|'); - expect(renderTemplate(tmpl, { A: false, B: true })).toBe('|bbb'); - expect(renderTemplate(tmpl, { A: true, B: true })).toBe('aaa|bbb'); - }); -}); diff --git a/vendor/bytelyst/create-app/src/generators/agents-md.ts b/vendor/bytelyst/create-app/src/generators/agents-md.ts deleted file mode 100644 index 9c4b9bd..0000000 --- a/vendor/bytelyst/create-app/src/generators/agents-md.ts +++ /dev/null @@ -1,605 +0,0 @@ -#!/usr/bin/env node -/** - * AGENTS.md Auto-Generator - * - * Generates AGENTS.md from product.json + repo directory scan. - * Also creates/updates symlinks: CLAUDE.md, .cursorrules, .windsurfrules - * - * Usage: - * npx tsx agents-md.ts --repo /path/to/product-repo - * npx tsx agents-md.ts --repo /path/to/product-repo --dry-run - * npx tsx agents-md.ts --repo /path/to/product-repo --update # preserves sections - * - * @module @bytelyst/create-app/generators/agents-md - */ - -/* eslint-disable no-console -- This generator is a CLI; console output is its user interface. */ - -import { promises as fs } from 'node:fs'; -import path from 'node:path'; -import { execSync } from 'node:child_process'; - -// ── CLI ────────────────────────────────────────────────────────────────────── - -interface Options { - repo: string; - dryRun: boolean; - update: boolean; -} - -function parseArgs(): Options { - const args = process.argv.slice(2); - const options: Options = { repo: '.', dryRun: false, update: false }; - - for (let i = 0; i < args.length; i++) { - const arg = args[i]; - if (arg === '--repo' || arg === '-r') options.repo = args[++i]; - else if (arg === '--dry-run' || arg === '-d') options.dryRun = true; - else if (arg === '--update' || arg === '-u') options.update = true; - else if (arg === '--help' || arg === '-h') { - showHelp(); - process.exit(0); - } - } - - return options; -} - -function showHelp(): void { - console.log(` -AGENTS.md Auto-Generator - -Generates AGENTS.md from product.json + repo scan, plus symlinks for -CLAUDE.md, .cursorrules, and .windsurfrules. - -Usage: - npx tsx agents-md.ts --repo [--dry-run] [--update] - -Options: - --repo, -r Path to product repo root (default: ".") - --dry-run, -d Preview without writing files - --update, -u Preserve sections in existing AGENTS.md - --help, -h Show this help - -Custom Sections: - Wrap any hand-written content in / - markers. The --update flag preserves these sections when regenerating. -`); -} - -// ── Product.json Loader ────────────────────────────────────────────────────── - -interface ProductManifest { - productId: string; - displayName: string; - tagline?: string; - domain?: string; - backendPort?: number; - primarySurface?: string; - mobileCompanion?: boolean; - bundleIds?: Record; - bundleId?: string; - version?: string; - description?: string; - licensePrefix?: string; - configDirName?: string; - envVarPrefix?: string; -} - -async function loadProductJson(repoPath: string): Promise { - const candidates = [ - path.join(repoPath, 'shared', 'product.json'), - path.join(repoPath, 'product.json'), - ]; - - for (const p of candidates) { - try { - const raw = await fs.readFile(p, 'utf-8'); - return JSON.parse(raw); - } catch { - // try next - } - } - - throw new Error('product.json not found in shared/ or repo root'); -} - -// ── Repo Scanner ───────────────────────────────────────────────────────────── - -interface RepoInfo { - repoName: string; - hasFastifyBackend: boolean; - hasNextWeb: boolean; - hasExpoMobile: boolean; - hasSwiftIos: boolean; - hasKotlinAndroid: boolean; - hasKmpShared: boolean; - backendModules: string[]; - backendTestCount: number; - webTestCount: number; - mobileTestCount: number; - backendLibFiles: string[]; - webLibFiles: string[]; - cosmosContainers: string[]; - techStack: { layer: string; tech: string }[]; - buildCommands: string[]; -} - -async function dirExists(p: string): Promise { - try { - const st = await fs.stat(p); - return st.isDirectory(); - } catch { - return false; - } -} - -async function fileExists(p: string): Promise { - try { - await fs.access(p); - return true; - } catch { - return false; - } -} - -function countTestsInFiles(dir: string): number { - try { - const output = execSync( - `grep -r "\\b\\(it\\|test\\)(" "${dir}" --include="*.test.ts" --include="*.test.tsx" --include="*.spec.ts" 2>/dev/null | wc -l`, - { encoding: 'utf-8', timeout: 5000 } - ); - return parseInt(output.trim()) || 0; - } catch { - return 0; - } -} - -async function listDirs(dir: string): Promise { - try { - const entries = await fs.readdir(dir, { withFileTypes: true }); - return entries.filter(e => e.isDirectory()).map(e => e.name); - } catch { - return []; - } -} - -async function listFiles(dir: string): Promise { - try { - const entries = await fs.readdir(dir, { withFileTypes: true }); - return entries.filter(e => e.isFile()).map(e => e.name); - } catch { - return []; - } -} - -async function scanRepo(repoPath: string, manifest: ProductManifest): Promise { - const repoName = path.basename(repoPath); - - // Detect surfaces - const hasFastifyBackend = await dirExists(path.join(repoPath, 'backend', 'src')); - const hasNextWeb = - (await fileExists(path.join(repoPath, 'web', 'next.config.ts'))) || - (await fileExists(path.join(repoPath, 'web', 'next.config.js'))) || - (await fileExists(path.join(repoPath, 'mindlyst-native', 'web', 'next.config.ts'))); - const hasExpoMobile = - (await fileExists(path.join(repoPath, 'mobile', 'app.json'))) || - (await fileExists(path.join(repoPath, 'app.json'))); - const hasSwiftIos = await dirExists(path.join(repoPath, 'ios')); - const hasKotlinAndroid = await dirExists(path.join(repoPath, 'android')); - const hasKmpShared = await dirExists(path.join(repoPath, 'shared', 'src')); - - // Backend modules - const modulesDir = path.join(repoPath, 'backend', 'src', 'modules'); - const backendModules = await listDirs(modulesDir); - - // Backend lib files - const libDir = path.join(repoPath, 'backend', 'src', 'lib'); - const backendLibFiles = (await listFiles(libDir)).filter( - f => f.endsWith('.ts') && !f.endsWith('.test.ts') - ); - - // Web lib files - let webLibDir = path.join(repoPath, 'web', 'src', 'lib'); - if (!(await dirExists(webLibDir))) { - webLibDir = path.join(repoPath, 'web', 'src', 'components', 'lib'); - } - const webLibFiles = (await listFiles(webLibDir)).filter( - f => f.endsWith('.ts') && !f.endsWith('.test.ts') - ); - - // Test counts - const backendTestCount = hasFastifyBackend - ? countTestsInFiles(path.join(repoPath, 'backend', 'src')) - : 0; - - let webTestDir = path.join(repoPath, 'web', 'src'); - if (!(await dirExists(webTestDir))) { - webTestDir = path.join(repoPath, 'mindlyst-native', 'web', 'src'); - } - const webTestCount = hasNextWeb ? countTestsInFiles(webTestDir) : 0; - - const mobileTestDir = hasExpoMobile - ? (await dirExists(path.join(repoPath, 'mobile'))) - ? path.join(repoPath, 'mobile') - : repoPath - : ''; - const mobileTestCount = mobileTestDir ? countTestsInFiles(mobileTestDir) : 0; - - // Cosmos containers — scan backend types files - const cosmosContainers: string[] = []; - for (const mod of backendModules) { - const typesFile = path.join(modulesDir, mod, 'types.ts'); - if (await fileExists(typesFile)) { - cosmosContainers.push(mod.replace(/-/g, '_')); - } - } - - // Tech stack - const techStack: { layer: string; tech: string }[] = []; - if (hasFastifyBackend) - techStack.push({ - layer: 'Backend', - tech: `Fastify 5, TypeScript ESM, Zod, jose (JWT), @bytelyst/datastore`, - }); - if (hasNextWeb) - techStack.push({ layer: 'Web', tech: 'Next.js 16 (App Router), React 19, TypeScript' }); - if (hasExpoMobile) - techStack.push({ layer: 'Mobile', tech: 'React Native (Expo), TypeScript, expo-router' }); - if (hasSwiftIos) techStack.push({ layer: 'iOS', tech: 'SwiftUI (iOS 17+)' }); - if (hasKotlinAndroid) - techStack.push({ layer: 'Android', tech: 'Jetpack Compose, Material 3, Kotlin' }); - if (hasKmpShared) techStack.push({ layer: 'Shared', tech: 'Kotlin Multiplatform (KMP)' }); - techStack.push({ - layer: 'Platform', - tech: 'platform-service (port 4003) for auth, flags, telemetry, billing', - }); - techStack.push({ - layer: 'Database', - tech: `Azure Cosmos DB via @bytelyst/datastore — productId: "${manifest.productId}"`, - }); - - // Build commands - const buildCommands: string[] = []; - if (hasFastifyBackend) { - const port = manifest.backendPort ?? 4000; - buildCommands.push(`cd backend && npm run dev # Dev server (port ${port})`); - buildCommands.push(`cd backend && npm run typecheck # tsc --noEmit`); - buildCommands.push(`cd backend && npm test # Vitest tests`); - } - if (hasNextWeb) { - buildCommands.push(`cd web && npm run dev # Dev server`); - buildCommands.push(`cd web && npm run typecheck # tsc --noEmit`); - buildCommands.push(`cd web && npm run build # Production build`); - } - if (hasExpoMobile) { - buildCommands.push(`cd mobile && npm start # Expo dev server`); - buildCommands.push(`cd mobile && npm run typecheck # tsc --noEmit`); - } - - return { - repoName, - hasFastifyBackend, - hasNextWeb, - hasExpoMobile, - hasSwiftIos, - hasKotlinAndroid, - hasKmpShared, - backendModules, - backendTestCount, - webTestCount, - mobileTestCount, - backendLibFiles, - webLibFiles, - cosmosContainers, - techStack, - buildCommands, - }; -} - -// ── Markdown Generator ─────────────────────────────────────────────────────── - -function generateAgentsMd(manifest: ProductManifest, info: RepoInfo): string { - const { productId, displayName, domain, tagline } = manifest; - const repoDesc = tagline ?? `${displayName} product`; - - const lines: string[] = []; - - // ── Header - lines.push(`# AGENTS.md — AI Coding Agent Instructions`); - lines.push(''); - lines.push( - `> **For:** Claude Code, OpenAI Codex, Cursor, GitHub Copilot, Windsurf Cascade, and any AI coding agent.` - ); - lines.push(`> **Repo:** \`${info.repoName}\` — ${repoDesc}.`); - lines.push(''); - lines.push('---'); - lines.push(''); - - // ── 1. Project Identity - lines.push('## 1. Project Identity'); - lines.push(''); - lines.push('| Key | Value |'); - lines.push('|-----|-------|'); - lines.push(`| **Product** | ${displayName} |`); - lines.push(`| **Product ID** | \`${productId}\` |`); - if (domain) lines.push(`| **Domain** | ${domain} |`); - lines.push(`| **Repo** | \`${info.repoName}\` |`); - lines.push(`| **Ecosystem** | ByteLyst (shares platform-service with other ByteLyst products) |`); - lines.push(''); - - // ── 2. Repo Layout (simplified tree) - lines.push('## 2. Repo Layout'); - lines.push(''); - lines.push('```'); - lines.push(`${info.repoName}/`); - if (info.hasFastifyBackend) { - const port = manifest.backendPort ?? 4000; - lines.push( - `├── backend/ # Fastify 5 + TypeScript ESM backend (port ${port})` - ); - lines.push(`│ ├── src/`); - if (info.backendLibFiles.length > 0) { - lines.push(`│ │ ├── lib/ # Shared backend wiring`); - for (const f of info.backendLibFiles.slice(0, 8)) { - lines.push(`│ │ │ ├── ${f}`); - } - if (info.backendLibFiles.length > 8) { - lines.push(`│ │ │ └── ... (${info.backendLibFiles.length - 8} more)`); - } - } - if (info.backendModules.length > 0) { - lines.push(`│ │ ├── modules/`); - for (const m of info.backendModules) { - lines.push(`│ │ │ ├── ${m}/`); - } - } - lines.push(`│ │ └── server.ts`); - lines.push(`│ ├── package.json`); - lines.push(`│ └── tsconfig.json`); - lines.push('│'); - } - if (info.hasNextWeb) { - lines.push(`├── web/ # Next.js 16 + React 19 (App Router)`); - lines.push(`│ ├── src/`); - lines.push(`│ │ ├── app/ # App Router pages`); - if (info.webLibFiles.length > 0) { - lines.push(`│ │ └── lib/ # Pure TS clients + config`); - } - lines.push(`│ ├── package.json`); - lines.push(`│ └── tsconfig.json`); - lines.push('│'); - } - if (info.hasExpoMobile) { - lines.push(`├── mobile/ # React Native + Expo`); - lines.push('│'); - } - if (info.hasSwiftIos) { - lines.push(`├── ios/ # SwiftUI native app`); - lines.push('│'); - } - if (info.hasKotlinAndroid) { - lines.push(`├── android/ # Jetpack Compose`); - lines.push('│'); - } - lines.push(`├── shared/`); - lines.push(`│ └── product.json # Canonical product identity`); - lines.push(`├── AGENTS.md # This file`); - lines.push(`└── README.md`); - lines.push('```'); - lines.push(''); - - // ── 3. Tech Stack - lines.push('## 3. Tech Stack'); - lines.push(''); - lines.push('| Layer | Technology |'); - lines.push('|-------|-----------|'); - for (const { layer, tech } of info.techStack) { - lines.push(`| **${layer}** | ${tech} |`); - } - - // Test counts - const totalTests = info.backendTestCount + info.webTestCount + info.mobileTestCount; - if (totalTests > 0) { - const parts: string[] = []; - if (info.backendTestCount > 0) parts.push(`${info.backendTestCount} backend`); - if (info.webTestCount > 0) parts.push(`${info.webTestCount} web`); - if (info.mobileTestCount > 0) parts.push(`${info.mobileTestCount} mobile`); - lines.push(`| **Tests** | Vitest — ~${totalTests} tests (${parts.join(' + ')}) |`); - } - lines.push(''); - - // ── 4. Coding Conventions - lines.push('## 4. Coding Conventions'); - lines.push(''); - lines.push('### MUST follow'); - lines.push(''); - lines.push(`- Every Cosmos document MUST include a \`productId: "${productId}"\` field`); - if (info.hasFastifyBackend) { - lines.push('- Backend modules follow `types.ts` → `repository.ts` → `routes.ts` pattern'); - lines.push( - '- All repositories use `@bytelyst/datastore` getCollection() — never direct Cosmos SDK calls' - ); - } - if (info.hasNextWeb) { - lines.push('- Web engine logic in `web/src/lib/` — pure TS, no React imports'); - lines.push('- Web components in `web/src/components/` — React UI only'); - } - if (info.hasExpoMobile) { - lines.push('- Mobile engine logic in `mobile/src/lib/` — pure TS, no React Native imports'); - } - lines.push( - '- Commit messages: `type(scope): description` — types: `feat`, `fix`, `docs`, `refactor`, `test`, `chore`' - ); - lines.push(''); - lines.push('### MUST NOT do'); - lines.push(''); - lines.push( - '- Never use `console.log` in production code — use `req.log` or `app.log` in Fastify' - ); - lines.push('- Never use `any` type — use Zod inference or explicit types'); - lines.push('- Never hardcode colors — use theme tokens'); - lines.push('- Never hardcode API URLs — use env vars or config'); - lines.push(`- Never hardcode product ID — use \`productConfig.productId\``); - lines.push('- Never modify tests to make them pass — fix the actual code'); - lines.push('- Never delete existing comments or documentation unless explicitly asked'); - lines.push('- Never add emojis to code unless explicitly asked'); - lines.push(''); - - // ── 5. Build & Test Commands - if (info.buildCommands.length > 0) { - lines.push('## 5. Build & Test Commands'); - lines.push(''); - lines.push('```bash'); - for (const cmd of info.buildCommands) { - lines.push(cmd); - } - lines.push('```'); - lines.push(''); - } - - // ── 6. Backend API Modules (if applicable) - if (info.hasFastifyBackend && info.backendModules.length > 0) { - lines.push('## 6. Backend Modules'); - lines.push(''); - lines.push('| Module | Container | Description |'); - lines.push('|--------|-----------|-------------|'); - for (const mod of info.backendModules) { - const container = mod.replace(/-/g, '_'); - lines.push(`| \`${mod}\` | \`${container}\` | ${mod.replace(/-/g, ' ')} |`); - } - lines.push(''); - } - - // ── Custom section placeholder - lines.push(''); - lines.push(''); - lines.push(''); - - return lines.join('\n'); -} - -// ── Custom Section Preservation ────────────────────────────────────────────── - -function extractCustomSections(content: string): Map { - const sections = new Map(); - const regex = /\n([\s\S]*?)/g; - let match; - while ((match = regex.exec(content)) !== null) { - sections.set(match[1], match[2]); - } - return sections; -} - -function mergeCustomSections(newContent: string, existing: Map): string { - let result = newContent; - for (const [key, value] of existing) { - const placeholder = `\n`; - const replacement = `\n${value}`; - result = result.replace(placeholder, replacement); - } - return result; -} - -// ── Symlink Manager ────────────────────────────────────────────────────────── - -async function ensureSymlinks(repoPath: string, dryRun: boolean): Promise { - const targets = ['CLAUDE.md', '.cursorrules', '.windsurfrules']; - - for (const target of targets) { - const linkPath = path.join(repoPath, target); - const exists = await fileExists(linkPath); - - if (exists) { - try { - const stat = await fs.lstat(linkPath); - if (stat.isSymbolicLink()) { - const linkTarget = await fs.readlink(linkPath); - if (linkTarget === 'AGENTS.md') { - continue; // already correct - } - } - } catch { - // not a symlink - } - } - - if (dryRun) { - console.log(` 📝 Would create symlink: ${target} → AGENTS.md`); - } else { - try { - if (exists) await fs.unlink(linkPath); - await fs.symlink('AGENTS.md', linkPath); - console.log(` ✅ ${target} → AGENTS.md`); - } catch (err) { - console.log( - ` ⚠️ Could not create symlink ${target}: ${err instanceof Error ? err.message : String(err)}` - ); - } - } - } -} - -// ── Main ───────────────────────────────────────────────────────────────────── - -async function main(): Promise { - const { repo, dryRun, update } = parseArgs(); - const repoPath = path.resolve(repo); - - console.log(`\n📄 AGENTS.md Generator`); - console.log(` Repo: ${repoPath}`); - if (dryRun) console.log(' ⚠️ DRY RUN — no files will be written\n'); - if (update) console.log(' 🔄 UPDATE mode — preserving custom sections\n'); - - // Load product.json - const manifest = await loadProductJson(repoPath); - console.log(` Product: ${manifest.displayName} (${manifest.productId})`); - - // Scan repo - const info = await scanRepo(repoPath, manifest); - console.log(` Backend modules: ${info.backendModules.length}`); - console.log(` Tests: ~${info.backendTestCount + info.webTestCount + info.mobileTestCount}`); - console.log(''); - - // Generate content - let content = generateAgentsMd(manifest, info); - - // Merge custom sections if updating - if (update) { - const agentsPath = path.join(repoPath, 'AGENTS.md'); - try { - const existing = await fs.readFile(agentsPath, 'utf-8'); - const customSections = extractCustomSections(existing); - if (customSections.size > 0) { - console.log(` 🔄 Preserving ${customSections.size} custom section(s)`); - content = mergeCustomSections(content, customSections); - } - } catch { - console.log(' ℹ️ No existing AGENTS.md to preserve custom sections from'); - } - } - - if (dryRun) { - console.log('── AGENTS.md ──────────────────────────────────────'); - console.log(content); - console.log('\n── Symlinks ──────────────────────────────────────'); - await ensureSymlinks(repoPath, true); - console.log('\n✨ Dry run complete.'); - return; - } - - // Write AGENTS.md - const agentsPath = path.join(repoPath, 'AGENTS.md'); - await fs.writeFile(agentsPath, content, 'utf-8'); - console.log(` ✅ AGENTS.md written`); - - // Ensure symlinks - await ensureSymlinks(repoPath, false); - - console.log(`\n✨ AGENTS.md generated for ${manifest.displayName}.`); -} - -main().catch(err => { - console.error('❌ Error:', err instanceof Error ? err.message : String(err)); - process.exit(1); -}); diff --git a/vendor/bytelyst/create-app/src/generators/api-routes.ts b/vendor/bytelyst/create-app/src/generators/api-routes.ts deleted file mode 100644 index 6c38101..0000000 --- a/vendor/bytelyst/create-app/src/generators/api-routes.ts +++ /dev/null @@ -1,770 +0,0 @@ -#!/usr/bin/env node -/** - * API Route Generator — Next.js App Router - * - * Generates two files: - * src/app/api//route.ts — GET (list) + POST (create) - * src/app/api//[id]/route.ts — GET (detail) + PATCH (update) + DELETE - * - * Follows the pattern used across ByteLyst product dashboards: - * - Named exports (export const GET, POST, PATCH, DELETE) - * - withErrorHandler HOF wrapper - * - Auth via getCurrentUser / getAccessToken - * - Zod validation on POST/PATCH bodies - * - NextRequest + NextResponse - * - * Usage: - * npx tsx src/generators/api-routes.ts --name tasks --fields "title:string,status:enum(pending,active,done),priority:number?" --target ../some-web/src - * npx tsx src/generators/api-routes.ts --name tasks --fields "title:string" --mode proxy --target ../some-web/src - * - * Modes: - * --mode direct (default) Direct Cosmos DB access via repository functions - * --mode proxy Proxy to product backend via fetch (for thin web clients) - * - * @module @bytelyst/create-app/generators/api-routes - */ - -/* eslint-disable no-console -- This generator is a CLI; console output is its user interface. */ - -import { promises as fs } from 'node:fs'; -import path from 'node:path'; - -// ── CLI ────────────────────────────────────────────────────────────────────── - -interface Options { - name: string; - fields: string; - target: string; - mode: 'direct' | 'proxy'; - methods: string[]; - withHandler: boolean; - dryRun: boolean; -} - -function parseArgs(): Options { - const args = process.argv.slice(2); - const options: Options = { - name: '', - fields: '', - target: './src', - mode: 'direct', - methods: ['GET', 'POST', 'PATCH', 'DELETE'], - withHandler: true, - dryRun: false, - }; - - for (let i = 0; i < args.length; i++) { - const arg = args[i]; - if (arg === '--name' || arg === '-n') options.name = args[++i]; - else if (arg === '--fields' || arg === '-f') options.fields = args[++i]; - else if (arg === '--target' || arg === '-t') options.target = args[++i]; - else if (arg === '--mode' || arg === '-m') options.mode = args[++i] as 'direct' | 'proxy'; - else if (arg === '--methods') - options.methods = args[++i].split(',').map(m => m.trim().toUpperCase()); - else if (arg === '--no-handler') options.withHandler = false; - else if (arg === '--dry-run' || arg === '-d') options.dryRun = true; - else if (arg === '--help' || arg === '-h') { - showHelp(); - process.exit(0); - } - } - - if (!options.name) { - console.error('Error: --name is required'); - showHelp(); - process.exit(1); - } - - if (!/^[a-z][a-z0-9-]*$/.test(options.name)) { - console.error('Error: --name must be lowercase alphanumeric with optional hyphens'); - process.exit(1); - } - - if (!['direct', 'proxy'].includes(options.mode)) { - console.error('Error: --mode must be "direct" or "proxy"'); - process.exit(1); - } - - return options; -} - -function showHelp(): void { - console.log(` -API Route Generator — Next.js App Router - -Generates CRUD API route files for a Next.js App Router project. - -Usage: - npx tsx api-routes.ts --name [--fields ""] [options] - -Options: - --name, -n Entity name (e.g., "tasks", "sessions") [required] - --fields, -f Comma-separated field definitions [optional for proxy mode] - --target, -t Target src/ directory (default: "./src") - --mode, -m "direct" (Cosmos DB) or "proxy" (backend fetch) [default: direct] - --methods HTTP methods to generate (default: GET,POST,PATCH,DELETE) - --no-handler Skip withErrorHandler wrapper - --dry-run, -d Preview without writing files - --help, -h Show this help - -Field Types (same as gen-module): - string z.string() - number z.number() - boolean z.boolean() - date z.string().datetime() - enum(a,b,c) z.enum(["a","b","c"]) - Append ? for optional fields - -Modes: - direct — Generates route files that call repository functions (direct Cosmos DB). - Also generates a lib/repositories/.ts and lib/schemas/.ts. - proxy — Generates route files that proxy to a product backend via fetch. - Requires lib/api-helpers.ts to exist (createApiClient pattern). - -Examples: - # Direct mode (full CRUD with Cosmos) - npx tsx api-routes.ts --name tasks \\ - --fields "title:string,status:enum(pending,active,done),priority:number?" \\ - --target ./src - - # Proxy mode (thin web client forwarding to backend) - npx tsx api-routes.ts --name tasks --mode proxy --target ./src - - # Preview only - npx tsx api-routes.ts --name tasks --fields "title:string" --dry-run -`); -} - -// ── Field Parser ───────────────────────────────────────────────────────────── - -interface ParsedField { - name: string; - type: string; - optional: boolean; - zodType: string; - tsType: string; - enumValues: string[] | null; -} - -function splitFields(str: string): string[] { - const parts: string[] = []; - let depth = 0; - let current = ''; - for (const ch of str) { - if (ch === '(') depth++; - if (ch === ')') depth--; - if (ch === ',' && depth === 0) { - parts.push(current.trim()); - current = ''; - } else { - current += ch; - } - } - if (current.trim()) parts.push(current.trim()); - return parts; -} - -function parseFields(fieldsStr: string): ParsedField[] { - if (!fieldsStr) return []; - const fields: ParsedField[] = []; - const fieldDefs = splitFields(fieldsStr); - - for (const raw of fieldDefs) { - const def = raw.trim(); - if (!def) continue; - - const optional = def.endsWith('?'); - const cleaned = optional ? def.slice(0, -1) : def; - const colonIdx = cleaned.indexOf(':'); - if (colonIdx === -1) throw new Error(`Invalid field (missing type): ${def}`); - - const name = cleaned.slice(0, colonIdx).trim(); - const type = cleaned.slice(colonIdx + 1).trim(); - - if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) { - throw new Error(`Invalid field name: ${name}`); - } - - let zodType: string; - let tsType: string; - let enumValues: string[] | null = null; - - if (type.startsWith('enum(') && type.endsWith(')')) { - enumValues = type - .slice(5, -1) - .split(',') - .map(v => v.trim()) - .filter(Boolean); - if (enumValues.length === 0) throw new Error(`Empty enum: ${def}`); - zodType = `z.enum([${enumValues.map(v => `'${v}'`).join(', ')}])`; - tsType = enumValues.map(v => `'${v}'`).join(' | '); - } else { - const MAP: Record = { - string: { zod: 'z.string().min(1)', ts: 'string' }, - number: { zod: 'z.number()', ts: 'number' }, - boolean: { zod: 'z.boolean()', ts: 'boolean' }, - date: { zod: 'z.string().datetime()', ts: 'string' }, - datetime: { zod: 'z.string().datetime()', ts: 'string' }, - 'string[]': { zod: 'z.array(z.string())', ts: 'string[]' }, - 'number[]': { zod: 'z.array(z.number())', ts: 'number[]' }, - }; - const mapping = MAP[type]; - if (!mapping) throw new Error(`Unknown field type "${type}" in: ${def}`); - zodType = mapping.zod; - tsType = mapping.ts; - } - - fields.push({ name, type, optional, zodType, tsType, enumValues }); - } - return fields; -} - -// ── Helpers ────────────────────────────────────────────────────────────────── - -function pascal(s: string): string { - return s.replace(/(^|-)([a-z])/g, (_, __, c: string) => c.toUpperCase()); -} -// ── Proxy Mode Templates ──────────────────────────────────────────────────── - -function genProxyListRoute(name: string, methods: string[], withHandler: boolean): string { - const hasGet = methods.includes('GET'); - const hasPost = methods.includes('POST'); - const handlerImport = withHandler - ? `import { withErrorHandler } from '@/lib/api-handler';\n` - : ''; - const wrap = (code: string) => (withHandler ? `withErrorHandler(${code})` : code); - - let out = `import { NextRequest, NextResponse } from 'next/server'; -import { getAccessToken } from '@/lib/api-helpers'; -${handlerImport} -const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL ?? 'http://localhost:4000'; -`; - - if (hasGet) { - out += ` -export const GET = ${wrap(`async (req: NextRequest) => { - const token = await getAccessToken(req); - if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - - const url = new URL(req.url); - const params = url.searchParams.toString(); - const res = await fetch(\`\${BACKEND_URL}/api/${name}\${params ? '?' + params : ''}\`, { - headers: { Authorization: \`Bearer \${token}\` }, - }); - const data = await res.json(); - return NextResponse.json(data, { status: res.status }); -}`)}; -`; - } - - if (hasPost) { - out += ` -export const POST = ${wrap(`async (req: NextRequest) => { - const token = await getAccessToken(req); - if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - - const body = await req.json(); - const res = await fetch(\`\${BACKEND_URL}/api/${name}\`, { - method: 'POST', - headers: { Authorization: \`Bearer \${token}\`, 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - const data = await res.json(); - return NextResponse.json(data, { status: res.status }); -}`)}; -`; - } - - return out; -} - -function genProxyDetailRoute(name: string, methods: string[], withHandler: boolean): string { - const hasGet = methods.includes('GET'); - const hasPatch = methods.includes('PATCH'); - const hasDelete = methods.includes('DELETE'); - const handlerImport = withHandler - ? `import { withErrorHandler } from '@/lib/api-handler';\n` - : ''; - const wrap = (code: string) => (withHandler ? `withErrorHandler(${code})` : code); - - let out = `import { NextRequest, NextResponse } from 'next/server'; -import { getAccessToken } from '@/lib/api-helpers'; -${handlerImport} -const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL ?? 'http://localhost:4000'; - -type RouteContext = { params: Promise<{ id: string }> }; -`; - - if (hasGet) { - out += ` -export const GET = ${wrap(`async (req: NextRequest, { params }: RouteContext) => { - const token = await getAccessToken(req); - if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - - const { id } = await params; - const res = await fetch(\`\${BACKEND_URL}/api/${name}/\${id}\`, { - headers: { Authorization: \`Bearer \${token}\` }, - }); - const data = await res.json(); - return NextResponse.json(data, { status: res.status }); -}`)}; -`; - } - - if (hasPatch) { - out += ` -export const PATCH = ${wrap(`async (req: NextRequest, { params }: RouteContext) => { - const token = await getAccessToken(req); - if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - - const { id } = await params; - const body = await req.json(); - const res = await fetch(\`\${BACKEND_URL}/api/${name}/\${id}\`, { - method: 'PATCH', - headers: { Authorization: \`Bearer \${token}\`, 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - const data = await res.json(); - return NextResponse.json(data, { status: res.status }); -}`)}; -`; - } - - if (hasDelete) { - out += ` -export const DELETE = ${wrap(`async (req: NextRequest, { params }: RouteContext) => { - const token = await getAccessToken(req); - if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - - const { id } = await params; - const res = await fetch(\`\${BACKEND_URL}/api/${name}/\${id}\`, { - method: 'DELETE', - headers: { Authorization: \`Bearer \${token}\` }, - }); - if (res.status === 204) return new NextResponse(null, { status: 204 }); - const data = await res.json(); - return NextResponse.json(data, { status: res.status }); -}`)}; -`; - } - - return out; -} - -// ── Direct Mode Templates ─────────────────────────────────────────────────── - -function genSchemaFile(name: string, fields: ParsedField[]): string { - const P = pascal(name); - const createFields = fields - .map(f => ` ${f.name}: ${f.zodType}${f.optional ? '.optional()' : ''},`) - .join('\n'); - const updateFields = fields.map(f => ` ${f.name}: ${f.zodType}.optional(),`).join('\n'); - - return `import { z } from 'zod'; - -export const Create${P}Schema = z.object({ -${createFields} -}); - -export const Update${P}Schema = z.object({ -${updateFields} -}); - -export type Create${P}Input = z.infer; -export type Update${P}Input = z.infer; -`; -} - -function genRepositoryFile(name: string, fields: ParsedField[]): string { - const P = pascal(name); - const docFields = fields.map(f => ` ${f.name}${f.optional ? '?' : ''}: ${f.tsType};`).join('\n'); - - return `import { randomUUID } from 'node:crypto'; -import { getCosmosContainer, PRODUCT_ID } from '@/lib/datastore'; -import type { Create${P}Input, Update${P}Input } from '@/lib/schemas/${name}'; - -export interface ${P}Doc { - id: string; - productId: string; - userId: string; -${docFields} - createdAt: string; - updatedAt: string; -} - -function getContainer() { - return getCosmosContainer('${name}'); -} - -export async function list${P}(userId: string, limit = 50, offset = 0): Promise<${P}Doc[]> { - const { resources } = await getContainer() - .items.query<${P}Doc>({ - query: 'SELECT * FROM c WHERE c.userId = @uid AND c.productId = @pid ORDER BY c.createdAt DESC OFFSET @off LIMIT @lim', - parameters: [ - { name: '@uid', value: userId }, - { name: '@pid', value: PRODUCT_ID }, - { name: '@off', value: offset }, - { name: '@lim', value: limit }, - ], - }) - .fetchAll(); - return resources; -} - -export async function get${P}(id: string, userId: string): Promise<${P}Doc | null> { - try { - const { resource } = await getContainer().item(id, userId).read<${P}Doc>(); - return resource ?? null; - } catch { - return null; - } -} - -export async function create${P}(userId: string, input: Create${P}Input): Promise<${P}Doc> { - const now = new Date().toISOString(); - const doc: ${P}Doc = { - id: \`${name.slice(0, 3)}_\${randomUUID()}\`, - productId: PRODUCT_ID, - userId, - ...input, - createdAt: now, - updatedAt: now, - }; - await getContainer().items.create(doc); - return doc; -} - -export async function update${P}(id: string, userId: string, updates: Update${P}Input): Promise<${P}Doc | null> { - const existing = await get${P}(id, userId); - if (!existing) return null; - - const updated: ${P}Doc = { - ...existing, - ...updates, - updatedAt: new Date().toISOString(), - }; - await getContainer().item(id, userId).replace(updated); - return updated; -} - -export async function delete${P}(id: string, userId: string): Promise { - try { - await getContainer().item(id, userId).delete(); - return true; - } catch { - return false; - } -} -`; -} - -function genDirectListRoute( - name: string, - fields: ParsedField[], - methods: string[], - withHandler: boolean -): string { - const P = pascal(name); - const hasGet = methods.includes('GET'); - const hasPost = methods.includes('POST'); - const handlerImport = withHandler - ? `import { withErrorHandler } from '@/lib/api-handler';\n` - : ''; - const wrap = (code: string) => (withHandler ? `withErrorHandler(${code})` : code); - - const imports: string[] = []; - if (hasGet) imports.push(`list${P}`); - if (hasPost) imports.push(`create${P}`); - - let out = `import { NextRequest, NextResponse } from 'next/server'; -import { getCurrentUser } from '@/lib/auth-server'; -${handlerImport}`; - - if (imports.length > 0) { - out += `import { ${imports.join(', ')} } from '@/lib/repositories/${name}';\n`; - } - - if (hasPost) { - out += `import { Create${P}Schema } from '@/lib/schemas/${name}';\n`; - } - - if (hasGet) { - out += ` -export const GET = ${wrap(`async (req: NextRequest) => { - const user = await getCurrentUser(req.headers.get('authorization')); - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - - const url = new URL(req.url); - const limit = parseInt(url.searchParams.get('limit') ?? '50'); - const offset = parseInt(url.searchParams.get('offset') ?? '0'); - - const items = await list${P}(user.id, limit, offset); - return NextResponse.json({ items }); -}`)}; -`; - } - - if (hasPost) { - out += ` -export const POST = ${wrap(`async (req: NextRequest) => { - const user = await getCurrentUser(req.headers.get('authorization')); - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - - const body = await req.json(); - const input = Create${P}Schema.parse(body); - const item = await create${P}(user.id, input); - return NextResponse.json(item, { status: 201 }); -}`)}; -`; - } - - return out; -} - -function genDirectDetailRoute( - name: string, - _fields: ParsedField[], - methods: string[], - withHandler: boolean -): string { - const P = pascal(name); - const hasGet = methods.includes('GET'); - const hasPatch = methods.includes('PATCH'); - const hasDelete = methods.includes('DELETE'); - const handlerImport = withHandler - ? `import { withErrorHandler } from '@/lib/api-handler';\n` - : ''; - const wrap = (code: string) => (withHandler ? `withErrorHandler(${code})` : code); - - const imports: string[] = []; - if (hasGet) imports.push(`get${P}`); - if (hasPatch) imports.push(`update${P}`); - if (hasDelete) imports.push(`delete${P}`); - - let out = `import { NextRequest, NextResponse } from 'next/server'; -import { getCurrentUser } from '@/lib/auth-server'; -${handlerImport}`; - - if (imports.length > 0) { - out += `import { ${imports.join(', ')} } from '@/lib/repositories/${name}';\n`; - } - - if (hasPatch) { - out += `import { Update${P}Schema } from '@/lib/schemas/${name}';\n`; - } - - out += ` -type RouteContext = { params: Promise<{ id: string }> }; -`; - - if (hasGet) { - out += ` -export const GET = ${wrap(`async ( - req: NextRequest, - { params }: RouteContext, -) => { - const user = await getCurrentUser(req.headers.get('authorization')); - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - - const { id } = await params; - const item = await get${P}(id, user.id); - if (!item) return NextResponse.json({ error: 'Not found' }, { status: 404 }); - - return NextResponse.json(item); -}`)}; -`; - } - - if (hasPatch) { - out += ` -export const PATCH = ${wrap(`async ( - req: NextRequest, - { params }: RouteContext, -) => { - const user = await getCurrentUser(req.headers.get('authorization')); - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - - const { id } = await params; - const body = await req.json(); - const updates = Update${P}Schema.parse(body); - const item = await update${P}(id, user.id, updates); - if (!item) return NextResponse.json({ error: 'Not found' }, { status: 404 }); - - return NextResponse.json(item); -}`)}; -`; - } - - if (hasDelete) { - out += ` -export const DELETE = ${wrap(`async ( - req: NextRequest, - { params }: RouteContext, -) => { - const user = await getCurrentUser(req.headers.get('authorization')); - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - - const { id } = await params; - const deleted = await delete${P}(id, user.id); - if (!deleted) return NextResponse.json({ error: 'Not found' }, { status: 404 }); - - return NextResponse.json({ success: true }); -}`)}; -`; - } - - return out; -} - -// ── Test Template ──────────────────────────────────────────────────────────── - -function genSchemaTest(name: string, fields: ParsedField[]): string { - const P = pascal(name); - const requiredFields = fields.filter(f => !f.optional); - - function sampleValue(f: ParsedField): string { - if (f.enumValues) return `'${f.enumValues[0]}'`; - if (f.tsType === 'string') return `'test'`; - if (f.tsType === 'number') return '42'; - if (f.tsType === 'boolean') return 'true'; - return `'2026-01-01T00:00:00.000Z'`; - } - - const validPayload = requiredFields.map(f => ` ${f.name}: ${sampleValue(f)},`).join('\n'); - - return `import { describe, it, expect } from 'vitest'; -import { Create${P}Schema, Update${P}Schema } from './schemas/${name}'; - -describe('Create${P}Schema', () => { - it('accepts valid input', () => { - const result = Create${P}Schema.parse({ -${validPayload} - }); - expect(result).toBeDefined(); - }); - - it('rejects empty object', () => { - expect(() => Create${P}Schema.parse({})).toThrow(); - }); -}); - -describe('Update${P}Schema', () => { - it('accepts empty object (all optional)', () => { - const result = Update${P}Schema.parse({}); - expect(result).toEqual({}); - }); - - it('accepts partial update', () => { - const result = Update${P}Schema.parse({ ${requiredFields[0]?.name ?? 'id'}: ${sampleValue(requiredFields[0] ?? fields[0])} }); - expect(result).toBeDefined(); - }); -}); -`; -} - -// ── Main ───────────────────────────────────────────────────────────────────── - -async function main(): Promise { - const { name, fields, target, mode, methods, withHandler, dryRun } = parseArgs(); - - console.log(`\n🚀 Generating API routes: ${name}`); - console.log(` Mode: ${mode}`); - console.log(` Methods: ${methods.join(', ')}`); - console.log(` Target: ${target}`); - if (fields) console.log(` Fields: ${fields}`); - if (dryRun) console.log(' ⚠️ DRY RUN — no files will be written\n'); - - const parsedFields = parseFields(fields); - - // Determine which files to generate - const files: { path: string; content: string }[] = []; - - if (mode === 'proxy') { - files.push({ - path: `app/api/${name}/route.ts`, - content: genProxyListRoute(name, methods, withHandler), - }); - if (methods.some(m => ['GET', 'PATCH', 'DELETE'].includes(m))) { - files.push({ - path: `app/api/${name}/[id]/route.ts`, - content: genProxyDetailRoute(name, methods, withHandler), - }); - } - } else { - // Direct mode — also generate schema + repository - if (parsedFields.length === 0) { - console.error('Error: --fields is required in direct mode'); - process.exit(1); - } - - files.push({ - path: `lib/schemas/${name}.ts`, - content: genSchemaFile(name, parsedFields), - }); - files.push({ - path: `lib/repositories/${name}.ts`, - content: genRepositoryFile(name, parsedFields), - }); - files.push({ - path: `app/api/${name}/route.ts`, - content: genDirectListRoute(name, parsedFields, methods, withHandler), - }); - if (methods.some(m => ['GET', 'PATCH', 'DELETE'].includes(m))) { - files.push({ - path: `app/api/${name}/[id]/route.ts`, - content: genDirectDetailRoute(name, parsedFields, methods, withHandler), - }); - } - files.push({ - path: `lib/__tests__/${name}.test.ts`, - content: genSchemaTest(name, parsedFields), - }); - } - - if (dryRun) { - console.log('📄 Generated files:\n'); - for (const file of files) { - console.log(`── ${file.path} ──────────────────────────────────────`); - console.log(file.content); - } - console.log(`\n✨ Dry run complete. Re-run without --dry-run to write files.`); - return; - } - - // Write files - for (const file of files) { - const fullPath = path.join(target, file.path); - await fs.mkdir(path.dirname(fullPath), { recursive: true }); - - // Check if file already exists - try { - await fs.access(fullPath); - console.log(` ⚠️ SKIP ${file.path} (already exists)`); - continue; - } catch { - // Good — doesn't exist - } - - await fs.writeFile(fullPath, file.content, 'utf-8'); - console.log(` ✅ ${file.path}`); - } - - console.log(`\n✨ API routes generated for "${name}".`); - if (mode === 'direct') { - console.log(`\nPrerequisites (if not already present):`); - console.log(` - lib/auth-server.ts — getCurrentUser(authHeader) function`); - console.log(` - lib/api-handler.ts — withErrorHandler HOF`); - console.log(` - lib/datastore.ts — getCosmosContainer + PRODUCT_ID`); - console.log(` - zod installed — npm install zod`); - } else { - console.log(`\nPrerequisites (if not already present):`); - console.log(` - lib/api-helpers.ts — getAccessToken(req) function`); - console.log(` - lib/api-handler.ts — withErrorHandler HOF`); - console.log(` - NEXT_PUBLIC_BACKEND_URL env var (or defaults to localhost:4000)`); - } -} - -main().catch(err => { - console.error('❌ Error:', err instanceof Error ? err.message : String(err)); - process.exit(1); -}); diff --git a/vendor/bytelyst/create-app/src/index.ts b/vendor/bytelyst/create-app/src/index.ts deleted file mode 100644 index 48fc97d..0000000 --- a/vendor/bytelyst/create-app/src/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * @bytelyst/create-app — CLI tools for scaffolding ByteLyst product repos. - * - * Generators: - * - api-routes.ts — Next.js App Router API route generator - * - agents-md.ts — AGENTS.md auto-generator from product.json + repo scan - */ - -export {}; diff --git a/vendor/bytelyst/create-app/src/lib/template-engine.ts b/vendor/bytelyst/create-app/src/lib/template-engine.ts deleted file mode 100644 index 629f5e3..0000000 --- a/vendor/bytelyst/create-app/src/lib/template-engine.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Simple template engine for scaffolding. - * Supports {{VARIABLE}} replacement and {{#IF FEATURE}}...{{/IF FEATURE}} conditional blocks. - */ - -export type TemplateVars = Record; - -/** - * Replace {{VARIABLE}} placeholders and process {{#IF FEATURE}}...{{/IF FEATURE}} blocks. - */ -export function renderTemplate(template: string, vars: TemplateVars): string { - let result = template; - - // Process conditional blocks: {{#IF KEY}}...{{/IF KEY}} - const ifRegex = /\{\{#IF (\w+)\}\}([\s\S]*?)\{\{\/IF \1\}\}/g; - result = result.replace(ifRegex, (_, key: string, content: string) => { - return vars[key] ? content : ''; - }); - - // Process negative conditional blocks: {{#UNLESS KEY}}...{{/UNLESS KEY}} - const unlessRegex = /\{\{#UNLESS (\w+)\}\}([\s\S]*?)\{\{\/UNLESS \1\}\}/g; - result = result.replace(unlessRegex, (_, key: string, content: string) => { - return !vars[key] ? content : ''; - }); - - // Replace {{VARIABLE}} placeholders - result = result.replace(/\{\{(\w+)\}\}/g, (match, key: string) => { - const val = vars[key]; - if (val === undefined) return match; - return String(val); - }); - - return result; -} diff --git a/vendor/bytelyst/create-app/src/lib/templates.ts b/vendor/bytelyst/create-app/src/lib/templates.ts deleted file mode 100644 index fd44728..0000000 --- a/vendor/bytelyst/create-app/src/lib/templates.ts +++ /dev/null @@ -1,457 +0,0 @@ -/** - * Inline templates for product repo scaffolding. - * Each template uses {{VARIABLE}} and {{#IF FEATURE}}...{{/IF FEATURE}} syntax. - */ - -// ── product.json ───────────────────────────────────────────────────────────── - -export const PRODUCT_JSON = `{ - "productId": "{{PRODUCT_ID}}", - "displayName": "{{DISPLAY_NAME}}", - "tagline": "{{TAGLINE}}", - "domain": "{{DOMAIN}}", - "backendPort": {{BACKEND_PORT}}, - "primarySurface": "{{PRIMARY_SURFACE}}", - "bundleIds": { - "web": "{{DOMAIN}}"{{#IF HAS_IOS}}, - "ios": "com.bytelyst.{{PRODUCT_ID}}"{{/IF HAS_IOS}}{{#IF HAS_ANDROID}}, - "android": "com.{{PRODUCT_ID}}.app"{{/IF HAS_ANDROID}} - }, - "appStore": { - "category": "Productivity", - "privacyUrl": "https://{{DOMAIN}}/privacy", - "termsUrl": "https://{{DOMAIN}}/terms", - "supportUrl": "https://{{DOMAIN}}/support" - }, - "version": "0.1.0" -} -`; - -// ── .gitignore ─────────────────────────────────────────────────────────────── - -export const GITIGNORE = `node_modules/ -dist/ -.next/ -.env -.env.local -*.log -.DS_Store -coverage/ -`; - -// ── .env.example ───────────────────────────────────────────────────────────── - -export const ENV_EXAMPLE = `# {{DISPLAY_NAME}} environment variables -NODE_ENV=development -{{#IF HAS_BACKEND}} -# Backend -PORT={{BACKEND_PORT}} -HOST=0.0.0.0 -JWT_SECRET=dev-secret-change-me -DB_PROVIDER=memory -COSMOS_ENDPOINT= -COSMOS_KEY= -COSMOS_DATABASE=bytelyst -PLATFORM_SERVICE_URL=http://localhost:4003 -{{/IF HAS_BACKEND}} -{{#IF HAS_WEB}} -# Web -NEXT_PUBLIC_BACKEND_URL=http://localhost:{{BACKEND_PORT}} -{{/IF HAS_WEB}} -`; - -// ── README.md ──────────────────────────────────────────────────────────────── - -export const README = `# {{DISPLAY_NAME}} - -> {{TAGLINE}} - -## Quick Start - -\`\`\`bash -{{#IF HAS_BACKEND}}# Backend -cd backend && npm install && npm run dev -{{/IF HAS_BACKEND}}{{#IF HAS_WEB}}# Web -cd web && npm install && npm run dev -{{/IF HAS_WEB}}{{#IF HAS_MOBILE}}# Mobile -cd mobile && npm install && npm start -{{/IF HAS_MOBILE}}\`\`\` - -## Architecture - -| Layer | Technology | -|-------|-----------| -{{#IF HAS_BACKEND}}| Backend | Fastify 5 + TypeScript ESM (port {{BACKEND_PORT}}) | -{{/IF HAS_BACKEND}}{{#IF HAS_WEB}}| Web | Next.js 16 (App Router) + React 19 | -{{/IF HAS_WEB}}{{#IF HAS_MOBILE}}| Mobile | React Native (Expo) | -{{/IF HAS_MOBILE}}| Platform | platform-service (port 4003) | -| Database | Azure Cosmos DB (\`productId: "{{PRODUCT_ID}}"\`) | - -## Product Identity - -- **Product ID:** \`{{PRODUCT_ID}}\` -- **Domain:** {{DOMAIN}} -- **Backend Port:** {{BACKEND_PORT}} - -See [AGENTS.md](AGENTS.md) for AI agent instructions. -`; - -// ── Backend templates ──────────────────────────────────────────────────────── - -export const BACKEND_PACKAGE_JSON = `{ - "name": "@{{PRODUCT_ID}}/backend", - "version": "0.1.0", - "private": true, - "type": "module", - "scripts": { - "dev": "tsx watch src/server.ts", - "build": "tsc", - "start": "node dist/server.js", - "test": "vitest run", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "@bytelyst/config": "file:../../learning_ai_common_plat/packages/config", - "@bytelyst/cosmos": "file:../../learning_ai_common_plat/packages/cosmos", - "@bytelyst/datastore": "file:../../learning_ai_common_plat/packages/datastore", - "@bytelyst/errors": "file:../../learning_ai_common_plat/packages/errors", - "@bytelyst/fastify-core": "file:../../learning_ai_common_plat/packages/fastify-core", - "fastify": "^5.3.3", - "jose": "^6.0.11", - "zod": "^3.24.4" - }, - "devDependencies": { - "tsx": "^4.19.2", - "typescript": "^5.7.3", - "vitest": "^3.0.5" - } -} -`; - -export const BACKEND_TSCONFIG = `{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "outDir": "dist", - "rootDir": "src", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "resolveJsonModule": true, - "declaration": true, - "declarationMap": true, - "sourceMap": true - }, - "include": ["src"], - "exclude": ["dist", "src/**/*.test.ts"] -} -`; - -export const BACKEND_CONFIG = `import { z } from 'zod'; -import { PRODUCT_ID } from './product-config.js'; - -const envSchema = z.object({ - PORT: z.coerce.number().default({{BACKEND_PORT}}), - HOST: z.string().default('0.0.0.0'), - NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), - CORS_ORIGIN: z.string().optional(), - SERVICE_NAME: z.string().default('{{PRODUCT_ID}}-backend'), - COSMOS_ENDPOINT: z.string().optional(), - COSMOS_KEY: z.string().optional(), - COSMOS_DATABASE: z.string().default('bytelyst'), - JWT_SECRET: z.string().min(1, 'JWT_SECRET is required'), - DB_PROVIDER: z.enum(['cosmos', 'memory']).default('cosmos'), - PRODUCT_ID: z.string().default(PRODUCT_ID), - PLATFORM_SERVICE_URL: z.string().default('http://localhost:4003'), -}); - -export const config = envSchema.parse(process.env); -`; - -export const BACKEND_PRODUCT_CONFIG = `import { readFileSync } from 'node:fs'; -import { join, dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const raw = readFileSync(join(__dirname, '../../../shared/product.json'), 'utf-8'); -const manifest = JSON.parse(raw); -export const PRODUCT_ID: string = manifest.productId; -`; - -export const BACKEND_AUTH = `import { jwtVerify, createRemoteJWKSet } from 'jose'; -import type { FastifyRequest } from 'fastify'; -import { config } from './config.js'; - -const secret = new TextEncoder().encode(config.JWT_SECRET); - -export interface JwtPayload { - sub: string; - email?: string; - role?: string; -} - -export async function verifyToken(token: string): Promise { - try { - const { payload } = await jwtVerify(token, secret); - return payload as unknown as JwtPayload; - } catch { - return null; - } -} - -export function extractToken(req: FastifyRequest): string | null { - const auth = req.headers.authorization; - if (!auth?.startsWith('Bearer ')) return null; - return auth.slice(7); -} -`; - -export const BACKEND_REQUEST_CONTEXT = `import type { FastifyRequest } from 'fastify'; -import { verifyToken, extractToken, type JwtPayload } from './auth.js'; -import { config } from './config.js'; - -export async function getUserPayload(req: FastifyRequest): Promise { - const token = extractToken(req); - if (!token) return null; - return verifyToken(token); -} - -export function getUserId(req: FastifyRequest & { user?: JwtPayload }): string { - if (!req.user?.sub) throw new Error('Unauthenticated'); - return req.user.sub; -} - -export function getRequestProductId(_req: FastifyRequest): string { - return config.PRODUCT_ID; -} -`; - -export const BACKEND_ERRORS = `export { BadRequestError, NotFoundError, ConflictError, ForbiddenError } from '@bytelyst/errors'; -`; - -export const BACKEND_DATASTORE = `import { config } from './config.js'; - -const collections = new Map>(); - -export function getCollection(name: string): Map { - if (!collections.has(name)) { - collections.set(name, new Map()); - } - return collections.get(name) as Map; -} - -export const PRODUCT_ID = config.PRODUCT_ID; -export const DB_PROVIDER = config.DB_PROVIDER; -`; - -export const BACKEND_SERVER = `import Fastify from 'fastify'; -import { config } from './lib/config.js'; - -const app = Fastify({ - logger: { - level: config.NODE_ENV === 'test' ? 'silent' : 'info', - }, -}); - -// CORS -if (config.CORS_ORIGIN) { - app.addHook('onRequest', async (request, reply) => { - reply.header('Access-Control-Allow-Origin', config.CORS_ORIGIN); - reply.header('Access-Control-Allow-Methods', 'GET,POST,PATCH,PUT,DELETE,OPTIONS'); - reply.header('Access-Control-Allow-Headers', 'Content-Type, Authorization'); - if (request.method === 'OPTIONS') { - reply.status(204).send(); - } - }); -} - -// Health check -app.get('/health', async () => ({ - status: 'ok', - service: config.SERVICE_NAME, - productId: config.PRODUCT_ID, -})); - -// TODO: Register your route modules here -// import { routes as exampleRoutes } from './modules/example/routes.js'; -// app.register(exampleRoutes, { prefix: '/api' }); - -async function start() { - try { - await app.listen({ port: config.PORT, host: config.HOST }); - app.log.info(\`{{DISPLAY_NAME}} backend listening on port \${config.PORT}\`); - } catch (err) { - app.log.error(err); - process.exit(1); - } -} - -start(); - -export { app }; -`; - -// ── Web templates ──────────────────────────────────────────────────────────── - -export const WEB_PACKAGE_JSON = `{ - "name": "@{{PRODUCT_ID}}/web", - "version": "0.1.0", - "private": true, - "scripts": { - "dev": "next dev", - "build": "next build --webpack", - "start": "next start", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "next": "^16.0.0", - "react": "^19.0.0", - "react-dom": "^19.0.0" - }, - "devDependencies": { - "@types/node": "^22.16.0", - "@types/react": "^19.2.0", - "@types/react-dom": "^19.2.0", - "typescript": "^5.7.3" - } -} -`; - -export const WEB_TSCONFIG = `{ - "compilerOptions": { - "target": "ES2022", - "lib": ["dom", "dom.iterable", "esnext"], - "allowJs": true, - "skipLibCheck": true, - "strict": true, - "noEmit": true, - "esModuleInterop": true, - "module": "esnext", - "moduleResolution": "bundler", - "resolveJsonModule": true, - "isolatedModules": true, - "jsx": "preserve", - "incremental": true, - "plugins": [{ "name": "next" }], - "paths": { - "@/*": ["./src/*"] - } - }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] -} -`; - -export const WEB_NEXT_CONFIG = `import type { NextConfig } from 'next'; - -const nextConfig: NextConfig = { - reactStrictMode: true, -}; - -export default nextConfig; -`; - -export const WEB_LAYOUT = `import type { Metadata } from 'next'; - -export const metadata: Metadata = { - title: '{{DISPLAY_NAME}}', - description: '{{TAGLINE}}', -}; - -export default function RootLayout({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ); -} -`; - -export const WEB_PAGE = `export default function Home() { - return ( -
-

{{DISPLAY_NAME}}

-

{{TAGLINE}}

-

Edit src/app/page.tsx to get started.

-
- ); -} -`; - -export const WEB_PRODUCT_CONFIG = `const manifest = { - productId: '{{PRODUCT_ID}}', - displayName: '{{DISPLAY_NAME}}', - domain: '{{DOMAIN}}', - backendPort: {{BACKEND_PORT}}, -}; - -export const PRODUCT_ID = manifest.productId; - -export function getBackendURL(): string { - return process.env.NEXT_PUBLIC_BACKEND_URL ?? 'http://localhost:{{BACKEND_PORT}}'; -} - -export default manifest; -`; - -// ── Mobile (Expo) templates ────────────────────────────────────────────────── - -export const MOBILE_PACKAGE_JSON = `{ - "name": "@{{PRODUCT_ID}}/mobile", - "version": "0.1.0", - "private": true, - "main": "expo-router/entry", - "scripts": { - "start": "expo start", - "android": "expo start --android", - "ios": "expo start --ios", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "expo": "~55.0.0", - "expo-router": "~5.0.0", - "react": "^19.0.0", - "react-native": "^0.79.0" - }, - "devDependencies": { - "@types/react": "^19.2.0", - "typescript": "^5.7.3" - } -} -`; - -export const MOBILE_APP_JSON = `{ - "expo": { - "name": "{{DISPLAY_NAME}}", - "slug": "{{PRODUCT_ID}}", - "version": "1.0.0", - "scheme": "{{PRODUCT_ID}}", - "platforms": ["ios", "android"], - "ios": { - "bundleIdentifier": "com.bytelyst.{{PRODUCT_ID}}" - }, - "android": { - "package": "com.{{PRODUCT_ID}}.app" - } - } -} -`; - -export const MOBILE_INDEX = `import { View, Text, StyleSheet } from 'react-native'; - -export default function Home() { - return ( - - {{DISPLAY_NAME}} - {{TAGLINE}} - - ); -} - -const styles = StyleSheet.create({ - container: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 20 }, - title: { fontSize: 28, fontWeight: '700', marginBottom: 8 }, - subtitle: { fontSize: 16, color: '#666' }, -}); -`; diff --git a/vendor/bytelyst/create-app/src/scaffolder.ts b/vendor/bytelyst/create-app/src/scaffolder.ts deleted file mode 100644 index e733506..0000000 --- a/vendor/bytelyst/create-app/src/scaffolder.ts +++ /dev/null @@ -1,315 +0,0 @@ -#!/usr/bin/env node -/** - * CLI Scaffolder — generates a fully wired ByteLyst product repo. - * - * Usage: - * npx tsx scaffolder.ts # Interactive prompts - * npx tsx scaffolder.ts --from product.json # From existing manifest - * npx tsx scaffolder.ts --from product.json --dry-run # Preview - * - * @module @bytelyst/create-app/scaffolder - */ - -import { promises as fs } from 'node:fs'; -import path from 'node:path'; -import readline from 'node:readline'; -import { renderTemplate, type TemplateVars } from './lib/template-engine.js'; -import * as T from './lib/templates.js'; - -// ── Types ──────────────────────────────────────────────────────────────────── - -interface ProductManifest { - productId: string; - displayName: string; - tagline: string; - domain: string; - backendPort: number; - primarySurface: string; - platforms: ('web' | 'mobile' | 'ios' | 'android')[]; - features: string[]; -} - -interface CliOptions { - from: string | null; - outDir: string | null; - dryRun: boolean; -} - -// ── CLI Arg Parsing ────────────────────────────────────────────────────────── - -function parseCliArgs(): CliOptions { - const args = process.argv.slice(2); - const options: CliOptions = { from: null, outDir: null, dryRun: false }; - - for (let i = 0; i < args.length; i++) { - const arg = args[i]; - if (arg === '--from' || arg === '-f') options.from = args[++i]; - else if (arg === '--out' || arg === '-o') options.outDir = args[++i]; - else if (arg === '--dry-run' || arg === '-d') options.dryRun = true; - else if (arg === '--help' || arg === '-h') { - showHelp(); - process.exit(0); - } - } - - return options; -} - -function showHelp(): void { - // eslint-disable-next-line no-console - console.log(` -@bytelyst/create-app — Product Repo Scaffolder - -Generates a fully wired ByteLyst product repo with backend, web, and/or mobile. - -Usage: - npx tsx scaffolder.ts # Interactive prompts - npx tsx scaffolder.ts --from product.json # From existing manifest - npx tsx scaffolder.ts --from product.json -d # Dry run - -Options: - --from, -f Path to existing product.json (skip prompts) - --out, -o Output directory (default: ./) - --dry-run, -d Preview files without writing - --help, -h Show this help - -Interactive Prompts: - 1. Product name + ID + tagline + domain - 2. Backend port - 3. Platform selection: web, mobile (Expo), iOS, Android - 4. Feature selection: auth, billing, telemetry, flags, sync, push - -Output: - / - ├── shared/product.json - ├── backend/ (if selected) - ├── web/ (if selected) - ├── mobile/ (if selected) - ├── .gitignore - ├── .env.example - ├── README.md - └── AGENTS.md -`); -} - -// ── Interactive Prompts ────────────────────────────────────────────────────── - -function createPrompt(): (question: string) => Promise { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - - return (question: string) => - new Promise(resolve => { - rl.question(question, answer => { - resolve(answer.trim()); - }); - }); -} - -async function gatherManifestInteractively(): Promise { - const ask = createPrompt(); - - // eslint-disable-next-line no-console - console.log('\n📦 ByteLyst Product Scaffolder\n'); - - const displayName = (await ask('Product name (e.g., FlowMonk): ')) || 'MyApp'; - const defaultId = displayName.toLowerCase().replace(/[^a-z0-9]/g, ''); - const productId = (await ask(`Product ID [${defaultId}]: `)) || defaultId; - const tagline = (await ask('Tagline: ')) || `${displayName} — a ByteLyst product`; - const defaultDomain = `${productId}.app`; - const domain = (await ask(`Domain [${defaultDomain}]: `)) || defaultDomain; - const backendPort = parseInt(await ask('Backend port [4020]: ')) || 4020; - - // eslint-disable-next-line no-console - console.log('\nPlatforms (comma-separated: web, mobile, ios, android)'); - const platformStr = (await ask('Platforms [web]: ')) || 'web'; - const platforms = platformStr - .split(',') - .map(p => p.trim().toLowerCase()) as ProductManifest['platforms']; - - const primarySurface = platforms.includes('web') ? 'web' : platforms[0]; - - // eslint-disable-next-line no-console - console.log('\nFeatures (comma-separated: auth, billing, telemetry, flags, sync, push)'); - const featureStr = (await ask('Features [auth,telemetry,flags]: ')) || 'auth,telemetry,flags'; - const features = featureStr.split(',').map(f => f.trim().toLowerCase()); - - // Close readline - process.stdin.unref(); - - return { - productId, - displayName, - tagline, - domain, - backendPort, - primarySurface, - platforms, - features, - }; -} - -async function loadManifestFromFile(filePath: string): Promise { - const raw = await fs.readFile(filePath, 'utf-8'); - const data = JSON.parse(raw); - - return { - productId: data.productId || 'myapp', - displayName: data.displayName || data.productId || 'MyApp', - tagline: data.tagline || `${data.displayName} — a ByteLyst product`, - domain: data.domain || `${data.productId}.app`, - backendPort: data.backendPort || 4020, - primarySurface: data.primarySurface || 'web', - platforms: data.platforms || ['web'], - features: data.features || ['auth', 'telemetry', 'flags'], - }; -} - -// ── File Generator ─────────────────────────────────────────────────────────── - -interface GeneratedFile { - path: string; - content: string; -} - -function generateFiles(manifest: ProductManifest): GeneratedFile[] { - const vars: TemplateVars = { - PRODUCT_ID: manifest.productId, - DISPLAY_NAME: manifest.displayName, - TAGLINE: manifest.tagline, - DOMAIN: manifest.domain, - BACKEND_PORT: manifest.backendPort, - PRIMARY_SURFACE: manifest.primarySurface, - HAS_BACKEND: true, // Always generate backend - HAS_WEB: manifest.platforms.includes('web'), - HAS_MOBILE: manifest.platforms.includes('mobile'), - HAS_IOS: manifest.platforms.includes('ios'), - HAS_ANDROID: manifest.platforms.includes('android'), - HAS_AUTH: manifest.features.includes('auth'), - HAS_BILLING: manifest.features.includes('billing'), - HAS_TELEMETRY: manifest.features.includes('telemetry'), - HAS_FLAGS: manifest.features.includes('flags'), - HAS_SYNC: manifest.features.includes('sync'), - HAS_PUSH: manifest.features.includes('push'), - }; - - const render = (tmpl: string) => renderTemplate(tmpl, vars); - const files: GeneratedFile[] = []; - - // ── Root files - files.push({ path: 'shared/product.json', content: render(T.PRODUCT_JSON) }); - files.push({ path: '.gitignore', content: render(T.GITIGNORE) }); - files.push({ path: '.env.example', content: render(T.ENV_EXAMPLE) }); - files.push({ path: 'README.md', content: render(T.README) }); - - // ── Backend (always generated) - files.push({ path: 'backend/package.json', content: render(T.BACKEND_PACKAGE_JSON) }); - files.push({ path: 'backend/tsconfig.json', content: render(T.BACKEND_TSCONFIG) }); - files.push({ path: 'backend/src/lib/config.ts', content: render(T.BACKEND_CONFIG) }); - files.push({ - path: 'backend/src/lib/product-config.ts', - content: render(T.BACKEND_PRODUCT_CONFIG), - }); - files.push({ path: 'backend/src/lib/auth.ts', content: render(T.BACKEND_AUTH) }); - files.push({ - path: 'backend/src/lib/request-context.ts', - content: render(T.BACKEND_REQUEST_CONTEXT), - }); - files.push({ path: 'backend/src/lib/errors.ts', content: render(T.BACKEND_ERRORS) }); - files.push({ path: 'backend/src/lib/datastore.ts', content: render(T.BACKEND_DATASTORE) }); - files.push({ path: 'backend/src/server.ts', content: render(T.BACKEND_SERVER) }); - - // ── Web (if selected) - if (manifest.platforms.includes('web')) { - files.push({ path: 'web/package.json', content: render(T.WEB_PACKAGE_JSON) }); - files.push({ path: 'web/tsconfig.json', content: render(T.WEB_TSCONFIG) }); - files.push({ path: 'web/next.config.ts', content: render(T.WEB_NEXT_CONFIG) }); - files.push({ path: 'web/src/app/layout.tsx', content: render(T.WEB_LAYOUT) }); - files.push({ path: 'web/src/app/page.tsx', content: render(T.WEB_PAGE) }); - files.push({ path: 'web/src/lib/product-config.ts', content: render(T.WEB_PRODUCT_CONFIG) }); - } - - // ── Mobile (if selected) - if (manifest.platforms.includes('mobile')) { - files.push({ path: 'mobile/package.json', content: render(T.MOBILE_PACKAGE_JSON) }); - files.push({ path: 'mobile/app.json', content: render(T.MOBILE_APP_JSON) }); - files.push({ path: 'mobile/src/app/index.tsx', content: render(T.MOBILE_INDEX) }); - } - - return files; -} - -// ── Main ───────────────────────────────────────────────────────────────────── - -async function main(): Promise { - const cliOpts = parseCliArgs(); - - const manifest = cliOpts.from - ? await loadManifestFromFile(cliOpts.from) - : await gatherManifestInteractively(); - - const outDir = cliOpts.outDir || manifest.productId; - const outPath = path.resolve(outDir); - - // eslint-disable-next-line no-console - console.log(`\n🚀 Scaffolding ${manifest.displayName}`); - // eslint-disable-next-line no-console - console.log(` Product ID: ${manifest.productId}`); - // eslint-disable-next-line no-console - console.log(` Output: ${outPath}`); - // eslint-disable-next-line no-console - console.log(` Platforms: ${manifest.platforms.join(', ')}`); - // eslint-disable-next-line no-console - console.log(` Features: ${manifest.features.join(', ')}`); - if (cliOpts.dryRun) { - // eslint-disable-next-line no-console - console.log(' ⚠️ DRY RUN\n'); - } - - const files = generateFiles(manifest); - - if (cliOpts.dryRun) { - // eslint-disable-next-line no-console - console.log(`📄 ${files.length} files would be generated:\n`); - for (const file of files) { - // eslint-disable-next-line no-console - console.log(`── ${file.path} ──────────────────────────────────────`); - // eslint-disable-next-line no-console - console.log(file.content); - } - // eslint-disable-next-line no-console - console.log(`\n✨ Dry run complete. Re-run without --dry-run to write files.`); - return; - } - - // Write files - for (const file of files) { - const fullPath = path.join(outPath, file.path); - await fs.mkdir(path.dirname(fullPath), { recursive: true }); - await fs.writeFile(fullPath, file.content, 'utf-8'); - // eslint-disable-next-line no-console - console.log(` ✅ ${file.path}`); - } - - // eslint-disable-next-line no-console - console.log(`\n✨ ${manifest.displayName} scaffolded at ${outPath}`); - // eslint-disable-next-line no-console - console.log(`\nNext steps:`); - // eslint-disable-next-line no-console - console.log(` cd ${outDir}/backend && npm install && npm run dev`); - if (manifest.platforms.includes('web')) { - // eslint-disable-next-line no-console - console.log(` cd ${outDir}/web && npm install && npm run dev`); - } -} - -// Export for testing -export { generateFiles, renderTemplate, type ProductManifest, type GeneratedFile }; - -main().catch(err => { - // eslint-disable-next-line no-console - console.error('❌ Error:', err instanceof Error ? err.message : String(err)); - process.exit(1); -}); diff --git a/vendor/bytelyst/create-app/tsconfig.json b/vendor/bytelyst/create-app/tsconfig.json deleted file mode 100644 index 81f2cd1..0000000 --- a/vendor/bytelyst/create-app/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src"], - "exclude": ["dist", "src/**/*.test.ts"] -} diff --git a/vendor/bytelyst/create-app/vitest.config.ts b/vendor/bytelyst/create-app/vitest.config.ts deleted file mode 100644 index 19bef51..0000000 --- a/vendor/bytelyst/create-app/vitest.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - test: { - passWithNoTests: true, - pool: 'forks', - }, -}); diff --git a/vendor/bytelyst/dashboard-components/package.json b/vendor/bytelyst/dashboard-components/package.json deleted file mode 100644 index 92002a2..0000000 --- a/vendor/bytelyst/dashboard-components/package.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "name": "@bytelyst/dashboard-components", - "version": "0.1.5", - "description": "Shared React components for ByteLyst dashboards", - "type": "module", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks", - "typecheck": "tsc --noEmit" - }, - "peerDependencies": { - "react": ">=18.0.0", - "react-dom": ">=18.0.0" - }, - "devDependencies": { - "@testing-library/react": "^16.3.2", - "@types/react": "^19.2.14", - "@types/react-dom": "^19.2.3", - "happy-dom": "^18.0.1", - "react": "^19.2.4", - "react-dom": "^19.2.4", - "typescript": "^5.7.3", - "vitest": "^4.0.18" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/dashboard-components/src/EmptyState.tsx b/vendor/bytelyst/dashboard-components/src/EmptyState.tsx deleted file mode 100644 index f3aba1e..0000000 --- a/vendor/bytelyst/dashboard-components/src/EmptyState.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import type { ReactNode } from 'react'; - -export interface EmptyStateProps { - icon?: ReactNode; - title: string; - description: string; - action?: { - label: string; - onClick: () => void; - }; - className?: string; -} - -export function EmptyState({ - icon, - title, - description, - action, - className = '', -}: EmptyStateProps): ReactNode { - return ( -
- {icon && ( -
- {icon} -
- )} -

- {title} -

-

- {description} -

- {action && ( - - )} -
- ); -} diff --git a/vendor/bytelyst/dashboard-components/src/ErrorPage.tsx b/vendor/bytelyst/dashboard-components/src/ErrorPage.tsx deleted file mode 100644 index ad12ef2..0000000 --- a/vendor/bytelyst/dashboard-components/src/ErrorPage.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import type { ReactNode } from 'react'; - -export interface ErrorPageProps { - title?: string; - message?: string; - onRetry?: () => void; - className?: string; -} - -export function ErrorPage({ - title = 'Something went wrong', - message = 'An unexpected error occurred. Please try again.', - onRetry, - className = '', -}: ErrorPageProps): ReactNode { - return ( -
-
- - - -
-

- {title} -

-

- {message} -

- {onRetry && ( - - )} -
- ); -} diff --git a/vendor/bytelyst/dashboard-components/src/LoadingSkeleton.tsx b/vendor/bytelyst/dashboard-components/src/LoadingSkeleton.tsx deleted file mode 100644 index f7e2e91..0000000 --- a/vendor/bytelyst/dashboard-components/src/LoadingSkeleton.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import type { ReactNode } from 'react'; - -export interface LoadingSkeletonProps { - rows?: number; - className?: string; -} - -export function LoadingSkeleton({ rows = 3, className = '' }: LoadingSkeletonProps): ReactNode { - return ( -
- {Array.from({ length: rows }).map((_, i) => ( -
- ))} -
- ); -} diff --git a/vendor/bytelyst/dashboard-components/src/LoadingSpinner.tsx b/vendor/bytelyst/dashboard-components/src/LoadingSpinner.tsx deleted file mode 100644 index 9109b2e..0000000 --- a/vendor/bytelyst/dashboard-components/src/LoadingSpinner.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import type { ReactNode } from 'react'; - -export interface LoadingSpinnerProps { - size?: 'sm' | 'md' | 'lg'; - className?: string; -} - -export function LoadingSpinner({ size = 'md', className = '' }: LoadingSpinnerProps): ReactNode { - const sizeClasses = { - sm: 'w-4 h-4', - md: 'w-8 h-8', - lg: 'w-12 h-12', - }; - - return ( -
- - - - -
- ); -} diff --git a/vendor/bytelyst/dashboard-components/src/NotFoundPage.tsx b/vendor/bytelyst/dashboard-components/src/NotFoundPage.tsx deleted file mode 100644 index ef2247f..0000000 --- a/vendor/bytelyst/dashboard-components/src/NotFoundPage.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import type { ReactNode } from 'react'; - -export interface NotFoundPageProps { - title?: string; - message?: string; - statusCode?: string; - backLabel?: string; - backHref?: string; - onBack?: () => void; - className?: string; -} - -export function NotFoundPage({ - title = 'Page not found', - message = "The page you're looking for doesn't exist or has been moved.", - statusCode = '404', - backLabel = 'Go Back', - backHref, - onBack, - className = '', -}: NotFoundPageProps): ReactNode { - return ( -
-
-
- {statusCode} -
-

- {title} -

-

- {message} -

- {(onBack || backHref) && - (backHref ? ( - - {backLabel} - - ) : ( - - ))} -
-
- ); -} diff --git a/vendor/bytelyst/dashboard-components/src/PageHeader.tsx b/vendor/bytelyst/dashboard-components/src/PageHeader.tsx deleted file mode 100644 index c8e5d1b..0000000 --- a/vendor/bytelyst/dashboard-components/src/PageHeader.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import type { ReactNode } from 'react'; - -export interface Breadcrumb { - label: string; - href?: string; -} - -export interface PageHeaderProps { - title: string; - breadcrumbs?: Breadcrumb[]; - actions?: ReactNode; - className?: string; -} - -export function PageHeader({ - title, - breadcrumbs, - actions, - className = '', -}: PageHeaderProps): ReactNode { - return ( -
-
- {breadcrumbs && breadcrumbs.length > 0 && ( - - )} -

- {title} -

-
- {actions &&
{actions}
} -
- ); -} diff --git a/vendor/bytelyst/dashboard-components/src/components.test.tsx b/vendor/bytelyst/dashboard-components/src/components.test.tsx deleted file mode 100644 index 9f838e7..0000000 --- a/vendor/bytelyst/dashboard-components/src/components.test.tsx +++ /dev/null @@ -1,254 +0,0 @@ -// @vitest-environment happy-dom -import { describe, it, expect, vi } from 'vitest'; -import { render, screen, fireEvent } from '@testing-library/react'; -import { LoadingSpinner } from './LoadingSpinner.js'; -import { LoadingSkeleton } from './LoadingSkeleton.js'; -import { EmptyState } from './EmptyState.js'; -import { PageHeader } from './PageHeader.js'; -import { ErrorPage } from './ErrorPage.js'; -import { NotFoundPage } from './NotFoundPage.js'; - -describe('LoadingSpinner', () => { - it('renders with default size', () => { - render(); - const status = screen.getByRole('status'); - expect(status).toBeDefined(); - expect(status.className).toContain('w-8 h-8'); - }); - - it('renders with small size', () => { - render(); - const status = screen.getByRole('status'); - expect(status.className).toContain('w-4 h-4'); - }); - - it('renders with large size', () => { - render(); - const status = screen.getByRole('status'); - expect(status.className).toContain('w-12 h-12'); - }); - - it('applies custom className', () => { - render(); - const status = screen.getByRole('status'); - expect(status.className).toContain('mt-4'); - }); - - it('renders SVG spinner element', () => { - render(); - const svg = screen.getByRole('status').querySelector('svg'); - expect(svg).toBeDefined(); - expect(svg!.classList.contains('animate-spin')).toBe(true); - }); -}); - -describe('LoadingSkeleton', () => { - it('renders default 3 rows', () => { - render(); - const container = screen.getByRole('status'); - const rows = container.querySelectorAll('.animate-pulse'); - expect(rows.length).toBe(3); - }); - - it('renders custom number of rows', () => { - render(); - const container = screen.getByRole('status'); - const rows = container.querySelectorAll('.animate-pulse'); - expect(rows.length).toBe(5); - }); - - it('applies custom className', () => { - render(); - const container = screen.getByRole('status'); - expect(container.className).toContain('my-8'); - }); - - it('renders pulse-animated skeleton rows', () => { - render(); - const row = screen.getByRole('status').querySelector('.animate-pulse'); - expect(row).toBeDefined(); - expect(row!.classList.contains('rounded')).toBe(true); - }); -}); - -describe('EmptyState', () => { - it('renders title and description', () => { - render(); - expect(screen.getByText('No items')).toBeDefined(); - expect(screen.getByText('Create your first item.')).toBeDefined(); - }); - - it('renders icon when provided', () => { - render( - X} - /> - ); - expect(screen.getByTestId('icon')).toBeDefined(); - }); - - it('does not render icon container when not provided', () => { - const { container } = render(); - const iconWrapper = container.querySelector('.w-16.h-16'); - expect(iconWrapper).toBeNull(); - }); - - it('renders action button and handles click', () => { - const onClick = vi.fn(); - render( - - ); - const button = screen.getByText('Create'); - expect(button).toBeDefined(); - fireEvent.click(button); - expect(onClick).toHaveBeenCalledOnce(); - }); - - it('does not render action button when not provided', () => { - const { container } = render(); - const buttons = container.querySelectorAll('button'); - expect(buttons.length).toBe(0); - }); - - it('renders with theme-aware structure', () => { - const { container } = render(); - const heading = container.querySelector('h3'); - expect(heading).toBeDefined(); - expect(heading!.textContent).toBe('Test'); - const desc = container.querySelector('p'); - expect(desc).toBeDefined(); - expect(desc!.textContent).toBe('Desc'); - }); -}); - -describe('PageHeader', () => { - it('renders title', () => { - render(); - expect(screen.getByText('Dashboard')).toBeDefined(); - }); - - it('renders breadcrumbs', () => { - render( - - ); - expect(screen.getByText('Home')).toBeDefined(); - expect(screen.getByLabelText('Breadcrumb')).toBeDefined(); - }); - - it('renders breadcrumb links with href', () => { - render( - - ); - const link = screen.getByText('Home'); - expect(link.tagName).toBe('A'); - expect(link.getAttribute('href')).toBe('/'); - }); - - it('renders breadcrumb text without href', () => { - render(); - const text = screen.getByText('Current'); - expect(text.tagName).toBe('SPAN'); - }); - - it('renders actions', () => { - render(Action} />); - expect(screen.getByTestId('action-btn')).toBeDefined(); - }); - - it('does not render breadcrumb nav when empty', () => { - const { container } = render(); - const nav = container.querySelector('nav'); - expect(nav).toBeNull(); - }); -}); - -describe('ErrorPage', () => { - it('renders with default props', () => { - render(); - expect(screen.getByText('Something went wrong')).toBeDefined(); - expect(screen.getByText('An unexpected error occurred. Please try again.')).toBeDefined(); - }); - - it('renders custom title and message', () => { - render(); - expect(screen.getByText('Server Error')).toBeDefined(); - expect(screen.getByText('The server is down.')).toBeDefined(); - }); - - it('renders retry button and handles click', () => { - const onRetry = vi.fn(); - render(); - const button = screen.getByText('Try Again'); - expect(button).toBeDefined(); - fireEvent.click(button); - expect(onRetry).toHaveBeenCalledOnce(); - }); - - it('does not render retry button when not provided', () => { - const { container } = render(); - const buttons = container.querySelectorAll('button'); - expect(buttons.length).toBe(0); - }); - - it('renders error icon and semantic structure', () => { - const { container } = render(); - const iconContainer = container.querySelector('.w-16'); - expect(iconContainer).toBeDefined(); - const svg = iconContainer!.querySelector('svg'); - expect(svg).toBeDefined(); - const heading = container.querySelector('h2'); - expect(heading).toBeDefined(); - expect(heading!.textContent).toBe('Something went wrong'); - }); -}); - -describe('NotFoundPage', () => { - it('renders with default props', () => { - render(); - expect(screen.getByText('404')).toBeDefined(); - expect(screen.getByText('Page not found')).toBeDefined(); - }); - - it('renders custom status code', () => { - render(); - expect(screen.getByText('403')).toBeDefined(); - expect(screen.getByText('Forbidden')).toBeDefined(); - }); - - it('renders back button with onClick', () => { - const onBack = vi.fn(); - render(); - const button = screen.getByText('Go Back'); - fireEvent.click(button); - expect(onBack).toHaveBeenCalledOnce(); - }); - - it('renders back link with href', () => { - render(); - const link = screen.getByText('Go Home'); - expect(link.tagName).toBe('A'); - expect(link.getAttribute('href')).toBe('/'); - }); - - it('does not render button when neither onBack nor backHref provided', () => { - const { container } = render(); - const buttons = container.querySelectorAll('button'); - const links = container.querySelectorAll('a'); - expect(buttons.length).toBe(0); - expect(links.length).toBe(0); - }); - - it('custom backLabel is used', () => { - render( {}} backLabel="Return" />); - expect(screen.getByText('Return')).toBeDefined(); - }); -}); diff --git a/vendor/bytelyst/dashboard-components/src/index.ts b/vendor/bytelyst/dashboard-components/src/index.ts deleted file mode 100644 index ca49e77..0000000 --- a/vendor/bytelyst/dashboard-components/src/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * @bytelyst/dashboard-components - * - * Shared React components for ByteLyst dashboards. - * All components are theme-aware — they read CSS custom properties - * (--color-primary, --color-foreground, --color-muted, etc.) - * with sensible fallback defaults. - */ - -export { ErrorPage, type ErrorPageProps } from './ErrorPage.js'; -export { NotFoundPage, type NotFoundPageProps } from './NotFoundPage.js'; -export { LoadingSpinner, type LoadingSpinnerProps } from './LoadingSpinner.js'; -export { LoadingSkeleton, type LoadingSkeletonProps } from './LoadingSkeleton.js'; -export { EmptyState, type EmptyStateProps } from './EmptyState.js'; -export { PageHeader, type PageHeaderProps, type Breadcrumb } from './PageHeader.js'; diff --git a/vendor/bytelyst/dashboard-components/tsconfig.json b/vendor/bytelyst/dashboard-components/tsconfig.json deleted file mode 100644 index b15ef2e..0000000 --- a/vendor/bytelyst/dashboard-components/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "./dist", - "rootDir": "./src", - "jsx": "react-jsx", - "declaration": true, - "declarationMap": true, - "lib": ["ES2022", "DOM", "DOM.Iterable"] - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"] -} diff --git a/vendor/bytelyst/dashboard-components/vitest.config.ts b/vendor/bytelyst/dashboard-components/vitest.config.ts deleted file mode 100644 index e8ecb58..0000000 --- a/vendor/bytelyst/dashboard-components/vitest.config.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - test: { - globals: true, - environment: 'happy-dom', - passWithNoTests: true, - pool: 'forks', - }, -}); diff --git a/vendor/bytelyst/dashboard-shell/package.json b/vendor/bytelyst/dashboard-shell/package.json deleted file mode 100644 index c183f4f..0000000 --- a/vendor/bytelyst/dashboard-shell/package.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "name": "@bytelyst/dashboard-shell", - "version": "0.1.5", - "description": "Configurable Next.js dashboard layout with sidebar, profile, billing, and settings pages", - "type": "module", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks", - "typecheck": "tsc --noEmit" - }, - "peerDependencies": { - "react": ">=18.0.0", - "react-dom": ">=18.0.0" - }, - "devDependencies": { - "@testing-library/react": "^16.3.2", - "@types/react": "^19.2.14", - "@types/react-dom": "^19.2.3", - "happy-dom": "^18.0.1", - "react": "^19.2.4", - "react-dom": "^19.2.4", - "typescript": "^5.7.3", - "vitest": "^4.0.18" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/dashboard-shell/src/BillingPage.tsx b/vendor/bytelyst/dashboard-shell/src/BillingPage.tsx deleted file mode 100644 index b53c128..0000000 --- a/vendor/bytelyst/dashboard-shell/src/BillingPage.tsx +++ /dev/null @@ -1,189 +0,0 @@ -import type { ReactNode } from 'react'; -import type { BillingPageProps } from './types.js'; - -const statusColors: Record = { - active: 'var(--color-success, #16a34a)', - trialing: 'var(--color-warning, #d97706)', - past_due: 'var(--color-destructive, #dc2626)', - canceled: 'var(--color-muted-foreground, #6b7280)', -}; - -export function BillingPage({ - currentPlan = 'Free', - status = 'active', - trialEndsAt, - onManageBilling, - plans = [], -}: BillingPageProps): ReactNode { - return ( -
-

- Billing -

- - {/* Current plan card */} -
-
-
-
- Current Plan -
-
- {currentPlan} -
-
- - {status.replace('_', ' ')} - -
- - {trialEndsAt && ( -
- Trial ends: {trialEndsAt} -
- )} - - {onManageBilling && ( - - )} -
- - {/* Plan comparison */} - {plans.length > 0 && ( -
-

- Available Plans -

-
- {plans.map(plan => ( -
-
{plan.name}
-
- {plan.price} -
-
    - {plan.features.map(f => ( -
  • - ✓ {f} -
  • - ))} -
- {plan.current && ( -
- Current Plan -
- )} -
- ))} -
-
- )} -
- ); -} diff --git a/vendor/bytelyst/dashboard-shell/src/DashboardShell.tsx b/vendor/bytelyst/dashboard-shell/src/DashboardShell.tsx deleted file mode 100644 index d7457da..0000000 --- a/vendor/bytelyst/dashboard-shell/src/DashboardShell.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { useState, type ReactNode } from 'react'; -import type { DashboardShellProps } from './types.js'; -import { Sidebar } from './Sidebar.js'; -import { TopBar } from './TopBar.js'; - -export function DashboardShell({ - productName, - logo, - version, - nav, - pathname: externalPathname, - user, - features = {}, - onSignOut, - onNavigate, - sidebarFooter, - topBarActions, - children, -}: DashboardShellProps): ReactNode { - const [sidebarCollapsed, setSidebarCollapsed] = useState(false); - - // Use external pathname or default to '/' - const pathname = externalPathname ?? '/'; - - return ( -
- {/* Sidebar */} - setSidebarCollapsed(!sidebarCollapsed)} - /> - - {/* Main content area */} -
- {/* Top bar */} - - - {/* Page content */} -
- {children} -
-
-
- ); -} diff --git a/vendor/bytelyst/dashboard-shell/src/ProfilePage.tsx b/vendor/bytelyst/dashboard-shell/src/ProfilePage.tsx deleted file mode 100644 index 570bb31..0000000 --- a/vendor/bytelyst/dashboard-shell/src/ProfilePage.tsx +++ /dev/null @@ -1,180 +0,0 @@ -import { useState, type ReactNode } from 'react'; -import type { ProfilePageProps } from './types.js'; - -export function ProfilePage({ - user, - onUpdateProfile, - isLoading, - error, - success, -}: ProfilePageProps): ReactNode { - const [name, setName] = useState(user.name); - const [email, setEmail] = useState(user.email); - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (onUpdateProfile) onUpdateProfile({ name, email }); - }; - - return ( -
-

- Profile -

- - {error && ( -
- {error} -
- )} - {success && ( -
- {success} -
- )} - - {/* Avatar */} -
-
- {user.avatarUrl ? ( - {user.name} - ) : ( - user.name - .split(' ') - .map(w => w[0]) - .join('') - .toUpperCase() - .slice(0, 2) - )} -
-
-
{user.name}
-
- {user.email} -
- {user.role && ( -
- Role: {user.role} -
- )} -
-
- - {/* Form */} -
-
- - setName(e.target.value)} - required - style={inputStyle} - /> -
-
- - setEmail(e.target.value)} - required - style={inputStyle} - /> -
- - {onUpdateProfile && ( - - )} -
-
- ); -} - -const labelStyle: React.CSSProperties = { - display: 'block', - fontSize: 14, - fontWeight: 500, - marginBottom: 6, - color: 'var(--color-foreground, #111827)', -}; - -const inputStyle: React.CSSProperties = { - width: '100%', - padding: '10px 12px', - borderRadius: 8, - border: '1px solid var(--bl-shell-border, var(--color-border, #e5e7eb))', - fontSize: 14, - background: 'var(--color-surface, #fff)', - color: 'var(--color-foreground, #111827)', - boxSizing: 'border-box', -}; - -function alertStyle(color: string): React.CSSProperties { - return { - padding: '10px 14px', - borderRadius: 8, - marginBottom: 16, - fontSize: 14, - color, - background: `color-mix(in srgb, ${color} 10%, transparent)`, - border: `1px solid color-mix(in srgb, ${color} 30%, transparent)`, - }; -} diff --git a/vendor/bytelyst/dashboard-shell/src/SettingsPage.tsx b/vendor/bytelyst/dashboard-shell/src/SettingsPage.tsx deleted file mode 100644 index 2a0f669..0000000 --- a/vendor/bytelyst/dashboard-shell/src/SettingsPage.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import type { ReactNode } from 'react'; -import type { SettingsPageProps } from './types.js'; - -export function SettingsPage({ productName, sections = [] }: SettingsPageProps): ReactNode { - return ( -
-

- Settings -

- - {sections.length === 0 && ( -
- No settings configured for {productName}. -
- )} - - {sections.map((section, i) => ( -
-

- {section.title} -

- {section.description && ( -

- {section.description} -

- )} -
{section.content}
-
- ))} -
- ); -} diff --git a/vendor/bytelyst/dashboard-shell/src/Sidebar.tsx b/vendor/bytelyst/dashboard-shell/src/Sidebar.tsx deleted file mode 100644 index 4bc7743..0000000 --- a/vendor/bytelyst/dashboard-shell/src/Sidebar.tsx +++ /dev/null @@ -1,236 +0,0 @@ -import type { ReactNode } from 'react'; -import type { SidebarProps, NavItem, NavSection } from './types.js'; - -function isNavSections(nav: NavItem[] | NavSection[]): nav is NavSection[] { - return nav.length > 0 && 'items' in nav[0]; -} - -function NavLink({ - item, - active, - collapsed, - onNavigate, -}: { - item: NavItem; - active: boolean; - collapsed: boolean; - onNavigate?: (href: string) => void; -}): ReactNode { - if (item.hidden) return null; - - const handleClick = (e: React.MouseEvent) => { - if (onNavigate) { - e.preventDefault(); - onNavigate(item.href); - } - }; - - return ( - - {item.icon && ( - - {item.icon} - - )} - {!collapsed && {item.label}} - {!collapsed && item.badge !== undefined && ( - - {item.badge} - - )} - - ); -} - -export function Sidebar({ - productName, - logo, - version, - nav, - pathname, - features = {}, - onNavigate, - footer, - collapsed = false, - onToggleCollapse, -}: SidebarProps): ReactNode { - const sections: NavSection[] = isNavSections(nav) ? nav : [{ items: nav }]; - - const isActive = (href: string) => pathname === href || pathname.startsWith(href + '/'); - - // Add built-in settings nav if enabled and not already present - const hasSettings = features.settings !== false; - const allItems = sections.flatMap(s => s.items); - const settingsExists = allItems.some(i => i.href === '/settings'); - - return ( - - ); -} diff --git a/vendor/bytelyst/dashboard-shell/src/TopBar.tsx b/vendor/bytelyst/dashboard-shell/src/TopBar.tsx deleted file mode 100644 index 9759315..0000000 --- a/vendor/bytelyst/dashboard-shell/src/TopBar.tsx +++ /dev/null @@ -1,244 +0,0 @@ -import { useState, type ReactNode } from 'react'; -import type { TopBarProps } from './types.js'; - -export function TopBar({ - user, - features = {}, - onSignOut, - onNavigate, - actions, - onToggleSidebar, -}: TopBarProps): ReactNode { - const [menuOpen, setMenuOpen] = useState(false); - - const handleNav = (href: string) => { - setMenuOpen(false); - if (onNavigate) onNavigate(href); - }; - - const initials = user - ? user.name - .split(' ') - .map(w => w[0]) - .join('') - .toUpperCase() - .slice(0, 2) - : '?'; - - return ( -
- {/* Left: mobile hamburger */} -
- {onToggleSidebar && ( - - )} -
- - {/* Right: actions + user menu */} -
- {actions} - - {features.notifications && ( - - )} - - {user && ( -
- - - {menuOpen && ( -
-
-
{user.name}
-
- {user.email} -
-
- - {features.profile !== false && ( - { - e.preventDefault(); - handleNav('/profile'); - }} - style={menuItemStyle} - > - Profile - - )} - {features.billing && ( - { - e.preventDefault(); - handleNav('/billing'); - }} - style={menuItemStyle} - > - Billing - - )} - {features.settings !== false && ( - { - e.preventDefault(); - handleNav('/settings'); - }} - style={menuItemStyle} - > - Settings - - )} - - {onSignOut && ( - - )} -
- )} -
- )} -
-
- ); -} - -const menuItemStyle: React.CSSProperties = { - display: 'block', - padding: '10px 16px', - fontSize: 14, - color: 'var(--color-foreground, #111827)', - textDecoration: 'none', - cursor: 'pointer', -}; diff --git a/vendor/bytelyst/dashboard-shell/src/__tests__/dashboard-shell.test.tsx b/vendor/bytelyst/dashboard-shell/src/__tests__/dashboard-shell.test.tsx deleted file mode 100644 index ebce925..0000000 --- a/vendor/bytelyst/dashboard-shell/src/__tests__/dashboard-shell.test.tsx +++ /dev/null @@ -1,377 +0,0 @@ -// @vitest-environment happy-dom -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, fireEvent, cleanup } from '@testing-library/react'; -import { DashboardShell } from '../DashboardShell.js'; -import { Sidebar } from '../Sidebar.js'; -import { TopBar } from '../TopBar.js'; -import { ProfilePage } from '../ProfilePage.js'; -import { BillingPage } from '../BillingPage.js'; -import { SettingsPage } from '../SettingsPage.js'; -import type { NavItem, NavSection, ShellUser } from '../types.js'; - -const NAV: NavItem[] = [ - { href: '/dashboard', label: 'Dashboard', icon: '◈' }, - { href: '/tasks', label: 'Tasks', icon: '✓' }, - { href: '/settings', label: 'Settings', icon: '⚙' }, -]; - -const USER: ShellUser = { - id: 'u1', - name: 'Alice Smith', - email: 'alice@example.com', - role: 'admin', -}; - -// ── DashboardShell ─────────────────────────────────────────────────────────── - -describe('DashboardShell', () => { - beforeEach(() => cleanup()); - - it('renders sidebar, topbar, and content', () => { - render( - -
Page content
-
- ); - expect(screen.getByTestId('bl-dashboard-shell')).toBeDefined(); - expect(screen.getByTestId('bl-shell-sidebar')).toBeDefined(); - expect(screen.getByTestId('bl-shell-topbar')).toBeDefined(); - expect(screen.getByTestId('bl-shell-main')).toBeDefined(); - expect(screen.getByText('Page content')).toBeDefined(); - }); - - it('passes product name to sidebar', () => { - render( - -
- - ); - expect(screen.getByTestId('bl-shell-product-name').textContent).toBe('MyProduct'); - }); - - it('passes user to topbar', () => { - render( - -
- - ); - expect(screen.getByText('Alice Smith')).toBeDefined(); - }); - - it('calls onSignOut when sign out clicked', () => { - const onSignOut = vi.fn(); - render( - -
- - ); - // Open user menu - fireEvent.click(screen.getByTestId('bl-shell-user-menu-trigger')); - fireEvent.click(screen.getByTestId('bl-shell-menu-signout')); - expect(onSignOut).toHaveBeenCalledOnce(); - }); - - it('toggles sidebar collapse', () => { - render( - -
- - ); - const toggle = screen.getByTestId('bl-shell-collapse-toggle'); - // Initially expanded — product name shows full text - expect(screen.getByTestId('bl-shell-product-name').textContent).toBe('TestApp'); - fireEvent.click(toggle); - // Collapsed — shows first letter - expect(screen.getByTestId('bl-shell-product-name').textContent).toBe('T'); - }); -}); - -// ── Sidebar ────────────────────────────────────────────────────────────────── - -describe('Sidebar', () => { - beforeEach(() => cleanup()); - - it('renders nav items', () => { - render(); - expect(screen.getByText('Dashboard')).toBeDefined(); - expect(screen.getByText('Tasks')).toBeDefined(); - }); - - it('highlights active nav item', () => { - render(); - const dashLink = screen.getByTestId('bl-nav-dashboard'); - expect(dashLink.style.fontWeight).toBe('600'); - }); - - it('calls onNavigate when item clicked', () => { - const onNavigate = vi.fn(); - render(); - fireEvent.click(screen.getByTestId('bl-nav-tasks')); - expect(onNavigate).toHaveBeenCalledWith('/tasks'); - }); - - it('supports NavSection format', () => { - const sections: NavSection[] = [ - { title: 'Main', items: [{ href: '/home', label: 'Home' }] }, - { title: 'Admin', items: [{ href: '/admin', label: 'Admin Panel' }] }, - ]; - render(); - expect(screen.getByText('Main')).toBeDefined(); - expect(screen.getByText('Admin')).toBeDefined(); - expect(screen.getByText('Home')).toBeDefined(); - expect(screen.getByText('Admin Panel')).toBeDefined(); - }); - - it('hides hidden nav items', () => { - const items: NavItem[] = [ - { href: '/visible', label: 'Visible' }, - { href: '/hidden', label: 'Hidden', hidden: true }, - ]; - render(); - expect(screen.getByText('Visible')).toBeDefined(); - expect(screen.queryByText('Hidden')).toBeNull(); - }); - - it('shows badge on nav item', () => { - const items: NavItem[] = [{ href: '/inbox', label: 'Inbox', badge: 5 }]; - render(); - expect(screen.getByText('5')).toBeDefined(); - }); - - it('renders version in footer', () => { - render(); - expect(screen.getByTestId('bl-shell-sidebar-footer').textContent).toContain('v1.2.3'); - }); - - it('renders custom footer', () => { - render(Custom} />); - expect(screen.getByText('Custom')).toBeDefined(); - }); - - it('renders logo instead of product name', () => { - render( - Logo} - /> - ); - expect(screen.getByTestId('logo')).toBeDefined(); - }); - - it('auto-adds settings link when not present', () => { - const items: NavItem[] = [{ href: '/dashboard', label: 'Dashboard' }]; - render( - - ); - expect(screen.getByTestId('bl-nav-settings')).toBeDefined(); - }); - - it('does not duplicate settings link when already present', () => { - render(); - const settingsLinks = screen.getAllByText('Settings'); - expect(settingsLinks.length).toBe(1); - }); -}); - -// ── TopBar ─────────────────────────────────────────────────────────────────── - -describe('TopBar', () => { - beforeEach(() => cleanup()); - - it('renders user name', () => { - render(); - expect(screen.getByText('Alice Smith')).toBeDefined(); - }); - - it('renders initials avatar when no avatarUrl', () => { - render(); - expect(screen.getByTestId('bl-shell-user-avatar').textContent).toBe('AS'); - }); - - it('opens and closes user menu', () => { - render(); - expect(screen.queryByTestId('bl-shell-user-menu')).toBeNull(); - fireEvent.click(screen.getByTestId('bl-shell-user-menu-trigger')); - expect(screen.getByTestId('bl-shell-user-menu')).toBeDefined(); - fireEvent.click(screen.getByTestId('bl-shell-user-menu-trigger')); - expect(screen.queryByTestId('bl-shell-user-menu')).toBeNull(); - }); - - it('shows profile link in menu by default', () => { - render(); - fireEvent.click(screen.getByTestId('bl-shell-user-menu-trigger')); - expect(screen.getByTestId('bl-shell-menu-profile')).toBeDefined(); - }); - - it('shows billing link when feature enabled', () => { - render(); - fireEvent.click(screen.getByTestId('bl-shell-user-menu-trigger')); - expect(screen.getByTestId('bl-shell-menu-billing')).toBeDefined(); - }); - - it('hides billing link when feature disabled', () => { - render(); - fireEvent.click(screen.getByTestId('bl-shell-user-menu-trigger')); - expect(screen.queryByTestId('bl-shell-menu-billing')).toBeNull(); - }); - - it('shows notifications bell when enabled', () => { - render(); - expect(screen.getByTestId('bl-shell-notifications')).toBeDefined(); - }); - - it('calls onSignOut', () => { - const onSignOut = vi.fn(); - render(); - fireEvent.click(screen.getByTestId('bl-shell-user-menu-trigger')); - fireEvent.click(screen.getByTestId('bl-shell-menu-signout')); - expect(onSignOut).toHaveBeenCalledOnce(); - }); - - it('calls onNavigate for menu items', () => { - const onNavigate = vi.fn(); - render(); - fireEvent.click(screen.getByTestId('bl-shell-user-menu-trigger')); - fireEvent.click(screen.getByTestId('bl-shell-menu-profile')); - expect(onNavigate).toHaveBeenCalledWith('/profile'); - }); - - it('renders custom actions', () => { - render(Action} />); - expect(screen.getByTestId('custom-action')).toBeDefined(); - }); - - it('shows hamburger when onToggleSidebar provided', () => { - const toggle = vi.fn(); - render(); - fireEvent.click(screen.getByTestId('bl-shell-hamburger')); - expect(toggle).toHaveBeenCalledOnce(); - }); -}); - -// ── ProfilePage ────────────────────────────────────────────────────────────── - -describe('ProfilePage', () => { - beforeEach(() => cleanup()); - - it('renders user info', () => { - render(); - expect(screen.getByTestId('bl-shell-profile-page')).toBeDefined(); - expect(screen.getByTestId('bl-profile-avatar')).toBeDefined(); - }); - - it('pre-fills form fields', () => { - render(); - const nameInput = screen.getByTestId('bl-profile-name') as unknown as { value: string }; - const emailInput = screen.getByTestId('bl-profile-email') as unknown as { value: string }; - expect(nameInput.value).toBe('Alice Smith'); - expect(emailInput.value).toBe('alice@example.com'); - }); - - it('calls onUpdateProfile with form data', () => { - const onUpdate = vi.fn(); - render(); - fireEvent.change(screen.getByTestId('bl-profile-name'), { target: { value: 'Bob Jones' } }); - fireEvent.submit(screen.getByTestId('bl-profile-submit').closest('form')!); - expect(onUpdate).toHaveBeenCalledWith({ name: 'Bob Jones', email: 'alice@example.com' }); - }); - - it('shows loading state', () => { - render(); - expect(screen.getByText('Saving...')).toBeDefined(); - }); - - it('shows error and success messages', () => { - const { rerender } = render(); - expect(screen.getByTestId('bl-profile-error')).toBeDefined(); - - rerender(); - expect(screen.getByTestId('bl-profile-success')).toBeDefined(); - }); - - it('shows role when present', () => { - render(); - expect(screen.getByText('Role: admin')).toBeDefined(); - }); -}); - -// ── BillingPage ────────────────────────────────────────────────────────────── - -describe('BillingPage', () => { - beforeEach(() => cleanup()); - - it('renders current plan', () => { - render(); - expect(screen.getByTestId('bl-shell-billing-page')).toBeDefined(); - expect(screen.getByText('Pro')).toBeDefined(); - }); - - it('shows status badge', () => { - render(); - expect(screen.getByTestId('bl-billing-status').textContent).toBe('trialing'); - }); - - it('shows trial end date', () => { - render(); - expect(screen.getByTestId('bl-billing-trial').textContent).toContain('2026-04-01'); - }); - - it('calls onManageBilling', () => { - const onManage = vi.fn(); - render(); - fireEvent.click(screen.getByTestId('bl-billing-manage')); - expect(onManage).toHaveBeenCalledOnce(); - }); - - it('renders plan comparison grid', () => { - const plans = [ - { name: 'Free', price: '$0/mo', features: ['Basic features'], current: true }, - { name: 'Pro', price: '$9/mo', features: ['All features', 'Priority support'] }, - ]; - render(); - expect(screen.getByTestId('bl-billing-plans')).toBeDefined(); - expect(screen.getByTestId('bl-billing-plan-free')).toBeDefined(); - expect(screen.getByTestId('bl-billing-plan-pro')).toBeDefined(); - expect(screen.getByText('$9/mo')).toBeDefined(); - }); - - it('defaults to Free plan when not specified', () => { - render(); - expect(screen.getByText('Free')).toBeDefined(); - }); -}); - -// ── SettingsPage ───────────────────────────────────────────────────────────── - -describe('SettingsPage', () => { - beforeEach(() => cleanup()); - - it('renders empty state when no sections', () => { - render(); - expect(screen.getByTestId('bl-shell-settings-page')).toBeDefined(); - expect(screen.getByTestId('bl-settings-empty')).toBeDefined(); - expect(screen.getByText('No settings configured for TestApp.')).toBeDefined(); - }); - - it('renders sections', () => { - const sections = [ - { title: 'Notifications', description: 'Manage alerts', content:
Toggle
}, - { title: 'Theme', content:
Dark mode
}, - ]; - render(); - expect(screen.getByTestId('bl-settings-section-0')).toBeDefined(); - expect(screen.getByTestId('bl-settings-section-1')).toBeDefined(); - expect(screen.getByText('Notifications')).toBeDefined(); - expect(screen.getByText('Manage alerts')).toBeDefined(); - expect(screen.getByText('Toggle')).toBeDefined(); - expect(screen.getByText('Dark mode')).toBeDefined(); - }); -}); diff --git a/vendor/bytelyst/dashboard-shell/src/index.ts b/vendor/bytelyst/dashboard-shell/src/index.ts deleted file mode 100644 index 70bef60..0000000 --- a/vendor/bytelyst/dashboard-shell/src/index.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * @bytelyst/dashboard-shell - * - * Configurable Next.js dashboard layout with sidebar, top bar, - * and built-in pages for profile, billing, and settings. - * - * All components read CSS custom properties (--bl-shell-*, --color-*) - * with sensible fallback defaults. - */ - -export { DashboardShell } from './DashboardShell.js'; -export { Sidebar } from './Sidebar.js'; -export { TopBar } from './TopBar.js'; -export { ProfilePage } from './ProfilePage.js'; -export { BillingPage } from './BillingPage.js'; -export { SettingsPage } from './SettingsPage.js'; - -export type { - DashboardShellProps, - SidebarProps, - TopBarProps, - NavItem, - NavSection, - ShellUser, - ShellFeatures, - ProfilePageProps, - BillingPageProps, - SettingsPageProps, - SettingsSection, -} from './types.js'; diff --git a/vendor/bytelyst/dashboard-shell/src/types.ts b/vendor/bytelyst/dashboard-shell/src/types.ts deleted file mode 100644 index 14a3e98..0000000 --- a/vendor/bytelyst/dashboard-shell/src/types.ts +++ /dev/null @@ -1,131 +0,0 @@ -import type { ReactNode } from 'react'; - -// ── Navigation ─────────────────────────────────────────────────────────────── - -export interface NavItem { - href: string; - label: string; - icon?: ReactNode; - badge?: string | number; - /** Hide this item from nav (useful for feature-flag gating) */ - hidden?: boolean; -} - -export interface NavSection { - title?: string; - items: NavItem[]; -} - -// ── User ───────────────────────────────────────────────────────────────────── - -export interface ShellUser { - id: string; - name: string; - email: string; - avatarUrl?: string; - role?: string; -} - -// ── Features ───────────────────────────────────────────────────────────────── - -export interface ShellFeatures { - /** Show profile page link in user menu (default: true) */ - profile?: boolean; - /** Show billing page link in user menu (default: false) */ - billing?: boolean; - /** Show settings page link in sidebar (default: true) */ - settings?: boolean; - /** Show notifications icon in top bar (default: false) */ - notifications?: boolean; - /** Show dark/light theme toggle (default: false) */ - themeToggle?: boolean; -} - -// ── Shell Config ───────────────────────────────────────────────────────────── - -export interface DashboardShellProps { - /** Product display name shown in sidebar header */ - productName: string; - /** Product logo element (replaces text name if provided) */ - logo?: ReactNode; - /** Product version shown in sidebar footer */ - version?: string; - /** Navigation items or sections */ - nav: NavItem[] | NavSection[]; - /** Current pathname for active state (if not using internal detection) */ - pathname?: string; - /** Currently logged-in user */ - user?: ShellUser; - /** Feature toggles for built-in pages */ - features?: ShellFeatures; - /** Called when user clicks Sign Out */ - onSignOut?: () => void; - /** Called when a nav item is clicked (for SPA routers) */ - onNavigate?: (href: string) => void; - /** Sidebar footer content (replaces default) */ - sidebarFooter?: ReactNode; - /** Content to render in the top bar (right side) */ - topBarActions?: ReactNode; - /** Dashboard page content */ - children: ReactNode; -} - -// ── Sidebar Props ──────────────────────────────────────────────────────────── - -export interface SidebarProps { - productName: string; - logo?: ReactNode; - version?: string; - nav: NavItem[] | NavSection[]; - pathname: string; - features?: ShellFeatures; - onNavigate?: (href: string) => void; - footer?: ReactNode; - collapsed?: boolean; - onToggleCollapse?: () => void; -} - -// ── Top Bar Props ──────────────────────────────────────────────────────────── - -export interface TopBarProps { - user?: ShellUser; - features?: ShellFeatures; - onSignOut?: () => void; - onNavigate?: (href: string) => void; - actions?: ReactNode; - onToggleSidebar?: () => void; -} - -// ── Built-in Page Props ────────────────────────────────────────────────────── - -export interface ProfilePageProps { - user: ShellUser; - onUpdateProfile?: (data: { name: string; email: string }) => void; - isLoading?: boolean; - error?: string; - success?: string; -} - -export interface BillingPageProps { - currentPlan?: string; - status?: 'active' | 'trialing' | 'past_due' | 'canceled'; - trialEndsAt?: string; - onManageBilling?: () => void; - plans?: Array<{ - name: string; - price: string; - features: string[]; - current?: boolean; - }>; -} - -export interface SettingsPageProps { - productName: string; - sections?: SettingsSection[]; -} - -export interface SettingsSection { - title: string; - description?: string; - content: ReactNode; -} diff --git a/vendor/bytelyst/dashboard-shell/tsconfig.json b/vendor/bytelyst/dashboard-shell/tsconfig.json deleted file mode 100644 index 3128359..0000000 --- a/vendor/bytelyst/dashboard-shell/tsconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src", - "jsx": "react-jsx", - "lib": ["ES2022", "DOM", "DOM.Iterable"] - }, - "include": ["src"], - "exclude": ["dist", "src/**/*.test.*"] -} diff --git a/vendor/bytelyst/dashboard-shell/vitest.config.ts b/vendor/bytelyst/dashboard-shell/vitest.config.ts deleted file mode 100644 index cf32686..0000000 --- a/vendor/bytelyst/dashboard-shell/vitest.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - test: { - environment: 'happy-dom', - pool: 'forks', - }, -}); diff --git a/vendor/bytelyst/datastore/package.json b/vendor/bytelyst/datastore/package.json deleted file mode 100644 index ed43c01..0000000 --- a/vendor/bytelyst/datastore/package.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "name": "@bytelyst/datastore", - "version": "0.1.5", - "type": "module", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - }, - "./testing": { - "import": "./dist/testing.js", - "types": "./dist/testing.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks" - }, - "peerDependencies": { - "@azure/cosmos": ">=4.0.0" - }, - "peerDependenciesMeta": { - "@azure/cosmos": { - "optional": true - } - }, - "devDependencies": { - "vitest": "^3.0.0" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/datastore/src/__tests__/memory.test.ts b/vendor/bytelyst/datastore/src/__tests__/memory.test.ts deleted file mode 100644 index 9652d2f..0000000 --- a/vendor/bytelyst/datastore/src/__tests__/memory.test.ts +++ /dev/null @@ -1,285 +0,0 @@ -/** - * Tests for MemoryDatastoreProvider and filter evaluation. - */ - -import { describe, it, expect, beforeEach } from 'vitest'; -import { MemoryDatastoreProvider } from '../providers/memory.js'; -import { matchesFilter, filterToCosmosSQL } from '../filter.js'; -import type { BaseDocument, DocumentCollection } from '../types.js'; - -interface TestDoc extends BaseDocument { - name: string; - price?: number; - tags?: string[]; - createdAt?: string; -} - -describe('MemoryDatastoreProvider', () => { - let provider: MemoryDatastoreProvider; - let collection: DocumentCollection; - - beforeEach(() => { - provider = new MemoryDatastoreProvider(); - collection = provider.getCollection('test', '/productId'); - }); - - it('isHealthy returns true', async () => { - expect(await provider.isHealthy()).toBe(true); - }); - - it('returns same collection instance for same name', () => { - const c1 = provider.getCollection('test', '/productId'); - const c2 = provider.getCollection('test', '/productId'); - expect(c1).toBe(c2); - }); - - describe('CRUD operations', () => { - const doc: TestDoc = { id: '1', productId: 'test', name: 'Widget', price: 10 }; - - it('create + findById', async () => { - const created = await collection.create(doc); - expect(created).toEqual(doc); - const found = await collection.findById('1', 'test'); - expect(found).toEqual(doc); - }); - - it('create throws on duplicate', async () => { - await collection.create(doc); - await expect(collection.create(doc)).rejects.toThrow('already exists'); - }); - - it('findById returns null for missing', async () => { - expect(await collection.findById('missing', 'test')).toBeNull(); - }); - - it('update merges fields', async () => { - await collection.create(doc); - const updated = await collection.update('1', 'test', { price: 20 }); - expect(updated.price).toBe(20); - expect(updated.name).toBe('Widget'); - }); - - it('update throws for missing doc', async () => { - await expect(collection.update('missing', 'test', { price: 20 })).rejects.toThrow( - 'not found' - ); - }); - - it('upsert creates or replaces', async () => { - const created = await collection.upsert(doc); - expect(created).toEqual(doc); - const replaced = await collection.upsert({ ...doc, name: 'Gadget' }); - expect(replaced.name).toBe('Gadget'); - }); - - it('delete removes doc', async () => { - await collection.create(doc); - await collection.delete('1', 'test'); - expect(await collection.findById('1', 'test')).toBeNull(); - }); - }); - - describe('findMany', () => { - const docs: TestDoc[] = [ - { id: '1', productId: 'p1', name: 'Alpha', price: 30, createdAt: '2026-01-01' }, - { id: '2', productId: 'p1', name: 'Beta', price: 10, createdAt: '2026-01-02' }, - { id: '3', productId: 'p1', name: 'Gamma', price: 20, createdAt: '2026-01-03' }, - { id: '4', productId: 'p2', name: 'Delta', price: 15, createdAt: '2026-01-04' }, - ]; - - beforeEach(async () => { - for (const d of docs) await collection.create(d); - }); - - it('returns all without query', async () => { - const results = await collection.findMany(); - expect(results).toHaveLength(4); - }); - - it('filters by exact match', async () => { - const results = await collection.findMany({ filter: { productId: 'p1' } }); - expect(results).toHaveLength(3); - }); - - it('filters with $gt', async () => { - const results = await collection.findMany({ filter: { price: { $gt: 15 } } }); - expect(results).toHaveLength(2); - }); - - it('filters with $in', async () => { - const results = await collection.findMany({ filter: { name: { $in: ['Alpha', 'Gamma'] } } }); - expect(results).toHaveLength(2); - }); - - it('sorts ascending', async () => { - const results = await collection.findMany({ sort: { price: 1 } }); - expect(results.map(d => d.price)).toEqual([10, 15, 20, 30]); - }); - - it('sorts descending', async () => { - const results = await collection.findMany({ sort: { price: -1 } }); - expect(results.map(d => d.price)).toEqual([30, 20, 15, 10]); - }); - - it('limits results', async () => { - const results = await collection.findMany({ limit: 2 }); - expect(results).toHaveLength(2); - }); - - it('offsets + limits', async () => { - const all = await collection.findMany({ sort: { price: 1 } }); - const page2 = await collection.findMany({ sort: { price: 1 }, offset: 2, limit: 2 }); - expect(page2).toEqual(all.slice(2, 4)); - }); - - it('selects specific fields', async () => { - const results = await collection.findMany({ select: ['id', 'name'] }); - expect(results[0]).toHaveProperty('id'); - expect(results[0]).toHaveProperty('name'); - expect(results[0]).not.toHaveProperty('price'); - }); - }); - - describe('findOne', () => { - it('returns first match', async () => { - await collection.create({ id: '1', productId: 'p', name: 'A' }); - await collection.create({ id: '2', productId: 'p', name: 'B' }); - const result = await collection.findOne({ filter: { productId: 'p' } }); - expect(result).not.toBeNull(); - }); - - it('returns null when no match', async () => { - const result = await collection.findOne({ filter: { productId: 'missing' } }); - expect(result).toBeNull(); - }); - }); - - describe('count', () => { - it('counts all without filter', async () => { - await collection.create({ id: '1', productId: 'p', name: 'A' }); - await collection.create({ id: '2', productId: 'p', name: 'B' }); - expect(await collection.count()).toBe(2); - }); - - it('counts with filter', async () => { - await collection.create({ id: '1', productId: 'p1', name: 'A' }); - await collection.create({ id: '2', productId: 'p2', name: 'B' }); - expect(await collection.count({ productId: 'p1' })).toBe(1); - }); - }); - - describe('aggregate', () => { - it('groups and aggregates', async () => { - await collection.create({ id: '1', productId: 'p1', name: 'A', price: 10 }); - await collection.create({ id: '2', productId: 'p1', name: 'B', price: 20 }); - await collection.create({ id: '3', productId: 'p2', name: 'C', price: 30 }); - - const results = await collection.aggregate<{ productId: string; total: number; cnt: number }>( - { - groupBy: 'productId', - aggregations: [ - { field: 'price', op: 'sum', alias: 'total' }, - { field: 'price', op: 'count', alias: 'cnt' }, - ], - } - ); - - expect(results).toHaveLength(2); - const p1 = results.find(r => r.productId === 'p1'); - expect(p1?.total).toBe(30); - expect(p1?.cnt).toBe(2); - }); - }); - - describe('rawQuery', () => { - it('throws for memory provider', async () => { - await expect(collection.rawQuery('SELECT * FROM c')).rejects.toThrow('not supported'); - }); - }); -}); - -describe('matchesFilter', () => { - const doc = { id: '1', productId: 'p', name: 'test', price: 25, tags: ['a', 'b'] }; - - it('exact match', () => { - expect(matchesFilter(doc, { name: 'test' })).toBe(true); - expect(matchesFilter(doc, { name: 'other' })).toBe(false); - }); - - it('$ne', () => { - expect(matchesFilter(doc, { price: { $ne: 30 } })).toBe(true); - expect(matchesFilter(doc, { price: { $ne: 25 } })).toBe(false); - }); - - it('$exists', () => { - expect(matchesFilter(doc, { price: { $exists: true } })).toBe(true); - expect(matchesFilter(doc, { missing: { $exists: false } })).toBe(true); - expect(matchesFilter(doc, { price: { $exists: false } })).toBe(false); - }); - - it('$startsWith', () => { - expect(matchesFilter(doc, { name: { $startsWith: 'te' } })).toBe(true); - expect(matchesFilter(doc, { name: { $startsWith: 'no' } })).toBe(false); - }); - - it('$contains on string', () => { - expect(matchesFilter(doc, { name: { $contains: 'es' } })).toBe(true); - }); - - it('$contains on array', () => { - expect(matchesFilter(doc, { tags: { $contains: 'a' } })).toBe(true); - expect(matchesFilter(doc, { tags: { $contains: 'z' } })).toBe(false); - }); - - it('$or', () => { - expect(matchesFilter(doc, { $or: [{ name: 'test' }, { name: 'other' }] })).toBe(true); - expect(matchesFilter(doc, { $or: [{ name: 'x' }, { name: 'y' }] })).toBe(false); - }); - - it('combined conditions', () => { - expect(matchesFilter(doc, { productId: 'p', price: { $gte: 20, $lt: 30 } })).toBe(true); - expect(matchesFilter(doc, { productId: 'p', price: { $gte: 30 } })).toBe(false); - }); -}); - -describe('filterToCosmosSQL', () => { - it('exact match', () => { - const result = filterToCosmosSQL({ name: 'test' }); - expect(result.query).toBe('WHERE c.name = @p0'); - expect(result.parameters).toEqual([{ name: '@p0', value: 'test' }]); - }); - - it('comparison operators', () => { - const result = filterToCosmosSQL({ price: { $gte: 10, $lt: 50 } }); - expect(result.query).toContain('c.price >= @p0'); - expect(result.query).toContain('c.price < @p1'); - }); - - it('$exists', () => { - const result = filterToCosmosSQL({ deleted: { $exists: false } }); - expect(result.query).toContain('NOT IS_DEFINED(c.deleted)'); - }); - - it('$in', () => { - const result = filterToCosmosSQL({ status: { $in: ['active', 'pending'] } }); - expect(result.query).toContain('c.status IN'); - expect(result.parameters).toHaveLength(2); - }); - - it('$contains emits ARRAY_CONTAINS OR CONTAINS', () => { - const result = filterToCosmosSQL({ tags: { $contains: 'urgent' } }); - expect(result.query).toContain('ARRAY_CONTAINS(c.tags, @p0)'); - expect(result.query).toContain('CONTAINS(c.tags, @p0)'); - expect(result.parameters).toEqual([{ name: '@p0', value: 'urgent' }]); - }); - - it('$or', () => { - const result = filterToCosmosSQL({ $or: [{ a: 1 }, { b: 2 }] }); - expect(result.query).toContain('OR'); - }); - - it('empty filter returns empty WHERE', () => { - const result = filterToCosmosSQL({}); - expect(result.query).toBe(''); - }); -}); diff --git a/vendor/bytelyst/datastore/src/factory.ts b/vendor/bytelyst/datastore/src/factory.ts deleted file mode 100644 index 3aa0ff6..0000000 --- a/vendor/bytelyst/datastore/src/factory.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * Datastore provider factory. - * - * Creates a DatastoreProvider based on DB_PROVIDER env var or explicit type. - * Defaults to 'cosmos' for backward compatibility. - */ - -import { MemoryDatastoreProvider } from './providers/memory.js'; -import type { DatastoreProvider, DatastoreProviderType } from './types.js'; - -let _provider: DatastoreProvider | null = null; - -/** - * Get the singleton datastore provider. - * Lazily creates on first call based on DB_PROVIDER env var. - * - * - 'cosmos' (default) — Azure Cosmos DB - * - 'memory' — In-memory (for testing) - */ -export async function getDatastore(): Promise { - if (!_provider) { - const providerType = (process.env.DB_PROVIDER || 'cosmos') as DatastoreProviderType; - _provider = await createDatastoreProvider(providerType); - } - return _provider; -} - -/** - * Create a datastore provider by type. - */ -export async function createDatastoreProvider( - type: DatastoreProviderType -): Promise { - switch (type) { - case 'cosmos': { - const { CosmosDatastoreProvider } = await import('./providers/cosmos.js'); - return new CosmosDatastoreProvider(); - } - case 'memory': - return new MemoryDatastoreProvider(); - default: - throw new Error(`Unknown DB_PROVIDER: '${type}'. Valid: cosmos, memory`); - } -} - -/** - * Set the singleton datastore provider directly (for testing or manual wiring). - */ -export function setDatastore(provider: DatastoreProvider): void { - _provider = provider; -} - -/** - * Reset the singleton (for testing). - * @internal - */ -export function _resetDatastore(): void { - _provider = null; -} diff --git a/vendor/bytelyst/datastore/src/filter.ts b/vendor/bytelyst/datastore/src/filter.ts deleted file mode 100644 index 6ac78ae..0000000 --- a/vendor/bytelyst/datastore/src/filter.ts +++ /dev/null @@ -1,178 +0,0 @@ -/** - * FilterMap evaluation utilities. - * - * Used by memory provider for in-memory filtering and - * by cosmos provider for SQL query generation. - */ - -import type { FilterMap, FilterOperator, FilterValue } from './types.js'; - -/** - * Evaluate a FilterMap against a document (in-memory). - * Returns true if the document matches all filter conditions. - */ -export function matchesFilter(doc: Record, filter: FilterMap): boolean { - for (const [key, condition] of Object.entries(filter)) { - if (key === '$or') { - const orClauses = condition as FilterMap[]; - if (!orClauses.some(clause => matchesFilter(doc, clause))) return false; - continue; - } - if (condition === undefined) continue; - - const value = getNestedValue(doc, key); - - if (isFilterOperator(condition)) { - if (!matchesOperator(value, condition as FilterOperator)) return false; - } else { - // Exact match - if (value !== condition) return false; - } - } - return true; -} - -function getNestedValue(obj: Record, path: string): unknown { - const parts = path.split('.'); - let current: unknown = obj; - for (const part of parts) { - if (current == null || typeof current !== 'object') return undefined; - current = (current as Record)[part]; - } - return current; -} - -function isFilterOperator(value: unknown): value is FilterOperator { - if (value === null || typeof value !== 'object' || Array.isArray(value)) return false; - const keys = Object.keys(value); - return keys.some(k => k.startsWith('$')); -} - -function matchesOperator(docValue: unknown, op: FilterOperator): boolean { - if (op.$gt !== undefined && !(compare(docValue, op.$gt) > 0)) return false; - if (op.$gte !== undefined && !(compare(docValue, op.$gte) >= 0)) return false; - if (op.$lt !== undefined && !(compare(docValue, op.$lt) < 0)) return false; - if (op.$lte !== undefined && !(compare(docValue, op.$lte) <= 0)) return false; - if (op.$ne !== undefined && docValue === op.$ne) return false; - - if (op.$exists !== undefined) { - const exists = docValue !== undefined && docValue !== null; - if (op.$exists !== exists) return false; - } - - if (op.$startsWith !== undefined) { - if (typeof docValue !== 'string') return false; - if (!docValue.startsWith(op.$startsWith)) return false; - } - - if (op.$contains !== undefined) { - if (Array.isArray(docValue)) { - if (!docValue.includes(op.$contains)) return false; - } else if (typeof docValue === 'string' && typeof op.$contains === 'string') { - if (!docValue.includes(op.$contains)) return false; - } else { - return false; - } - } - - if (op.$in !== undefined) { - if (!op.$in.includes(docValue as FilterValue)) return false; - } - - return true; -} - -function compare(a: unknown, b: unknown): number { - if (a === b) return 0; - if (a == null) return -1; - if (b == null) return 1; - if (typeof a === 'number' && typeof b === 'number') return a - b; - if (typeof a === 'string' && typeof b === 'string') return a.localeCompare(b); - if (typeof a === 'boolean' && typeof b === 'boolean') return (a ? 1 : 0) - (b ? 1 : 0); - return String(a).localeCompare(String(b)); -} - -// ── Cosmos SQL generation ─────────────────────────────────────────────────── - -export interface SqlQuery { - query: string; - parameters: Array<{ name: string; value: unknown }>; -} - -/** - * Convert a FilterMap to a Cosmos SQL WHERE clause. - */ -export function filterToCosmosSQL(filter: FilterMap): SqlQuery { - const parameters: Array<{ name: string; value: unknown }> = []; - let paramIndex = 0; - - function nextParam(value: unknown): string { - const name = `@p${paramIndex++}`; - parameters.push({ name, value }); - return name; - } - - function buildCondition(key: string, condition: unknown): string { - if (condition === null) { - return `c.${key} = null`; - } - - if (!isFilterOperator(condition)) { - return `c.${key} = ${nextParam(condition)}`; - } - - const op = condition as FilterOperator; - const parts: string[] = []; - - if (op.$gt !== undefined) parts.push(`c.${key} > ${nextParam(op.$gt)}`); - if (op.$gte !== undefined) parts.push(`c.${key} >= ${nextParam(op.$gte)}`); - if (op.$lt !== undefined) parts.push(`c.${key} < ${nextParam(op.$lt)}`); - if (op.$lte !== undefined) parts.push(`c.${key} <= ${nextParam(op.$lte)}`); - if (op.$ne !== undefined) parts.push(`c.${key} != ${nextParam(op.$ne)}`); - - if (op.$exists !== undefined) { - parts.push(op.$exists ? `IS_DEFINED(c.${key})` : `NOT IS_DEFINED(c.${key})`); - } - - if (op.$startsWith !== undefined) { - parts.push(`STARTSWITH(c.${key}, ${nextParam(op.$startsWith)})`); - } - - if (op.$contains !== undefined) { - // Could be array or string — emit both ARRAY_CONTAINS and CONTAINS. - // Cosmos returns false (not error) when the function doesn't match the field type, - // so OR-ing them handles both array membership and string substring correctly. - const param = nextParam(op.$contains); - parts.push(`(ARRAY_CONTAINS(c.${key}, ${param}) OR CONTAINS(c.${key}, ${param}))`); - } - - if (op.$in !== undefined) { - const placeholders = op.$in.map(v => nextParam(v)); - parts.push(`c.${key} IN (${placeholders.join(', ')})`); - } - - return parts.join(' AND '); - } - - function buildFilter(f: FilterMap): string { - const clauses: string[] = []; - - for (const [key, condition] of Object.entries(f)) { - if (key === '$or') { - const orClauses = (condition as FilterMap[]).map(c => `(${buildFilter(c)})`); - clauses.push(`(${orClauses.join(' OR ')})`); - continue; - } - if (condition === undefined) continue; - clauses.push(buildCondition(key, condition)); - } - - return clauses.join(' AND '); - } - - const where = buildFilter(filter); - return { - query: where ? `WHERE ${where}` : '', - parameters, - }; -} diff --git a/vendor/bytelyst/datastore/src/index.ts b/vendor/bytelyst/datastore/src/index.ts deleted file mode 100644 index e0718b8..0000000 --- a/vendor/bytelyst/datastore/src/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -export type { - BaseDocument, - FilterMap, - FilterValue, - FilterOperator, - FilterCondition, - CollectionQuery, - AggregateQuery, - AggregationField, - DocumentCollection, - DatastoreProvider, - DatastoreProviderType, -} from './types.js'; - -export { getDatastore, createDatastoreProvider, setDatastore, _resetDatastore } from './factory.js'; -export { CosmosDatastoreProvider, type CosmosProviderConfig } from './providers/cosmos.js'; -export { MemoryDatastoreProvider } from './providers/memory.js'; -export { matchesFilter, filterToCosmosSQL } from './filter.js'; diff --git a/vendor/bytelyst/datastore/src/providers/cosmos.ts b/vendor/bytelyst/datastore/src/providers/cosmos.ts deleted file mode 100644 index 059f9d8..0000000 --- a/vendor/bytelyst/datastore/src/providers/cosmos.ts +++ /dev/null @@ -1,243 +0,0 @@ -/** - * Azure Cosmos DB datastore provider. - * - * Wraps @azure/cosmos SDK behind the cloud-agnostic DocumentCollection interface. - * Translates FilterMap queries to Cosmos SQL. - */ - -import { filterToCosmosSQL } from '../filter.js'; -import type { - AggregateQuery, - BaseDocument, - CollectionQuery, - DatastoreProvider, - DocumentCollection, - FilterMap, -} from '../types.js'; - -export interface CosmosProviderConfig { - endpoint: string; - key: string; - database: string; -} - -export class CosmosDatastoreProvider implements DatastoreProvider { - private client: import('@azure/cosmos').CosmosClient | null = null; - private databaseRef: import('@azure/cosmos').Database | null = null; - private config: CosmosProviderConfig; - private collections = new Map>(); - - constructor(config?: CosmosProviderConfig) { - this.config = config ?? { - endpoint: getEnvOrThrow('COSMOS_ENDPOINT'), - key: getEnvOrThrow('COSMOS_KEY'), - database: process.env.COSMOS_DATABASE || 'lysnrai', - }; - } - - private async getDatabase() { - if (!this.databaseRef) { - const { CosmosClient } = await import('@azure/cosmos'); - this.client = new CosmosClient({ - endpoint: this.config.endpoint, - key: this.config.key, - }); - this.databaseRef = this.client.database(this.config.database); - } - return this.databaseRef as import('@azure/cosmos').Database; - } - - getCollection( - name: string, - partitionKeyPath: string - ): DocumentCollection { - const cacheKey = `${name}:${partitionKeyPath}`; - let collection = this.collections.get(cacheKey); - if (!collection) { - collection = new CosmosCollection(name, partitionKeyPath, () => - this.getDatabase() - ); - this.collections.set(cacheKey, collection); - } - return collection as unknown as DocumentCollection; - } - - async isHealthy(): Promise { - try { - const db = await this.getDatabase(); - await db.read(); - return true; - } catch { - return false; - } - } -} - -class CosmosCollection implements DocumentCollection { - constructor( - private containerName: string, - private partitionKeyPath: string, - private getDatabase: () => Promise - ) {} - - private async container() { - const db = await this.getDatabase(); - return db.container(this.containerName); - } - - private pkField(): string { - // Convert /userId to userId, /id to id, etc. - return this.partitionKeyPath.replace(/^\//, ''); - } - - async findById(id: string, partitionKey: string): Promise { - try { - const c = await this.container(); - const { resource } = await c.item(id, partitionKey).read(); - return resource ?? null; - } catch (err: unknown) { - if ((err as { code?: number })?.code === 404) return null; - throw err; - } - } - - async findMany(query?: CollectionQuery): Promise { - const c = await this.container(); - const sql = this.buildSelectSQL(query); - const { resources } = await c.items - .query({ - query: sql.query, - parameters: sql.parameters as import('@azure/cosmos').SqlParameter[], - }) - .fetchAll(); - return resources; - } - - async findOne(query?: CollectionQuery): Promise { - const results = await this.findMany({ ...query, limit: 1 }); - return results[0] ?? null; - } - - async count(filter?: FilterMap): Promise { - const c = await this.container(); - const whereClause = filter ? filterToCosmosSQL(filter) : { query: '', parameters: [] }; - const { resources } = await c.items - .query({ - query: `SELECT VALUE COUNT(1) FROM c ${whereClause.query}`, - parameters: whereClause.parameters as import('@azure/cosmos').SqlParameter[], - }) - .fetchAll(); - return resources[0] ?? 0; - } - - async create(doc: T): Promise { - const c = await this.container(); - const { resource } = await c.items.create(doc); - return resource as T; - } - - async update(id: string, partitionKey: string, updates: Partial): Promise { - const c = await this.container(); - const { resource: existing } = await c.item(id, partitionKey).read(); - if (!existing) throw new Error(`Document '${id}' not found`); - const merged = { ...existing, ...updates } as T; - const { resource } = await c.item(id, partitionKey).replace(merged); - return resource as T; - } - - async upsert(doc: T): Promise { - const c = await this.container(); - const { resource } = await c.items.upsert(doc); - return resource as T; - } - - async delete(id: string, partitionKey: string): Promise { - const c = await this.container(); - await c.item(id, partitionKey).delete(); - } - - async aggregate>(query: AggregateQuery): Promise { - const c = await this.container(); - const whereClause = query.filter - ? filterToCosmosSQL(query.filter) - : { query: '', parameters: [] }; - - const aggFields = query.aggregations - .map(a => { - const func = a.op === 'count' ? `COUNT(1)` : `${a.op.toUpperCase()}(c.${a.field})`; - return `${func} AS ${a.alias}`; - }) - .join(', '); - - const { resources } = await c.items - .query({ - query: `SELECT c.${query.groupBy}, ${aggFields} FROM c ${whereClause.query} GROUP BY c.${query.groupBy}`, - parameters: whereClause.parameters as import('@azure/cosmos').SqlParameter[], - }) - .fetchAll(); - return resources; - } - - async rawQuery(query: string, parameters?: Record): Promise { - const c = await this.container(); - const cosmosParams = parameters - ? Object.entries(parameters).map(([name, value]) => ({ - name: name.startsWith('@') ? name : `@${name}`, - value, - })) - : []; - const { resources } = await c.items - .query({ - query, - parameters: cosmosParams as import('@azure/cosmos').SqlParameter[], - }) - .fetchAll(); - return resources; - } - - private buildSelectSQL(query?: CollectionQuery): { - query: string; - parameters: Array<{ name: string; value: unknown }>; - } { - const parts: string[] = []; - - // SELECT - if (query?.select && query.select.length > 0) { - const fields = query.select.map(f => `c.${f as string}`).join(', '); - parts.push(`SELECT ${fields} FROM c`); - } else { - parts.push('SELECT * FROM c'); - } - - // WHERE - const whereClause = query?.filter - ? filterToCosmosSQL(query.filter) - : { query: '', parameters: [] }; - const parameters = [...whereClause.parameters]; - if (whereClause.query) parts.push(whereClause.query); - - // ORDER BY - if (query?.sort) { - const orderParts = Object.entries(query.sort) - .map(([field, dir]) => `c.${field} ${dir === 1 ? 'ASC' : 'DESC'}`) - .join(', '); - if (orderParts) parts.push(`ORDER BY ${orderParts}`); - } - - // OFFSET / LIMIT - if (query?.offset !== undefined && query?.limit !== undefined) { - parts.push(`OFFSET ${query.offset} LIMIT ${query.limit}`); - } else if (query?.limit !== undefined) { - parts.push(`OFFSET 0 LIMIT ${query.limit}`); - } - - return { query: parts.join(' '), parameters }; - } -} - -function getEnvOrThrow(name: string): string { - const value = process.env[name]; - if (!value) - throw new Error(`Environment variable ${name} is required for CosmosDatastoreProvider`); - return value; -} diff --git a/vendor/bytelyst/datastore/src/providers/memory.ts b/vendor/bytelyst/datastore/src/providers/memory.ts deleted file mode 100644 index 9d1cc3f..0000000 --- a/vendor/bytelyst/datastore/src/providers/memory.ts +++ /dev/null @@ -1,202 +0,0 @@ -/** - * In-memory datastore provider — for testing and local dev. - * - * All data is stored in Maps. No persistence. Fast and deterministic. - */ - -import { matchesFilter } from '../filter.js'; -import type { - AggregateQuery, - BaseDocument, - CollectionQuery, - DatastoreProvider, - DocumentCollection, - FilterMap, -} from '../types.js'; - -function deepClone(obj: T): T { - return JSON.parse(JSON.stringify(obj)) as T; -} - -export class MemoryDatastoreProvider implements DatastoreProvider { - private collections = new Map>(); - - getCollection( - name: string, - _partitionKeyPath: string - ): DocumentCollection { - let collection = this.collections.get(name); - if (!collection) { - collection = new MemoryCollection(); - this.collections.set(name, collection); - } - return collection as unknown as DocumentCollection; - } - - async isHealthy(): Promise { - return true; - } - - /** Clear all collections (for test cleanup). */ - clear(): void { - this.collections.clear(); - } -} - -class MemoryCollection implements DocumentCollection { - private docs = new Map(); - - async findById(id: string, _partitionKey: string): Promise { - return this.docs.get(id) ?? null; - } - - async findMany(query?: CollectionQuery): Promise { - let results = [...this.docs.values()]; - - if (query?.filter) { - results = results.filter(doc => matchesFilter(doc as Record, query.filter!)); - } - - if (query?.sort) { - const sortEntries = Object.entries(query.sort) as Array<[string, 1 | -1]>; - results.sort((a, b) => { - for (const [field, dir] of sortEntries) { - const aVal = (a as Record)[field]; - const bVal = (b as Record)[field]; - const cmp = compareValues(aVal, bVal); - if (cmp !== 0) return cmp * dir; - } - return 0; - }); - } - - if (query?.offset) { - results = results.slice(query.offset); - } - - if (query?.limit) { - results = results.slice(0, query.limit); - } - - if (query?.select && query.select.length > 0) { - results = results.map(doc => { - const picked: Record = {}; - for (const key of query.select!) { - picked[key as string] = (doc as Record)[key as string]; - } - return picked as T; - }); - } - - return results; - } - - async findOne(query?: CollectionQuery): Promise { - const results = await this.findMany({ ...query, limit: 1 }); - return results[0] ?? null; - } - - async count(filter?: FilterMap): Promise { - if (!filter) return this.docs.size; - const results = [...this.docs.values()].filter(doc => - matchesFilter(doc as Record, filter) - ); - return results.length; - } - - async create(doc: T): Promise { - if (this.docs.has(doc.id)) { - throw new Error(`Document with id '${doc.id}' already exists`); - } - const clone = deepClone(doc); - this.docs.set(doc.id, clone); - return clone; - } - - async update(id: string, _partitionKey: string, updates: Partial): Promise { - const existing = this.docs.get(id); - if (!existing) { - throw new Error(`Document with id '${id}' not found`); - } - const merged = { ...existing, ...updates } as T; - this.docs.set(id, deepClone(merged)); - return deepClone(merged); - } - - async upsert(doc: T): Promise { - const clone = deepClone(doc); - this.docs.set(doc.id, clone); - return clone; - } - - async delete(id: string, _partitionKey: string): Promise { - this.docs.delete(id); - } - - async aggregate>(query: AggregateQuery): Promise { - let docs = [...this.docs.values()]; - if (query.filter) { - docs = docs.filter(doc => matchesFilter(doc as Record, query.filter!)); - } - - // Group by field - const groups = new Map(); - for (const doc of docs) { - const key = String((doc as Record)[query.groupBy] ?? '__null__'); - const group = groups.get(key) ?? []; - group.push(doc); - groups.set(key, group); - } - - const results: Record[] = []; - for (const [groupKey, groupDocs] of groups) { - const row: Record = { [query.groupBy]: groupKey }; - for (const agg of query.aggregations) { - row[agg.alias] = computeAggregation(groupDocs, agg.field, agg.op); - } - results.push(row); - } - - return results as R[]; - } - - async rawQuery(_query: string, _parameters?: Record): Promise { - throw new Error( - 'rawQuery is not supported by MemoryDatastoreProvider. Use findMany/aggregate instead.' - ); - } -} - -function compareValues(a: unknown, b: unknown): number { - if (a === b) return 0; - if (a == null) return -1; - if (b == null) return 1; - if (typeof a === 'number' && typeof b === 'number') return a - b; - if (typeof a === 'string' && typeof b === 'string') return a.localeCompare(b); - return String(a).localeCompare(String(b)); -} - -function computeAggregation( - docs: T[], - field: string, - op: 'count' | 'sum' | 'avg' | 'min' | 'max' -): number { - if (op === 'count') return docs.length; - - const values = docs - .map(d => (d as Record)[field]) - .filter((v): v is number => typeof v === 'number'); - - if (values.length === 0) return 0; - - switch (op) { - case 'sum': - return values.reduce((a, b) => a + b, 0); - case 'avg': - return values.reduce((a, b) => a + b, 0) / values.length; - case 'min': - return Math.min(...values); - case 'max': - return Math.max(...values); - } -} diff --git a/vendor/bytelyst/datastore/src/testing.ts b/vendor/bytelyst/datastore/src/testing.ts deleted file mode 100644 index 6466bbf..0000000 --- a/vendor/bytelyst/datastore/src/testing.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * Test helpers for @bytelyst/datastore. - * - * Use setTestProvider('memory') in beforeAll() to wire up - * a fast, deterministic in-memory provider for tests. - */ - -import { setDatastore, _resetDatastore } from './factory.js'; -import { MemoryDatastoreProvider } from './providers/memory.js'; -import type { BaseDocument, DocumentCollection, DatastoreProviderType } from './types.js'; - -let _testProvider: MemoryDatastoreProvider | null = null; - -/** - * Set a test provider. Call in beforeAll(). - * Currently only 'memory' is supported for testing. - */ -export function setTestProvider(type: DatastoreProviderType = 'memory'): MemoryDatastoreProvider { - if (type !== 'memory') { - throw new Error(`setTestProvider only supports 'memory', got '${type}'`); - } - _testProvider = new MemoryDatastoreProvider(); - setDatastore(_testProvider); - return _testProvider; -} - -/** - * Clear all test data. Call in afterEach() or afterAll(). - */ -export function clearTestData(): void { - _testProvider?.clear(); -} - -/** - * Reset the test provider. Call in afterAll(). - */ -export function resetTestProvider(): void { - _testProvider?.clear(); - _testProvider = null; - _resetDatastore(); -} - -/** - * Seed a collection with documents for testing. - */ -export async function seedCollection( - collection: DocumentCollection, - docs: T[] -): Promise { - for (const doc of docs) { - await collection.create(doc); - } -} diff --git a/vendor/bytelyst/datastore/src/types.ts b/vendor/bytelyst/datastore/src/types.ts deleted file mode 100644 index 02fd04e..0000000 --- a/vendor/bytelyst/datastore/src/types.ts +++ /dev/null @@ -1,112 +0,0 @@ -/** - * Cloud-agnostic datastore interfaces. - * - * Provides DocumentCollection and DatastoreProvider abstractions - * that work with Cosmos DB, MongoDB, or in-memory storage. - */ - -// ── Base document type ────────────────────────────────────────────────────── - -export interface BaseDocument { - id: string; - productId: string; -} - -// ── Filter operators ──────────────────────────────────────────────────────── - -export type FilterValue = string | number | boolean | null; - -export interface FilterOperator { - $gt?: FilterValue; - $gte?: FilterValue; - $lt?: FilterValue; - $lte?: FilterValue; - $ne?: FilterValue; - $exists?: boolean; - $startsWith?: string; - $contains?: FilterValue; - $in?: FilterValue[]; -} - -export type FilterCondition = FilterValue | FilterOperator; - -export interface FilterMap { - [field: string]: FilterCondition | FilterMap[] | undefined; - $or?: FilterMap[]; -} - -// ── Query types ───────────────────────────────────────────────────────────── - -export interface CollectionQuery { - filter?: FilterMap; - sort?: Partial>; - limit?: number; - offset?: number; - select?: (keyof T & string)[]; -} - -export interface AggregateQuery { - groupBy: string; - aggregations: AggregationField[]; - filter?: FilterMap; -} - -export interface AggregationField { - field: string; - op: 'count' | 'sum' | 'avg' | 'min' | 'max'; - alias: string; -} - -// ── Document collection interface ─────────────────────────────────────────── - -export interface DocumentCollection { - /** Find a single document by ID + partition key. */ - findById(id: string, partitionKey: string): Promise; - - /** Find multiple documents matching a query. */ - findMany(query?: CollectionQuery): Promise; - - /** Find the first document matching a query. */ - findOne(query?: CollectionQuery): Promise; - - /** Count documents matching a filter. */ - count(filter?: FilterMap): Promise; - - /** Create a new document. */ - create(doc: T): Promise; - - /** Update a document by ID + partition key (merge semantics). */ - update(id: string, partitionKey: string, updates: Partial): Promise; - - /** Upsert a document (create or replace). */ - upsert(doc: T): Promise; - - /** Delete a document by ID + partition key. */ - delete(id: string, partitionKey: string): Promise; - - /** Run an aggregation query. */ - aggregate>(query: AggregateQuery): Promise; - - /** - * Execute a raw provider-specific query (escape hatch). - * Returns results as-is from the underlying provider. - */ - rawQuery(query: string, parameters?: Record): Promise; -} - -// ── Datastore provider interface ──────────────────────────────────────────── - -export interface DatastoreProvider { - /** Get a collection by name with a partition key path. */ - getCollection( - name: string, - partitionKeyPath: string - ): DocumentCollection; - - /** Check if the datastore is healthy / reachable. */ - isHealthy(): Promise; -} - -// ── Provider config ───────────────────────────────────────────────────────── - -export type DatastoreProviderType = 'cosmos' | 'memory'; diff --git a/vendor/bytelyst/datastore/tsconfig.json b/vendor/bytelyst/datastore/tsconfig.json deleted file mode 100644 index 5edad81..0000000 --- a/vendor/bytelyst/datastore/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/vendor/bytelyst/design-tokens/README.md b/vendor/bytelyst/design-tokens/README.md deleted file mode 100644 index 3e33d52..0000000 --- a/vendor/bytelyst/design-tokens/README.md +++ /dev/null @@ -1,276 +0,0 @@ -# @bytelyst/design-tokens - -ByteLyst cross-platform design system tokens. Single source of truth for colors, typography, spacing, and more across Web, iOS, Android/KMP, and React Native. - -## Quick Start - -### Install - -```bash -# This is a workspace package - use from source -# Copy generated files to your project -``` - -### Generate Tokens - -```bash -# From the monorepo root -pnpm --filter @bytelyst/design-tokens generate -``` - -This regenerates all platform outputs from `tokens/bytelyst.tokens.json`. - ---- - -## Token Structure - -### Color Tokens - -| Token Path | Example Value | Usage | -| ----------------------------------- | ------------- | -------------------------- | -| `color.semantic.dark.bgCanvas` | `#06070A` | Dark mode background | -| `color.semantic.dark.accentPrimary` | `#5A8CFF` | Primary brand color | -| `color.semantic.light.surfaceCard` | `#FFFFFF` | Light mode card background | -| `color.nomgap.stageKetosis` | `#5A8CFF` | Product-specific color | - -### Typography Tokens - -| Token Path | Value | CSS Output | -| -------------------------------- | ----------------- | ------------------- | -| `typography.fontFamily.display` | `'Space Grotesk'` | `--ml-font-display` | -| `typography.fontSize.lg` | `18` | `--ml-fs-lg: 18px` | -| `typography.fontWeight.semibold` | `600` | — | - -### Spacing Tokens (8pt Grid) - -| Token | Value | CSS | -| ----------- | ----- | -------------------- | -| `spacing.1` | `4` | `--ml-space-1: 4px` | -| `spacing.4` | `16` | `--ml-space-4: 16px` | -| `spacing.8` | `32` | `--ml-space-8: 32px` | - -### Elevation Tokens - -| Token | CSS Output | -| -------------- | ------------------------------------------------- | -| `elevation.sm` | `--ml-elevation-sm: 0 4px 12px rgba(0,0,0,0.12)` | -| `elevation.md` | `--ml-elevation-md: 0 12px 28px rgba(0,0,0,0.18)` | - ---- - -## Platform Usage - -### Web (CSS) - -```css -/* Import the CSS file */ -@import '@bytelyst/design-tokens/generated/tokens.css'; - -/* Use tokens */ -.my-component { - background-color: var(--ml-bg-canvas); - color: var(--ml-text-primary); - padding: var(--ml-space-4); - border-radius: var(--ml-radius-md); - box-shadow: var(--ml-elevation-sm); - font-family: var(--ml-font-display); - font-size: var(--ml-fs-lg); -} -``` - -#### With Tailwind CSS - -```html - -
- Content -
-``` - -### Web (TypeScript) - -```typescript -import { tokens } from '@bytelyst/design-tokens/generated/tokens'; - -// Access any token programmatically -const primaryColor = tokens.color.semantic.dark.accentPrimary; -const fontSize = tokens.typography.fontSize.lg; -``` - -### iOS (SwiftUI) - -```swift -import SwiftUI - -// Copy MindLystTheme.swift to your project -// Rename to YourProductTheme.swift - -struct MyView: View { - var body: some View { - Text("Hello") - .foregroundColor(ProductColors.darkTextPrimary) - .background(ProductColors.darkBgCanvas) - .font(.system(size: ProductSpacing.x4)) - } -} -``` - -### Android/KMP (Kotlin + Compose) - -```kotlin -import com.mindlyst.shared.theme.MindLystTokens - -@Composable -fun MyComponent() { - Box( - modifier = Modifier - .background(Color(MindLystTokens.Dark.BG_CANVAS)) - .padding(MindLystTokens.Spacing.X4.dp) - ) { - Text( - text = "Hello", - color = Color(MindLystTokens.Dark.TEXT_PRIMARY), - fontSize = MindLystTokens.Typography.SIZE_LG.sp - ) - } -} -``` - -### React Native (Expo) - -```typescript -import { tokens } from '@bytelyst/design-tokens/generated/react-native/tokens'; -import { StyleSheet } from 'react-native'; - -const styles = StyleSheet.create({ - container: { - backgroundColor: tokens.colors.bgCanvas, - padding: tokens.spacing['4'], - borderRadius: tokens.radius.md, - }, - text: { - color: tokens.colors.textPrimary, - fontSize: tokens.typography.fontSize.lg, - }, -}); -``` - ---- - -## Product-Specific Tokens - -Each product has dedicated color tokens: - -```json -{ - "color": { - "nomgap": { - "stageFed": "#FF9F43", - "stageKetosis": "#5A8CFF", - "autophagyMeter": "#5AE68C" - }, - "chronomind": { - "urgencyCritical": "#FF6E6E", - "focusMode": "#7C6BFF" - }, - "peakpulse": { - "activityHike": "#34D399", - "speedZoneFast": "#FFD166" - } - } -} -``` - ---- - -## Validation & Compliance - -### Check for Hardcoded Colors - -```bash -# From your product repo -node ../learning_ai_common_plat/packages/design-tokens/scripts/validate-tokens.cjs src/ -``` - -This scans for `#RRGGBB`, `rgb()`, `rgba()`, and `hsl()` patterns. - -### Get Token Coverage Report - -```bash -# From your product repo -node ../learning_ai_common_plat/packages/design-tokens/scripts/token-coverage.cjs src/ -``` - -Reports: - -- Percentage of files using tokens -- Number of hardcoded colors found -- Token adoption rate - ---- - -## CI Integration - -### GitHub Actions Example - -```yaml -- name: Check for hardcoded colors - run: | - node ../../learning_ai_common_plat/packages/design-tokens/scripts/validate-tokens.cjs src/ - if [ $? -ne 0 ]; then - echo "❌ Hardcoded colors found. Use design tokens instead." - exit 1 - fi -``` - -### Pre-commit Hook - -```bash -# .husky/pre-commit or .git/hooks/pre-commit -node packages/design-tokens/scripts/validate-tokens.cjs src/ || true -``` - ---- - -## Token Categories (v1.1.0) - -| Category | Count | Description | -| -------------- | ----- | ------------------------------------------------ | -| **Color** | 80+ | Palette, semantic (dark/light), product-specific | -| **Typography** | 20+ | Font families, sizes, weights, line heights | -| **Spacing** | 13 | 8pt grid (0-64px) | -| **Radius** | 6 | Corner radii (xs to pill) | -| **Elevation** | 4 | Shadow depths | -| **Motion** | 7 | Durations, easings | -| **Z-Index** | 9 | Layer stacking | -| **Icon** | 6 | Icon sizes | -| **Grid** | 12 | 12-column system | -| **Opacity** | 11 | Alpha values | - ---- - -## Version History - -| Version | Date | Changes | -| ------- | ---------- | ------------------------------------------------------------------------------------------------- | -| 1.1.0 | 2026-03-03 | Added product tokens (PeakPulse, ChronoMind, NomGap, LysnrAI), z-index, icon sizes, grid, opacity | -| 1.0.0 | 2026-02-12 | Initial release with color, typography, spacing, radius, elevation, motion | - ---- - -## Contributing - -1. Edit `tokens/bytelyst.tokens.json` (canonical source) -2. Run `pnpm generate` to regenerate outputs -3. Copy generated files to consumer repos -4. Commit both source and generated files - -**Never edit generated files directly** — they will be overwritten. - ---- - -## See Also - -- [Design System Audit Report](../../docs/design-system/DESIGN_SYSTEM_AUDIT_2026-03-03.md) -- [AGENTS.md](../../AGENTS.md) — Coding agent instructions -- [ECOSYSTEM_ARCHITECTURE.md](../../docs/ECOSYSTEM_ARCHITECTURE.md) diff --git a/vendor/bytelyst/design-tokens/bytelyst-design-tokens-0.1.0.tgz b/vendor/bytelyst/design-tokens/bytelyst-design-tokens-0.1.0.tgz deleted file mode 100644 index c526e95f8a84bd8f57484804c479f34ca0536e7f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 29482 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 H0DuDk4NovM diff --git a/vendor/bytelyst/design-tokens/generated/MindLystTheme.swift b/vendor/bytelyst/design-tokens/generated/MindLystTheme.swift deleted file mode 100644 index 0209ca7..0000000 --- a/vendor/bytelyst/design-tokens/generated/MindLystTheme.swift +++ /dev/null @@ -1,88 +0,0 @@ -// Auto-generated from bytelyst.tokens.json — do not edit manually -import SwiftUI - -// MARK: - MindLyst Design Tokens (from shared KMP MindLystTokens) -// These values mirror MindLystTokens.kt exactly. - -struct MindLystColors { - // Dark - static let darkBgCanvas = Color(hex: 0x06070A) - static let darkBgElevated = Color(hex: 0x0E1118) - static let darkSurfaceCard = Color(hex: 0x121725) - static let darkSurfaceMuted = Color(hex: 0x1A2335) - static let darkBorder = Color.white.opacity(0.12) - static let darkTextPrimary = Color(hex: 0xEFF4FF) - static let darkTextSecondary = Color(hex: 0xA5B1C7) - static let darkTextTertiary = Color(hex: 0x6C7C98) - static let darkAccentPrimary = Color(hex: 0x5A8CFF) - static let darkAccentSecondary = Color(hex: 0x2EE6D6) - static let darkSuccess = Color(hex: 0x34D399) - static let darkWarning = Color(hex: 0xF59E0B) - static let darkDanger = Color(hex: 0xFF6E6E) - - // Light - static let lightBgCanvas = Color(hex: 0xF6F8FC) - static let lightBgElevated = Color(hex: 0xEEF2FA) - static let lightSurfaceCard = Color.white - static let lightSurfaceMuted = Color(hex: 0xF3F5FA) - static let lightTextPrimary = Color(hex: 0x0E1320) - static let lightTextSecondary = Color(hex: 0x55637A) - static let lightTextTertiary = Color(hex: 0x6C7C98) - static let lightAccentPrimary = Color(hex: 0x5A8CFF) - static let lightAccentSecondary = Color(hex: 0x2EE6D6) - static let lightSuccess = Color(hex: 0x13956A) - static let lightWarning = Color(hex: 0xB87504) - static let lightDanger = Color(hex: 0xD24242) - - // Brain Gradients - static let brainWork = Gradient(colors: [Color(hex: 0x5A8CFF), Color(hex: 0x2EE6D6)]) - static let brainHome = Gradient(colors: [Color(hex: 0xFF6E6E), Color(hex: 0xFFD166)]) - static let brainMoney = Gradient(colors: [Color(hex: 0x34D399), Color(hex: 0x2EE6D6)]) - static let brainHealth = Gradient(colors: [Color(hex: 0x2EE6D6), Color(hex: 0x9FE870)]) - static let brainGlobal = Gradient(colors: [Color(hex: 0x7D8FB4), Color(hex: 0xA5B1C7)]) -} - -struct MindLystSpacing { - static let x0: CGFloat = 0 - static let x1: CGFloat = 4 - static let x2: CGFloat = 8 - static let x3: CGFloat = 12 - static let x4: CGFloat = 16 - static let x5: CGFloat = 20 - static let x6: CGFloat = 24 - static let x7: CGFloat = 28 - static let x8: CGFloat = 32 - static let x10: CGFloat = 40 - static let x12: CGFloat = 48 - static let x16: CGFloat = 64 -} - -struct MindLystRadius { - static let xs: CGFloat = 8 - static let sm: CGFloat = 12 - static let md: CGFloat = 16 - static let lg: CGFloat = 20 - static let xl: CGFloat = 24 - static let pill: CGFloat = 999 -} - -struct MindLystMotion { - static let instant: Double = 0.07 - static let fast: Double = 0.14 - static let base: Double = 0.22 - static let slow: Double = 0.32 -} - -// MARK: - Color Hex Extension - -extension Color { - init(hex: UInt, alpha: Double = 1.0) { - self.init( - .sRGB, - red: Double((hex >> 16) & 0xFF) / 255.0, - green: Double((hex >> 8) & 0xFF) / 255.0, - blue: Double(hex & 0xFF) / 255.0, - opacity: alpha - ) - } -} diff --git a/vendor/bytelyst/design-tokens/generated/MindLystTokens.kt b/vendor/bytelyst/design-tokens/generated/MindLystTokens.kt deleted file mode 100644 index 20c4faf..0000000 --- a/vendor/bytelyst/design-tokens/generated/MindLystTokens.kt +++ /dev/null @@ -1,137 +0,0 @@ -// Auto-generated from bytelyst.tokens.json — do not edit manually -package com.mindlyst.shared.theme - -/** - * Cross-platform design tokens from bytelyst.tokens.json. - * Single source of truth consumed by both Android (Compose) and iOS (SwiftUI). - */ -object MindLystTokens { - - // ── Color Palette ──────────────────────────────────────────────── - object Palette { - const val NEUTRAL_0 = 0xFFFFFFFF - const val NEUTRAL_50 = 0xFFF6F8FC - const val NEUTRAL_100 = 0xFFEEF2FA - const val NEUTRAL_200 = 0xFFDCE4F2 - const val NEUTRAL_300 = 0xFFBFCBDE - const val NEUTRAL_400 = 0xFF92A1BA - const val NEUTRAL_500 = 0xFF6C7C98 - const val NEUTRAL_600 = 0xFF55637A - const val NEUTRAL_700 = 0xFF3B455A - const val NEUTRAL_800 = 0xFF1A2335 - const val NEUTRAL_900 = 0xFF0E1320 - const val NEUTRAL_950 = 0xFF06070A - - const val BLUE = 0xFF5A8CFF - const val CYAN = 0xFF2EE6D6 - const val CORAL = 0xFFFF6E6E - const val GOLD = 0xFFFFD166 - const val MINT = 0xFF34D399 - const val WARNING = 0xFFF59E0B - const val MICROSOFTRED = 0xFFF25022 - const val MICROSOFTGREEN = 0xFF7FBA00 - const val MICROSOFTBLUE = 0xFF00A4EF - const val MICROSOFTYELLOW = 0xFFFFB900 - const val GOOGLEBLUE = 0xFF4285F4 - const val GOOGLEGREEN = 0xFF34A853 - const val GOOGLEYELLOW = 0xFFFBBC05 - const val GOOGLERED = 0xFFEA4335 - } - - // ── Semantic Colors (Dark Theme) ───────────────────────────────── - object Dark { - const val BG_CANVAS = 0xFF06070A - const val BG_ELEVATED = 0xFF0E1118 - const val SURFACE_CARD = 0xFF121725 - const val SURFACE_MUTED = 0xFF1A2335 - const val TEXT_PRIMARY = 0xFFEFF4FF - const val TEXT_SECONDARY = 0xFFA5B1C7 - const val TEXT_TERTIARY = 0xFF6C7C98 - const val ACCENT_PRIMARY = 0xFF5A8CFF - const val ACCENT_SECONDARY = 0xFF2EE6D6 - const val SUCCESS = 0xFF34D399 - const val WARNING = 0xFFF59E0B - const val DANGER = 0xFFFF6E6E - } - - // ── Semantic Colors (Light Theme) ──────────────────────────────── - object Light { - const val BG_CANVAS = 0xFFF6F8FC - const val BG_ELEVATED = 0xFFEEF2FA - const val SURFACE_CARD = 0xFFFFFFFF - const val SURFACE_MUTED = 0xFFF3F5FA - const val TEXT_PRIMARY = 0xFF0E1320 - const val TEXT_SECONDARY = 0xFF55637A - const val TEXT_TERTIARY = 0xFF6C7C98 - const val ACCENT_PRIMARY = 0xFF5A8CFF - const val ACCENT_SECONDARY = 0xFF2EE6D6 - const val SUCCESS = 0xFF13956A - const val WARNING = 0xFFB87504 - const val DANGER = 0xFFD24242 - } - - // ── Brain Identity Gradients ───────────────────────────────────── - data class BrainGradient(val from: Long, val to: Long) - - val BRAIN_WORK = BrainGradient(from = 0xFF5A8CFF, to = 0xFF2EE6D6) - val BRAIN_HOME = BrainGradient(from = 0xFFFF6E6E, to = 0xFFFFD166) - val BRAIN_MONEY = BrainGradient(from = 0xFF34D399, to = 0xFF2EE6D6) - val BRAIN_HEALTH = BrainGradient(from = 0xFF2EE6D6, to = 0xFF9FE870) - val BRAIN_GLOBAL = BrainGradient(from = 0xFF7D8FB4, to = 0xFFA5B1C7) - - // ── Spacing (8pt grid) ─────────────────────────────────────────── - object Spacing { - const val X0 = 0 - const val X1 = 4 - const val X2 = 8 - const val X3 = 12 - const val X4 = 16 - const val X5 = 20 - const val X6 = 24 - const val X7 = 28 - const val X8 = 32 - const val X10 = 40 - const val X12 = 48 - const val X16 = 64 - } - - // ── Radius ─────────────────────────────────────────────────────── - object Radius { - const val XS = 8 - const val SM = 12 - const val MD = 16 - const val LG = 20 - const val XL = 24 - const val PILL = 999 - } - - // ── Typography ─────────────────────────────────────────────────── - object Typography { - const val FONT_DISPLAY = "Space Grotesk" - const val FONT_BODY = "DM Sans" - const val FONT_MONO = "IBM Plex Mono" - - const val SIZE_XS = 12 - const val SIZE_SM = 14 - const val SIZE_MD = 16 - const val SIZE_LG = 18 - const val SIZE_XL = 22 - const val SIZE_2XL = 28 - const val SIZE_3XL = 36 - } - - // ── Motion ─────────────────────────────────────────────────────── - object Motion { - const val INSTANT = 70 - const val FAST = 140 - const val BASE = 220 - const val SLOW = 320 - } - - // ── Layout ─────────────────────────────────────────────────────── - object Layout { - const val TOUCH_TARGET_MIN = 44 - const val MOBILE_GUTTER = 16 - const val MAX_WIDTH = 1280 - } -} diff --git a/vendor/bytelyst/design-tokens/generated/actiontrail.css b/vendor/bytelyst/design-tokens/generated/actiontrail.css deleted file mode 100644 index 521beb5..0000000 --- a/vendor/bytelyst/design-tokens/generated/actiontrail.css +++ /dev/null @@ -1,97 +0,0 @@ -/* Auto-generated actiontrail tokens from bytelyst.tokens.json — do not edit manually */ - -:root { - --at-bg-canvas: #06070A; - --at-bg-elevated: #0E1118; - --at-surface-card: #121725; - --at-surface-muted: #1A2335; - --at-border-default: rgba(255,255,255,0.12); - --at-border-strong: rgba(255,255,255,0.22); - --at-text-primary: #EFF4FF; - --at-text-secondary: #A5B1C7; - --at-text-tertiary: #6C7C98; - --at-accent-primary: #5A8CFF; - --at-accent-secondary: #2EE6D6; - --at-success: #34D399; - --at-warning: #F59E0B; - --at-danger: #FF6E6E; - --at-focus-ring: rgba(90,140,255,0.45); - --at-overlay-scrim: rgba(5,8,18,0.72); - - /* actiontrail product colors */ - --at-bg: #07111F; - --at-surface: #0F1B2D; - --at-surface-elevated: #152338; - --at-border: #24344D; - --at-text: #EFF4FF; - --at-text-muted: #A8B4C8; - --at-primary: #5A8CFF; - --at-accent: #5AE68C; - --at-warning: #F59E0B; - --at-danger: #FF6E6E; - --at-risk-low: #5AE68C; - --at-risk-medium: #F59E0B; - --at-risk-high: #FF8C42; - --at-risk-critical: #FF6E6E; - --at-status-pending: #F59E0B; - --at-status-applied: #5AE68C; - --at-status-rejected: #FF6E6E; - --at-status-reverted: #A66BFF; - - --at-font-display: "Space Grotesk", "SF Pro Display", sans-serif; - --at-font-body: "DM Sans", "SF Pro Text", sans-serif; - --at-font-mono: "IBM Plex Mono", "SF Mono", monospace; - - --at-fs-xs: 12px; - --at-fs-sm: 14px; - --at-fs-md: 16px; - --at-fs-lg: 18px; - --at-fs-xl: 22px; - --at-fs-2xl: 28px; - --at-fs-3xl: 36px; - - --at-space-0: 0; - --at-space-1: 4px; - --at-space-2: 8px; - --at-space-3: 12px; - --at-space-4: 16px; - --at-space-5: 20px; - --at-space-6: 24px; - --at-space-7: 28px; - --at-space-8: 32px; - --at-space-10: 40px; - --at-space-12: 48px; - --at-space-16: 64px; - - --at-radius-xs: 8px; - --at-radius-sm: 12px; - --at-radius-md: 16px; - --at-radius-lg: 20px; - --at-radius-xl: 24px; - --at-radius-pill: 999px; - - --at-elevation-sm: 0 4px 12px rgba(0,0,0,0.12); - --at-elevation-md: 0 12px 28px rgba(0,0,0,0.18); - --at-elevation-lg: 0 20px 48px rgba(0,0,0,0.24); - - --at-motion-fast: 140ms; - --at-motion-base: 220ms; - --at-motion-slow: 320ms; - --at-easing-standard: cubic-bezier(0.2, 0.0, 0.2, 1); -} - -[data-theme="light"] { - --at-bg-canvas: #F6F8FC; - --at-bg-elevated: #EEF2FA; - --at-surface-card: #FFFFFF; - --at-surface-muted: #F3F5FA; - --at-border-default: rgba(14,19,32,0.12); - --at-border-strong: rgba(14,19,32,0.24); - --at-text-primary: #0E1320; - --at-text-secondary: #55637A; - --at-success: #13956A; - --at-warning: #B87504; - --at-danger: #D24242; - --at-focus-ring: rgba(90,140,255,0.35); - --at-overlay-scrim: rgba(10,13,23,0.5); -} diff --git a/vendor/bytelyst/design-tokens/generated/chronomind.css b/vendor/bytelyst/design-tokens/generated/chronomind.css deleted file mode 100644 index 8e9d642..0000000 --- a/vendor/bytelyst/design-tokens/generated/chronomind.css +++ /dev/null @@ -1,89 +0,0 @@ -/* Auto-generated chronomind tokens from bytelyst.tokens.json — do not edit manually */ - -:root { - --cm-bg-canvas: #06070A; - --cm-bg-elevated: #0E1118; - --cm-surface-card: #121725; - --cm-surface-muted: #1A2335; - --cm-border-default: rgba(255,255,255,0.12); - --cm-border-strong: rgba(255,255,255,0.22); - --cm-text-primary: #EFF4FF; - --cm-text-secondary: #A5B1C7; - --cm-text-tertiary: #6C7C98; - --cm-accent-primary: #5A8CFF; - --cm-accent-secondary: #2EE6D6; - --cm-success: #34D399; - --cm-warning: #F59E0B; - --cm-danger: #FF6E6E; - --cm-focus-ring: rgba(90,140,255,0.45); - --cm-overlay-scrim: rgba(5,8,18,0.72); - - /* chronomind product colors */ - --cm-urgency-critical: #FF6E6E; - --cm-urgency-important: #FFD166; - --cm-urgency-standard: #5A8CFF; - --cm-urgency-gentle: #34D399; - --cm-urgency-passive: #A5B1C7; - --cm-focus-mode: #7C6BFF; - --cm-pomodoro-work: #34D399; - --cm-pomodoro-break: #5A8CFF; - --cm-cascade-warning: #FF9F43; - --cm-timer-complete: #34D399; - - --cm-font-display: "Space Grotesk", "SF Pro Display", sans-serif; - --cm-font-body: "DM Sans", "SF Pro Text", sans-serif; - --cm-font-mono: "IBM Plex Mono", "SF Mono", monospace; - - --cm-fs-xs: 12px; - --cm-fs-sm: 14px; - --cm-fs-md: 16px; - --cm-fs-lg: 18px; - --cm-fs-xl: 22px; - --cm-fs-2xl: 28px; - --cm-fs-3xl: 36px; - - --cm-space-0: 0; - --cm-space-1: 4px; - --cm-space-2: 8px; - --cm-space-3: 12px; - --cm-space-4: 16px; - --cm-space-5: 20px; - --cm-space-6: 24px; - --cm-space-7: 28px; - --cm-space-8: 32px; - --cm-space-10: 40px; - --cm-space-12: 48px; - --cm-space-16: 64px; - - --cm-radius-xs: 8px; - --cm-radius-sm: 12px; - --cm-radius-md: 16px; - --cm-radius-lg: 20px; - --cm-radius-xl: 24px; - --cm-radius-pill: 999px; - - --cm-elevation-sm: 0 4px 12px rgba(0,0,0,0.12); - --cm-elevation-md: 0 12px 28px rgba(0,0,0,0.18); - --cm-elevation-lg: 0 20px 48px rgba(0,0,0,0.24); - - --cm-motion-fast: 140ms; - --cm-motion-base: 220ms; - --cm-motion-slow: 320ms; - --cm-easing-standard: cubic-bezier(0.2, 0.0, 0.2, 1); -} - -[data-theme="light"] { - --cm-bg-canvas: #F6F8FC; - --cm-bg-elevated: #EEF2FA; - --cm-surface-card: #FFFFFF; - --cm-surface-muted: #F3F5FA; - --cm-border-default: rgba(14,19,32,0.12); - --cm-border-strong: rgba(14,19,32,0.24); - --cm-text-primary: #0E1320; - --cm-text-secondary: #55637A; - --cm-success: #13956A; - --cm-warning: #B87504; - --cm-danger: #D24242; - --cm-focus-ring: rgba(90,140,255,0.35); - --cm-overlay-scrim: rgba(10,13,23,0.5); -} diff --git a/vendor/bytelyst/design-tokens/generated/flowmonk.css b/vendor/bytelyst/design-tokens/generated/flowmonk.css deleted file mode 100644 index 16e264f..0000000 --- a/vendor/bytelyst/design-tokens/generated/flowmonk.css +++ /dev/null @@ -1,99 +0,0 @@ -/* Auto-generated flowmonk tokens from bytelyst.tokens.json — do not edit manually */ - -:root { - --fm-bg-canvas: #06070A; - --fm-bg-elevated: #0E1118; - --fm-surface-card: #121725; - --fm-surface-muted: #1A2335; - --fm-border-default: rgba(255,255,255,0.12); - --fm-border-strong: rgba(255,255,255,0.22); - --fm-text-primary: #EFF4FF; - --fm-text-secondary: #A5B1C7; - --fm-text-tertiary: #6C7C98; - --fm-accent-primary: #5A8CFF; - --fm-accent-secondary: #2EE6D6; - --fm-success: #34D399; - --fm-warning: #F59E0B; - --fm-danger: #FF6E6E; - --fm-focus-ring: rgba(90,140,255,0.45); - --fm-overlay-scrim: rgba(5,8,18,0.72); - - /* flowmonk product colors */ - --fm-bg: #07111F; - --fm-surface: #0F1B2D; - --fm-surface-elevated: #152338; - --fm-border: #24344D; - --fm-text: #EFF4FF; - --fm-text-muted: #A8B4C8; - --fm-primary: #5A8CFF; - --fm-accent: #5AE68C; - --fm-warning: #F59E0B; - --fm-zonework: #5A8CFF; - --fm-zone-personal: #5AE68C; - --fm-zone-health: #FF6B6B; - --fm-zone-admin: #FECA57; - --fm-zone-learning: #A66BFF; - --fm-urgent-badge: #FF6E6E; - --fm-schedule-entry: #5A8CFF; - --fm-overflow-warning: #F59E0B; - --fm-recommendation-info: #5A8CFF; - --fm-recommendation-warning: #F59E0B; - --fm-recommendation-critical: #FF6E6E; - - --fm-font-display: "Space Grotesk", "SF Pro Display", sans-serif; - --fm-font-body: "DM Sans", "SF Pro Text", sans-serif; - --fm-font-mono: "IBM Plex Mono", "SF Mono", monospace; - - --fm-fs-xs: 12px; - --fm-fs-sm: 14px; - --fm-fs-md: 16px; - --fm-fs-lg: 18px; - --fm-fs-xl: 22px; - --fm-fs-2xl: 28px; - --fm-fs-3xl: 36px; - - --fm-space-0: 0; - --fm-space-1: 4px; - --fm-space-2: 8px; - --fm-space-3: 12px; - --fm-space-4: 16px; - --fm-space-5: 20px; - --fm-space-6: 24px; - --fm-space-7: 28px; - --fm-space-8: 32px; - --fm-space-10: 40px; - --fm-space-12: 48px; - --fm-space-16: 64px; - - --fm-radius-xs: 8px; - --fm-radius-sm: 12px; - --fm-radius-md: 16px; - --fm-radius-lg: 20px; - --fm-radius-xl: 24px; - --fm-radius-pill: 999px; - - --fm-elevation-sm: 0 4px 12px rgba(0,0,0,0.12); - --fm-elevation-md: 0 12px 28px rgba(0,0,0,0.18); - --fm-elevation-lg: 0 20px 48px rgba(0,0,0,0.24); - - --fm-motion-fast: 140ms; - --fm-motion-base: 220ms; - --fm-motion-slow: 320ms; - --fm-easing-standard: cubic-bezier(0.2, 0.0, 0.2, 1); -} - -[data-theme="light"] { - --fm-bg-canvas: #F6F8FC; - --fm-bg-elevated: #EEF2FA; - --fm-surface-card: #FFFFFF; - --fm-surface-muted: #F3F5FA; - --fm-border-default: rgba(14,19,32,0.12); - --fm-border-strong: rgba(14,19,32,0.24); - --fm-text-primary: #0E1320; - --fm-text-secondary: #55637A; - --fm-success: #13956A; - --fm-warning: #B87504; - --fm-danger: #D24242; - --fm-focus-ring: rgba(90,140,255,0.35); - --fm-overlay-scrim: rgba(10,13,23,0.5); -} diff --git a/vendor/bytelyst/design-tokens/generated/jarvisjr.css b/vendor/bytelyst/design-tokens/generated/jarvisjr.css deleted file mode 100644 index 7fa5832..0000000 --- a/vendor/bytelyst/design-tokens/generated/jarvisjr.css +++ /dev/null @@ -1,88 +0,0 @@ -/* Auto-generated jarvisjr tokens from bytelyst.tokens.json — do not edit manually */ - -:root { - --jj-bg-canvas: #06070A; - --jj-bg-elevated: #0E1118; - --jj-surface-card: #121725; - --jj-surface-muted: #1A2335; - --jj-border-default: rgba(255,255,255,0.12); - --jj-border-strong: rgba(255,255,255,0.22); - --jj-text-primary: #EFF4FF; - --jj-text-secondary: #A5B1C7; - --jj-text-tertiary: #6C7C98; - --jj-accent-primary: #5A8CFF; - --jj-accent-secondary: #2EE6D6; - --jj-success: #34D399; - --jj-warning: #F59E0B; - --jj-danger: #FF6E6E; - --jj-focus-ring: rgba(90,140,255,0.45); - --jj-overlay-scrim: rgba(5,8,18,0.72); - - /* jarvisjr product colors */ - --jj-accent-primary: #7C6BFF; - --jj-accent-secondary: #5AE6C8; - --jj-accent-voice: #FF6B8A; - --jj-agent-coach: #5A8CFF; - --jj-agent-lingua: #FFB74D; - --jj-agent-spark: #E040FB; - --jj-agent-mentor: #34D399; - --jj-agent-mirror: #2EE6D6; - --jj-agent-orator: #FF9F43; - - --jj-font-display: "Space Grotesk", "SF Pro Display", sans-serif; - --jj-font-body: "DM Sans", "SF Pro Text", sans-serif; - --jj-font-mono: "IBM Plex Mono", "SF Mono", monospace; - - --jj-fs-xs: 12px; - --jj-fs-sm: 14px; - --jj-fs-md: 16px; - --jj-fs-lg: 18px; - --jj-fs-xl: 22px; - --jj-fs-2xl: 28px; - --jj-fs-3xl: 36px; - - --jj-space-0: 0; - --jj-space-1: 4px; - --jj-space-2: 8px; - --jj-space-3: 12px; - --jj-space-4: 16px; - --jj-space-5: 20px; - --jj-space-6: 24px; - --jj-space-7: 28px; - --jj-space-8: 32px; - --jj-space-10: 40px; - --jj-space-12: 48px; - --jj-space-16: 64px; - - --jj-radius-xs: 8px; - --jj-radius-sm: 12px; - --jj-radius-md: 16px; - --jj-radius-lg: 20px; - --jj-radius-xl: 24px; - --jj-radius-pill: 999px; - - --jj-elevation-sm: 0 4px 12px rgba(0,0,0,0.12); - --jj-elevation-md: 0 12px 28px rgba(0,0,0,0.18); - --jj-elevation-lg: 0 20px 48px rgba(0,0,0,0.24); - - --jj-motion-fast: 140ms; - --jj-motion-base: 220ms; - --jj-motion-slow: 320ms; - --jj-easing-standard: cubic-bezier(0.2, 0.0, 0.2, 1); -} - -[data-theme="light"] { - --jj-bg-canvas: #F6F8FC; - --jj-bg-elevated: #EEF2FA; - --jj-surface-card: #FFFFFF; - --jj-surface-muted: #F3F5FA; - --jj-border-default: rgba(14,19,32,0.12); - --jj-border-strong: rgba(14,19,32,0.24); - --jj-text-primary: #0E1320; - --jj-text-secondary: #55637A; - --jj-success: #13956A; - --jj-warning: #B87504; - --jj-danger: #D24242; - --jj-focus-ring: rgba(90,140,255,0.35); - --jj-overlay-scrim: rgba(10,13,23,0.5); -} diff --git a/vendor/bytelyst/design-tokens/generated/localllmlab.css b/vendor/bytelyst/design-tokens/generated/localllmlab.css deleted file mode 100644 index 2a62100..0000000 --- a/vendor/bytelyst/design-tokens/generated/localllmlab.css +++ /dev/null @@ -1,94 +0,0 @@ -/* Auto-generated localllmlab tokens from bytelyst.tokens.json — do not edit manually */ - -:root { - --llm-bg-canvas: #06070A; - --llm-bg-elevated: #0E1118; - --llm-surface-card: #121725; - --llm-surface-muted: #1A2335; - --llm-border-default: rgba(255,255,255,0.12); - --llm-border-strong: rgba(255,255,255,0.22); - --llm-text-primary: #EFF4FF; - --llm-text-secondary: #A5B1C7; - --llm-text-tertiary: #6C7C98; - --llm-accent-primary: #5A8CFF; - --llm-accent-secondary: #2EE6D6; - --llm-success: #34D399; - --llm-warning: #F59E0B; - --llm-danger: #FF6E6E; - --llm-focus-ring: rgba(90,140,255,0.45); - --llm-overlay-scrim: rgba(5,8,18,0.72); - - /* localllmlab product colors */ - --llm-bg-canvas: #06070A; - --llm-bg-elevated: #0E1118; - --llm-surface-card: #121725; - --llm-surface-muted: #1A2335; - --llm-border-subtle: #1E293B; - --llm-border-default: #2A3654; - --llm-text-primary: #EFF4FF; - --llm-text-secondary: #A5B1C7; - --llm-text-tertiary: #6C7C98; - --llm-accent-primary: #5A8CFF; - --llm-accent-secondary: #2EE6D6; - --llm-success: #34D399; - --llm-warning: #F59E0B; - --llm-danger: #FF6E6E; - --llm-purple: #A78BFA; - - --llm-font-display: "Space Grotesk", "SF Pro Display", sans-serif; - --llm-font-body: "DM Sans", "SF Pro Text", sans-serif; - --llm-font-mono: "IBM Plex Mono", "SF Mono", monospace; - - --llm-fs-xs: 12px; - --llm-fs-sm: 14px; - --llm-fs-md: 16px; - --llm-fs-lg: 18px; - --llm-fs-xl: 22px; - --llm-fs-2xl: 28px; - --llm-fs-3xl: 36px; - - --llm-space-0: 0; - --llm-space-1: 4px; - --llm-space-2: 8px; - --llm-space-3: 12px; - --llm-space-4: 16px; - --llm-space-5: 20px; - --llm-space-6: 24px; - --llm-space-7: 28px; - --llm-space-8: 32px; - --llm-space-10: 40px; - --llm-space-12: 48px; - --llm-space-16: 64px; - - --llm-radius-xs: 8px; - --llm-radius-sm: 12px; - --llm-radius-md: 16px; - --llm-radius-lg: 20px; - --llm-radius-xl: 24px; - --llm-radius-pill: 999px; - - --llm-elevation-sm: 0 4px 12px rgba(0,0,0,0.12); - --llm-elevation-md: 0 12px 28px rgba(0,0,0,0.18); - --llm-elevation-lg: 0 20px 48px rgba(0,0,0,0.24); - - --llm-motion-fast: 140ms; - --llm-motion-base: 220ms; - --llm-motion-slow: 320ms; - --llm-easing-standard: cubic-bezier(0.2, 0.0, 0.2, 1); -} - -[data-theme="light"] { - --llm-bg-canvas: #F6F8FC; - --llm-bg-elevated: #EEF2FA; - --llm-surface-card: #FFFFFF; - --llm-surface-muted: #F3F5FA; - --llm-border-default: rgba(14,19,32,0.12); - --llm-border-strong: rgba(14,19,32,0.24); - --llm-text-primary: #0E1320; - --llm-text-secondary: #55637A; - --llm-success: #13956A; - --llm-warning: #B87504; - --llm-danger: #D24242; - --llm-focus-ring: rgba(90,140,255,0.35); - --llm-overlay-scrim: rgba(10,13,23,0.5); -} diff --git a/vendor/bytelyst/design-tokens/generated/localmemgpt.css b/vendor/bytelyst/design-tokens/generated/localmemgpt.css deleted file mode 100644 index 05beae5..0000000 --- a/vendor/bytelyst/design-tokens/generated/localmemgpt.css +++ /dev/null @@ -1,93 +0,0 @@ -/* Auto-generated localmemgpt tokens from bytelyst.tokens.json — do not edit manually */ - -:root { - --lmg-bg-canvas: #06070A; - --lmg-bg-elevated: #0E1118; - --lmg-surface-card: #121725; - --lmg-surface-muted: #1A2335; - --lmg-border-default: rgba(255,255,255,0.12); - --lmg-border-strong: rgba(255,255,255,0.22); - --lmg-text-primary: #EFF4FF; - --lmg-text-secondary: #A5B1C7; - --lmg-text-tertiary: #6C7C98; - --lmg-accent-primary: #5A8CFF; - --lmg-accent-secondary: #2EE6D6; - --lmg-success: #34D399; - --lmg-warning: #F59E0B; - --lmg-danger: #FF6E6E; - --lmg-focus-ring: rgba(90,140,255,0.45); - --lmg-overlay-scrim: rgba(5,8,18,0.72); - - /* localmemgpt product colors */ - --lmg-bg-primary: #0A0A0A; - --lmg-bg-secondary: #141414; - --lmg-bg-tertiary: #1E1E1E; - --lmg-bg-hover: #252525; - --lmg-bg-input: #1A1A1A; - --lmg-border: #2A2A2A; - --lmg-text-primary: #F0F0F0; - --lmg-text-secondary: #999999; - --lmg-text-muted: #666666; - --lmg-accent: #6366F1; - --lmg-accent-hover: #818CF8; - --lmg-success: #22C55E; - --lmg-warning: #F59E0B; - --lmg-error: #EF4444; - - --lmg-font-display: "Space Grotesk", "SF Pro Display", sans-serif; - --lmg-font-body: "DM Sans", "SF Pro Text", sans-serif; - --lmg-font-mono: "IBM Plex Mono", "SF Mono", monospace; - - --lmg-fs-xs: 12px; - --lmg-fs-sm: 14px; - --lmg-fs-md: 16px; - --lmg-fs-lg: 18px; - --lmg-fs-xl: 22px; - --lmg-fs-2xl: 28px; - --lmg-fs-3xl: 36px; - - --lmg-space-0: 0; - --lmg-space-1: 4px; - --lmg-space-2: 8px; - --lmg-space-3: 12px; - --lmg-space-4: 16px; - --lmg-space-5: 20px; - --lmg-space-6: 24px; - --lmg-space-7: 28px; - --lmg-space-8: 32px; - --lmg-space-10: 40px; - --lmg-space-12: 48px; - --lmg-space-16: 64px; - - --lmg-radius-xs: 8px; - --lmg-radius-sm: 12px; - --lmg-radius-md: 16px; - --lmg-radius-lg: 20px; - --lmg-radius-xl: 24px; - --lmg-radius-pill: 999px; - - --lmg-elevation-sm: 0 4px 12px rgba(0,0,0,0.12); - --lmg-elevation-md: 0 12px 28px rgba(0,0,0,0.18); - --lmg-elevation-lg: 0 20px 48px rgba(0,0,0,0.24); - - --lmg-motion-fast: 140ms; - --lmg-motion-base: 220ms; - --lmg-motion-slow: 320ms; - --lmg-easing-standard: cubic-bezier(0.2, 0.0, 0.2, 1); -} - -[data-theme="light"] { - --lmg-bg-canvas: #F6F8FC; - --lmg-bg-elevated: #EEF2FA; - --lmg-surface-card: #FFFFFF; - --lmg-surface-muted: #F3F5FA; - --lmg-border-default: rgba(14,19,32,0.12); - --lmg-border-strong: rgba(14,19,32,0.24); - --lmg-text-primary: #0E1320; - --lmg-text-secondary: #55637A; - --lmg-success: #13956A; - --lmg-warning: #B87504; - --lmg-danger: #D24242; - --lmg-focus-ring: rgba(90,140,255,0.35); - --lmg-overlay-scrim: rgba(10,13,23,0.5); -} diff --git a/vendor/bytelyst/design-tokens/generated/lysnrai.css b/vendor/bytelyst/design-tokens/generated/lysnrai.css deleted file mode 100644 index 383be4a..0000000 --- a/vendor/bytelyst/design-tokens/generated/lysnrai.css +++ /dev/null @@ -1,86 +0,0 @@ -/* Auto-generated lysnrai tokens from bytelyst.tokens.json — do not edit manually */ - -:root { - --lys-bg-canvas: #06070A; - --lys-bg-elevated: #0E1118; - --lys-surface-card: #121725; - --lys-surface-muted: #1A2335; - --lys-border-default: rgba(255,255,255,0.12); - --lys-border-strong: rgba(255,255,255,0.22); - --lys-text-primary: #EFF4FF; - --lys-text-secondary: #A5B1C7; - --lys-text-tertiary: #6C7C98; - --lys-accent-primary: #5A8CFF; - --lys-accent-secondary: #2EE6D6; - --lys-success: #34D399; - --lys-warning: #F59E0B; - --lys-danger: #FF6E6E; - --lys-focus-ring: rgba(90,140,255,0.45); - --lys-overlay-scrim: rgba(5,8,18,0.72); - - /* lysnrai product colors */ - --lys-recording-active: #FF6E6E; - --lys-recording-paused: #FFD166; - --lys-processing: #5A8CFF; - --lys-transcribed: #34D399; - --lys-dictation-mode: #7C6BFF; - --lys-command-mode: #2EE6D6; - --lys-hotkey-active: #FF6B8A; - - --lys-font-display: "Space Grotesk", "SF Pro Display", sans-serif; - --lys-font-body: "DM Sans", "SF Pro Text", sans-serif; - --lys-font-mono: "IBM Plex Mono", "SF Mono", monospace; - - --lys-fs-xs: 12px; - --lys-fs-sm: 14px; - --lys-fs-md: 16px; - --lys-fs-lg: 18px; - --lys-fs-xl: 22px; - --lys-fs-2xl: 28px; - --lys-fs-3xl: 36px; - - --lys-space-0: 0; - --lys-space-1: 4px; - --lys-space-2: 8px; - --lys-space-3: 12px; - --lys-space-4: 16px; - --lys-space-5: 20px; - --lys-space-6: 24px; - --lys-space-7: 28px; - --lys-space-8: 32px; - --lys-space-10: 40px; - --lys-space-12: 48px; - --lys-space-16: 64px; - - --lys-radius-xs: 8px; - --lys-radius-sm: 12px; - --lys-radius-md: 16px; - --lys-radius-lg: 20px; - --lys-radius-xl: 24px; - --lys-radius-pill: 999px; - - --lys-elevation-sm: 0 4px 12px rgba(0,0,0,0.12); - --lys-elevation-md: 0 12px 28px rgba(0,0,0,0.18); - --lys-elevation-lg: 0 20px 48px rgba(0,0,0,0.24); - - --lys-motion-fast: 140ms; - --lys-motion-base: 220ms; - --lys-motion-slow: 320ms; - --lys-easing-standard: cubic-bezier(0.2, 0.0, 0.2, 1); -} - -[data-theme="light"] { - --lys-bg-canvas: #F6F8FC; - --lys-bg-elevated: #EEF2FA; - --lys-surface-card: #FFFFFF; - --lys-surface-muted: #F3F5FA; - --lys-border-default: rgba(14,19,32,0.12); - --lys-border-strong: rgba(14,19,32,0.24); - --lys-text-primary: #0E1320; - --lys-text-secondary: #55637A; - --lys-success: #13956A; - --lys-warning: #B87504; - --lys-danger: #D24242; - --lys-focus-ring: rgba(90,140,255,0.35); - --lys-overlay-scrim: rgba(10,13,23,0.5); -} diff --git a/vendor/bytelyst/design-tokens/generated/mindlyst.css b/vendor/bytelyst/design-tokens/generated/mindlyst.css deleted file mode 100644 index 327e690..0000000 --- a/vendor/bytelyst/design-tokens/generated/mindlyst.css +++ /dev/null @@ -1,89 +0,0 @@ -/* Auto-generated mindlyst tokens from bytelyst.tokens.json — do not edit manually */ - -:root { - --ml-bg-canvas: #06070A; - --ml-bg-elevated: #0E1118; - --ml-surface-card: #121725; - --ml-surface-muted: #1A2335; - --ml-border-default: rgba(255,255,255,0.12); - --ml-border-strong: rgba(255,255,255,0.22); - --ml-text-primary: #EFF4FF; - --ml-text-secondary: #A5B1C7; - --ml-text-tertiary: #6C7C98; - --ml-accent-primary: #5A8CFF; - --ml-accent-secondary: #2EE6D6; - --ml-success: #34D399; - --ml-warning: #F59E0B; - --ml-danger: #FF6E6E; - --ml-focus-ring: rgba(90,140,255,0.45); - --ml-overlay-scrim: rgba(5,8,18,0.72); - - /* mindlyst product colors */ - --ml-work-from: #5A8CFF; - --ml-work-to: #2EE6D6; - --ml-home-from: #FF6E6E; - --ml-home-to: #FFD166; - --ml-money-from: #34D399; - --ml-money-to: #2EE6D6; - --ml-health-from: #2EE6D6; - --ml-health-to: #9FE870; - --ml-global-from: #7D8FB4; - --ml-global-to: #A5B1C7; - - --ml-font-display: "Space Grotesk", "SF Pro Display", sans-serif; - --ml-font-body: "DM Sans", "SF Pro Text", sans-serif; - --ml-font-mono: "IBM Plex Mono", "SF Mono", monospace; - - --ml-fs-xs: 12px; - --ml-fs-sm: 14px; - --ml-fs-md: 16px; - --ml-fs-lg: 18px; - --ml-fs-xl: 22px; - --ml-fs-2xl: 28px; - --ml-fs-3xl: 36px; - - --ml-space-0: 0; - --ml-space-1: 4px; - --ml-space-2: 8px; - --ml-space-3: 12px; - --ml-space-4: 16px; - --ml-space-5: 20px; - --ml-space-6: 24px; - --ml-space-7: 28px; - --ml-space-8: 32px; - --ml-space-10: 40px; - --ml-space-12: 48px; - --ml-space-16: 64px; - - --ml-radius-xs: 8px; - --ml-radius-sm: 12px; - --ml-radius-md: 16px; - --ml-radius-lg: 20px; - --ml-radius-xl: 24px; - --ml-radius-pill: 999px; - - --ml-elevation-sm: 0 4px 12px rgba(0,0,0,0.12); - --ml-elevation-md: 0 12px 28px rgba(0,0,0,0.18); - --ml-elevation-lg: 0 20px 48px rgba(0,0,0,0.24); - - --ml-motion-fast: 140ms; - --ml-motion-base: 220ms; - --ml-motion-slow: 320ms; - --ml-easing-standard: cubic-bezier(0.2, 0.0, 0.2, 1); -} - -[data-theme="light"] { - --ml-bg-canvas: #F6F8FC; - --ml-bg-elevated: #EEF2FA; - --ml-surface-card: #FFFFFF; - --ml-surface-muted: #F3F5FA; - --ml-border-default: rgba(14,19,32,0.12); - --ml-border-strong: rgba(14,19,32,0.24); - --ml-text-primary: #0E1320; - --ml-text-secondary: #55637A; - --ml-success: #13956A; - --ml-warning: #B87504; - --ml-danger: #D24242; - --ml-focus-ring: rgba(90,140,255,0.35); - --ml-overlay-scrim: rgba(10,13,23,0.5); -} diff --git a/vendor/bytelyst/design-tokens/generated/native/ActionTrailTheme.generated.swift b/vendor/bytelyst/design-tokens/generated/native/ActionTrailTheme.generated.swift deleted file mode 100644 index 2bb33f9..0000000 --- a/vendor/bytelyst/design-tokens/generated/native/ActionTrailTheme.generated.swift +++ /dev/null @@ -1,103 +0,0 @@ -// Auto-generated from bytelyst.tokens.json — do not edit manually. -// Product: actiontrail -// Regenerate: cd packages/design-tokens && tsx scripts/generate.ts - -import SwiftUI - -enum ActionTrailColors { - // MARK: - Semantic (Dark Theme) - static let bgCanvas = Color(hex: 0x06070A) - static let bgElevated = Color(hex: 0x0E1118) - static let surfaceCard = Color(hex: 0x121725) - static let surfaceMuted = Color(hex: 0x1A2335) - static let textPrimary = Color(hex: 0xEFF4FF) - static let textSecondary = Color(hex: 0xA5B1C7) - static let textTertiary = Color(hex: 0x6C7C98) - static let accentPrimary = Color(hex: 0x5A8CFF) - static let accentSecondary = Color(hex: 0x2EE6D6) - static let success = Color(hex: 0x34D399) - static let warning = Color(hex: 0xF59E0B) - static let danger = Color(hex: 0xFF6E6E) - - // MARK: - Actiontrail Product Colors - static let bg = Color(hex: 0x07111F) - static let surface = Color(hex: 0x0F1B2D) - static let surfaceElevated = Color(hex: 0x152338) - static let border = Color(hex: 0x24344D) - static let text = Color(hex: 0xEFF4FF) - static let textMuted = Color(hex: 0xA8B4C8) - static let primary = Color(hex: 0x5A8CFF) - static let accent = Color(hex: 0x5AE68C) - static let warning = Color(hex: 0xF59E0B) - static let danger = Color(hex: 0xFF6E6E) - static let riskLow = Color(hex: 0x5AE68C) - static let riskMedium = Color(hex: 0xF59E0B) - static let riskHigh = Color(hex: 0xFF8C42) - static let riskCritical = Color(hex: 0xFF6E6E) - static let statusPending = Color(hex: 0xF59E0B) - static let statusApplied = Color(hex: 0x5AE68C) - static let statusRejected = Color(hex: 0xFF6E6E) - static let statusReverted = Color(hex: 0xA66BFF) - -} - -enum ActionTrailColorsLight { - // MARK: - Semantic (Light Theme) - static let bgCanvas = Color(hex: 0xF6F8FC) - static let bgElevated = Color(hex: 0xEEF2FA) - static let surfaceCard = Color(hex: 0xFFFFFF) - static let surfaceMuted = Color(hex: 0xF3F5FA) - static let textPrimary = Color(hex: 0x0E1320) - static let textSecondary = Color(hex: 0x55637A) - static let textTertiary = Color(hex: 0x6C7C98) - static let accentPrimary = Color(hex: 0x5A8CFF) - static let accentSecondary = Color(hex: 0x2EE6D6) - static let success = Color(hex: 0x13956A) - static let warning = Color(hex: 0xB87504) - static let danger = Color(hex: 0xD24242) -} - -enum ActionTrailSpacing { - static let x0: CGFloat = 0 - static let x1: CGFloat = 4 - static let x2: CGFloat = 8 - static let x3: CGFloat = 12 - static let x4: CGFloat = 16 - static let x5: CGFloat = 20 - static let x6: CGFloat = 24 - static let x7: CGFloat = 28 - static let x8: CGFloat = 32 - static let x10: CGFloat = 40 - static let x12: CGFloat = 48 - static let x16: CGFloat = 64 -} - -enum ActionTrailRadius { - static let xs: CGFloat = 8 - static let sm: CGFloat = 12 - static let md: CGFloat = 16 - static let lg: CGFloat = 20 - static let xl: CGFloat = 24 - static let pill: CGFloat = 999 -} - -enum ActionTrailMotion { - static let instant: Double = 0.07 - static let fast: Double = 0.14 - static let base: Double = 0.22 - static let slow: Double = 0.32 -} - -// MARK: - Color Hex Extension (import if not already defined) - -extension Color { - init(hex: UInt, alpha: Double = 1.0) { - self.init( - .sRGB, - red: Double((hex >> 16) & 0xFF) / 255.0, - green: Double((hex >> 8) & 0xFF) / 255.0, - blue: Double(hex & 0xFF) / 255.0, - opacity: alpha - ) - } -} diff --git a/vendor/bytelyst/design-tokens/generated/native/ActionTrailTokens.generated.kt b/vendor/bytelyst/design-tokens/generated/native/ActionTrailTokens.generated.kt deleted file mode 100644 index eba86c0..0000000 --- a/vendor/bytelyst/design-tokens/generated/native/ActionTrailTokens.generated.kt +++ /dev/null @@ -1,102 +0,0 @@ -// Auto-generated from bytelyst.tokens.json — do not edit manually. -// Product: actiontrail -// Regenerate: cd packages/design-tokens && tsx scripts/generate.ts -package com.actiontrail.theme - -object ActionTrailTokens { - - // ── Semantic Colors (Dark Theme) ───────────────────────────────── - object Dark { - const val BG_CANVAS = 0xFF06070A - const val BG_ELEVATED = 0xFF0E1118 - const val SURFACE_CARD = 0xFF121725 - const val SURFACE_MUTED = 0xFF1A2335 - const val TEXT_PRIMARY = 0xFFEFF4FF - const val TEXT_SECONDARY = 0xFFA5B1C7 - const val TEXT_TERTIARY = 0xFF6C7C98 - const val ACCENT_PRIMARY = 0xFF5A8CFF - const val ACCENT_SECONDARY = 0xFF2EE6D6 - const val SUCCESS = 0xFF34D399 - const val WARNING = 0xFFF59E0B - const val DANGER = 0xFFFF6E6E - } - - // ── Semantic Colors (Light Theme) ──────────────────────────────── - object Light { - const val BG_CANVAS = 0xFFF6F8FC - const val BG_ELEVATED = 0xFFEEF2FA - const val SURFACE_CARD = 0xFFFFFFFF - const val SURFACE_MUTED = 0xFFF3F5FA - const val TEXT_PRIMARY = 0xFF0E1320 - const val TEXT_SECONDARY = 0xFF55637A - const val TEXT_TERTIARY = 0xFF6C7C98 - const val ACCENT_PRIMARY = 0xFF5A8CFF - const val ACCENT_SECONDARY = 0xFF2EE6D6 - const val SUCCESS = 0xFF13956A - const val WARNING = 0xFFB87504 - const val DANGER = 0xFFD24242 - } - - // ── Actiontrail Product Colors ─────────────────────────────── - object Product { - const val BG = 0xFF07111F - const val SURFACE = 0xFF0F1B2D - const val SURFACE_ELEVATED = 0xFF152338 - const val BORDER = 0xFF24344D - const val TEXT = 0xFFEFF4FF - const val TEXT_MUTED = 0xFFA8B4C8 - const val PRIMARY = 0xFF5A8CFF - const val ACCENT = 0xFF5AE68C - const val WARNING = 0xFFF59E0B - const val DANGER = 0xFFFF6E6E - const val RISK_LOW = 0xFF5AE68C - const val RISK_MEDIUM = 0xFFF59E0B - const val RISK_HIGH = 0xFFFF8C42 - const val RISK_CRITICAL = 0xFFFF6E6E - const val STATUS_PENDING = 0xFFF59E0B - const val STATUS_APPLIED = 0xFF5AE68C - const val STATUS_REJECTED = 0xFFFF6E6E - const val STATUS_REVERTED = 0xFFA66BFF - } - - // ── Spacing (8pt grid) ─────────────────────────────────────────── - object Spacing { - const val X0 = 0 - const val X1 = 4 - const val X2 = 8 - const val X3 = 12 - const val X4 = 16 - const val X5 = 20 - const val X6 = 24 - const val X7 = 28 - const val X8 = 32 - const val X10 = 40 - const val X12 = 48 - const val X16 = 64 - } - - // ── Radius ─────────────────────────────────────────────────────── - object Radius { - const val XS = 8 - const val SM = 12 - const val MD = 16 - const val LG = 20 - const val XL = 24 - const val PILL = 999 - } - - // ── Typography ─────────────────────────────────────────────────── - object Typography { - const val FONT_DISPLAY = "Space Grotesk" - const val FONT_BODY = "DM Sans" - const val FONT_MONO = "IBM Plex Mono" - } - - // ── Motion ─────────────────────────────────────────────────────── - object Motion { - const val INSTANT = 70 - const val FAST = 140 - const val BASE = 220 - const val SLOW = 320 - } -} diff --git a/vendor/bytelyst/design-tokens/generated/native/ChronoMindTheme.generated.swift b/vendor/bytelyst/design-tokens/generated/native/ChronoMindTheme.generated.swift deleted file mode 100644 index 8b4c085..0000000 --- a/vendor/bytelyst/design-tokens/generated/native/ChronoMindTheme.generated.swift +++ /dev/null @@ -1,95 +0,0 @@ -// Auto-generated from bytelyst.tokens.json — do not edit manually. -// Product: chronomind -// Regenerate: cd packages/design-tokens && tsx scripts/generate.ts - -import SwiftUI - -enum CMColors { - // MARK: - Semantic (Dark Theme) - static let bgCanvas = Color(hex: 0x06070A) - static let bgElevated = Color(hex: 0x0E1118) - static let surfaceCard = Color(hex: 0x121725) - static let surfaceMuted = Color(hex: 0x1A2335) - static let textPrimary = Color(hex: 0xEFF4FF) - static let textSecondary = Color(hex: 0xA5B1C7) - static let textTertiary = Color(hex: 0x6C7C98) - static let accentPrimary = Color(hex: 0x5A8CFF) - static let accentSecondary = Color(hex: 0x2EE6D6) - static let success = Color(hex: 0x34D399) - static let warning = Color(hex: 0xF59E0B) - static let danger = Color(hex: 0xFF6E6E) - - // MARK: - Chronomind Product Colors - static let urgencyCritical = Color(hex: 0xFF6E6E) - static let urgencyImportant = Color(hex: 0xFFD166) - static let urgencyStandard = Color(hex: 0x5A8CFF) - static let urgencyGentle = Color(hex: 0x34D399) - static let urgencyPassive = Color(hex: 0xA5B1C7) - static let focusMode = Color(hex: 0x7C6BFF) - static let pomodoroWork = Color(hex: 0x34D399) - static let pomodoroBreak = Color(hex: 0x5A8CFF) - static let cascadeWarning = Color(hex: 0xFF9F43) - static let timerComplete = Color(hex: 0x34D399) - -} - -enum CMColorsLight { - // MARK: - Semantic (Light Theme) - static let bgCanvas = Color(hex: 0xF6F8FC) - static let bgElevated = Color(hex: 0xEEF2FA) - static let surfaceCard = Color(hex: 0xFFFFFF) - static let surfaceMuted = Color(hex: 0xF3F5FA) - static let textPrimary = Color(hex: 0x0E1320) - static let textSecondary = Color(hex: 0x55637A) - static let textTertiary = Color(hex: 0x6C7C98) - static let accentPrimary = Color(hex: 0x5A8CFF) - static let accentSecondary = Color(hex: 0x2EE6D6) - static let success = Color(hex: 0x13956A) - static let warning = Color(hex: 0xB87504) - static let danger = Color(hex: 0xD24242) -} - -enum CMSpacing { - static let x0: CGFloat = 0 - static let x1: CGFloat = 4 - static let x2: CGFloat = 8 - static let x3: CGFloat = 12 - static let x4: CGFloat = 16 - static let x5: CGFloat = 20 - static let x6: CGFloat = 24 - static let x7: CGFloat = 28 - static let x8: CGFloat = 32 - static let x10: CGFloat = 40 - static let x12: CGFloat = 48 - static let x16: CGFloat = 64 -} - -enum CMRadius { - static let xs: CGFloat = 8 - static let sm: CGFloat = 12 - static let md: CGFloat = 16 - static let lg: CGFloat = 20 - static let xl: CGFloat = 24 - static let pill: CGFloat = 999 -} - -enum CMMotion { - static let instant: Double = 0.07 - static let fast: Double = 0.14 - static let base: Double = 0.22 - static let slow: Double = 0.32 -} - -// MARK: - Color Hex Extension (import if not already defined) - -extension Color { - init(hex: UInt, alpha: Double = 1.0) { - self.init( - .sRGB, - red: Double((hex >> 16) & 0xFF) / 255.0, - green: Double((hex >> 8) & 0xFF) / 255.0, - blue: Double(hex & 0xFF) / 255.0, - opacity: alpha - ) - } -} diff --git a/vendor/bytelyst/design-tokens/generated/native/ChronoMindTokens.generated.kt b/vendor/bytelyst/design-tokens/generated/native/ChronoMindTokens.generated.kt deleted file mode 100644 index 09aafe1..0000000 --- a/vendor/bytelyst/design-tokens/generated/native/ChronoMindTokens.generated.kt +++ /dev/null @@ -1,94 +0,0 @@ -// Auto-generated from bytelyst.tokens.json — do not edit manually. -// Product: chronomind -// Regenerate: cd packages/design-tokens && tsx scripts/generate.ts -package com.chronomind.app.theme - -object ChronoMindTokens { - - // ── Semantic Colors (Dark Theme) ───────────────────────────────── - object Dark { - const val BG_CANVAS = 0xFF06070A - const val BG_ELEVATED = 0xFF0E1118 - const val SURFACE_CARD = 0xFF121725 - const val SURFACE_MUTED = 0xFF1A2335 - const val TEXT_PRIMARY = 0xFFEFF4FF - const val TEXT_SECONDARY = 0xFFA5B1C7 - const val TEXT_TERTIARY = 0xFF6C7C98 - const val ACCENT_PRIMARY = 0xFF5A8CFF - const val ACCENT_SECONDARY = 0xFF2EE6D6 - const val SUCCESS = 0xFF34D399 - const val WARNING = 0xFFF59E0B - const val DANGER = 0xFFFF6E6E - } - - // ── Semantic Colors (Light Theme) ──────────────────────────────── - object Light { - const val BG_CANVAS = 0xFFF6F8FC - const val BG_ELEVATED = 0xFFEEF2FA - const val SURFACE_CARD = 0xFFFFFFFF - const val SURFACE_MUTED = 0xFFF3F5FA - const val TEXT_PRIMARY = 0xFF0E1320 - const val TEXT_SECONDARY = 0xFF55637A - const val TEXT_TERTIARY = 0xFF6C7C98 - const val ACCENT_PRIMARY = 0xFF5A8CFF - const val ACCENT_SECONDARY = 0xFF2EE6D6 - const val SUCCESS = 0xFF13956A - const val WARNING = 0xFFB87504 - const val DANGER = 0xFFD24242 - } - - // ── Chronomind Product Colors ─────────────────────────────── - object Product { - const val URGENCY_CRITICAL = 0xFFFF6E6E - const val URGENCY_IMPORTANT = 0xFFFFD166 - const val URGENCY_STANDARD = 0xFF5A8CFF - const val URGENCY_GENTLE = 0xFF34D399 - const val URGENCY_PASSIVE = 0xFFA5B1C7 - const val FOCUS_MODE = 0xFF7C6BFF - const val POMODORO_WORK = 0xFF34D399 - const val POMODORO_BREAK = 0xFF5A8CFF - const val CASCADE_WARNING = 0xFFFF9F43 - const val TIMER_COMPLETE = 0xFF34D399 - } - - // ── Spacing (8pt grid) ─────────────────────────────────────────── - object Spacing { - const val X0 = 0 - const val X1 = 4 - const val X2 = 8 - const val X3 = 12 - const val X4 = 16 - const val X5 = 20 - const val X6 = 24 - const val X7 = 28 - const val X8 = 32 - const val X10 = 40 - const val X12 = 48 - const val X16 = 64 - } - - // ── Radius ─────────────────────────────────────────────────────── - object Radius { - const val XS = 8 - const val SM = 12 - const val MD = 16 - const val LG = 20 - const val XL = 24 - const val PILL = 999 - } - - // ── Typography ─────────────────────────────────────────────────── - object Typography { - const val FONT_DISPLAY = "Space Grotesk" - const val FONT_BODY = "DM Sans" - const val FONT_MONO = "IBM Plex Mono" - } - - // ── Motion ─────────────────────────────────────────────────────── - object Motion { - const val INSTANT = 70 - const val FAST = 140 - const val BASE = 220 - const val SLOW = 320 - } -} diff --git a/vendor/bytelyst/design-tokens/generated/native/FlowMonkTheme.generated.swift b/vendor/bytelyst/design-tokens/generated/native/FlowMonkTheme.generated.swift deleted file mode 100644 index 6701005..0000000 --- a/vendor/bytelyst/design-tokens/generated/native/FlowMonkTheme.generated.swift +++ /dev/null @@ -1,105 +0,0 @@ -// Auto-generated from bytelyst.tokens.json — do not edit manually. -// Product: flowmonk -// Regenerate: cd packages/design-tokens && tsx scripts/generate.ts - -import SwiftUI - -enum FlowMonkColors { - // MARK: - Semantic (Dark Theme) - static let bgCanvas = Color(hex: 0x06070A) - static let bgElevated = Color(hex: 0x0E1118) - static let surfaceCard = Color(hex: 0x121725) - static let surfaceMuted = Color(hex: 0x1A2335) - static let textPrimary = Color(hex: 0xEFF4FF) - static let textSecondary = Color(hex: 0xA5B1C7) - static let textTertiary = Color(hex: 0x6C7C98) - static let accentPrimary = Color(hex: 0x5A8CFF) - static let accentSecondary = Color(hex: 0x2EE6D6) - static let success = Color(hex: 0x34D399) - static let warning = Color(hex: 0xF59E0B) - static let danger = Color(hex: 0xFF6E6E) - - // MARK: - Flowmonk Product Colors - static let bg = Color(hex: 0x07111F) - static let surface = Color(hex: 0x0F1B2D) - static let surfaceElevated = Color(hex: 0x152338) - static let border = Color(hex: 0x24344D) - static let text = Color(hex: 0xEFF4FF) - static let textMuted = Color(hex: 0xA8B4C8) - static let primary = Color(hex: 0x5A8CFF) - static let accent = Color(hex: 0x5AE68C) - static let warning = Color(hex: 0xF59E0B) - static let zonework = Color(hex: 0x5A8CFF) - static let zonePersonal = Color(hex: 0x5AE68C) - static let zoneHealth = Color(hex: 0xFF6B6B) - static let zoneAdmin = Color(hex: 0xFECA57) - static let zoneLearning = Color(hex: 0xA66BFF) - static let urgentBadge = Color(hex: 0xFF6E6E) - static let scheduleEntry = Color(hex: 0x5A8CFF) - static let overflowWarning = Color(hex: 0xF59E0B) - static let recommendationInfo = Color(hex: 0x5A8CFF) - static let recommendationWarning = Color(hex: 0xF59E0B) - static let recommendationCritical = Color(hex: 0xFF6E6E) - -} - -enum FlowMonkColorsLight { - // MARK: - Semantic (Light Theme) - static let bgCanvas = Color(hex: 0xF6F8FC) - static let bgElevated = Color(hex: 0xEEF2FA) - static let surfaceCard = Color(hex: 0xFFFFFF) - static let surfaceMuted = Color(hex: 0xF3F5FA) - static let textPrimary = Color(hex: 0x0E1320) - static let textSecondary = Color(hex: 0x55637A) - static let textTertiary = Color(hex: 0x6C7C98) - static let accentPrimary = Color(hex: 0x5A8CFF) - static let accentSecondary = Color(hex: 0x2EE6D6) - static let success = Color(hex: 0x13956A) - static let warning = Color(hex: 0xB87504) - static let danger = Color(hex: 0xD24242) -} - -enum FlowMonkSpacing { - static let x0: CGFloat = 0 - static let x1: CGFloat = 4 - static let x2: CGFloat = 8 - static let x3: CGFloat = 12 - static let x4: CGFloat = 16 - static let x5: CGFloat = 20 - static let x6: CGFloat = 24 - static let x7: CGFloat = 28 - static let x8: CGFloat = 32 - static let x10: CGFloat = 40 - static let x12: CGFloat = 48 - static let x16: CGFloat = 64 -} - -enum FlowMonkRadius { - static let xs: CGFloat = 8 - static let sm: CGFloat = 12 - static let md: CGFloat = 16 - static let lg: CGFloat = 20 - static let xl: CGFloat = 24 - static let pill: CGFloat = 999 -} - -enum FlowMonkMotion { - static let instant: Double = 0.07 - static let fast: Double = 0.14 - static let base: Double = 0.22 - static let slow: Double = 0.32 -} - -// MARK: - Color Hex Extension (import if not already defined) - -extension Color { - init(hex: UInt, alpha: Double = 1.0) { - self.init( - .sRGB, - red: Double((hex >> 16) & 0xFF) / 255.0, - green: Double((hex >> 8) & 0xFF) / 255.0, - blue: Double(hex & 0xFF) / 255.0, - opacity: alpha - ) - } -} diff --git a/vendor/bytelyst/design-tokens/generated/native/FlowMonkTokens.generated.kt b/vendor/bytelyst/design-tokens/generated/native/FlowMonkTokens.generated.kt deleted file mode 100644 index fdc64a4..0000000 --- a/vendor/bytelyst/design-tokens/generated/native/FlowMonkTokens.generated.kt +++ /dev/null @@ -1,104 +0,0 @@ -// Auto-generated from bytelyst.tokens.json — do not edit manually. -// Product: flowmonk -// Regenerate: cd packages/design-tokens && tsx scripts/generate.ts -package com.flowmonk.theme - -object FlowMonkTokens { - - // ── Semantic Colors (Dark Theme) ───────────────────────────────── - object Dark { - const val BG_CANVAS = 0xFF06070A - const val BG_ELEVATED = 0xFF0E1118 - const val SURFACE_CARD = 0xFF121725 - const val SURFACE_MUTED = 0xFF1A2335 - const val TEXT_PRIMARY = 0xFFEFF4FF - const val TEXT_SECONDARY = 0xFFA5B1C7 - const val TEXT_TERTIARY = 0xFF6C7C98 - const val ACCENT_PRIMARY = 0xFF5A8CFF - const val ACCENT_SECONDARY = 0xFF2EE6D6 - const val SUCCESS = 0xFF34D399 - const val WARNING = 0xFFF59E0B - const val DANGER = 0xFFFF6E6E - } - - // ── Semantic Colors (Light Theme) ──────────────────────────────── - object Light { - const val BG_CANVAS = 0xFFF6F8FC - const val BG_ELEVATED = 0xFFEEF2FA - const val SURFACE_CARD = 0xFFFFFFFF - const val SURFACE_MUTED = 0xFFF3F5FA - const val TEXT_PRIMARY = 0xFF0E1320 - const val TEXT_SECONDARY = 0xFF55637A - const val TEXT_TERTIARY = 0xFF6C7C98 - const val ACCENT_PRIMARY = 0xFF5A8CFF - const val ACCENT_SECONDARY = 0xFF2EE6D6 - const val SUCCESS = 0xFF13956A - const val WARNING = 0xFFB87504 - const val DANGER = 0xFFD24242 - } - - // ── Flowmonk Product Colors ─────────────────────────────── - object Product { - const val BG = 0xFF07111F - const val SURFACE = 0xFF0F1B2D - const val SURFACE_ELEVATED = 0xFF152338 - const val BORDER = 0xFF24344D - const val TEXT = 0xFFEFF4FF - const val TEXT_MUTED = 0xFFA8B4C8 - const val PRIMARY = 0xFF5A8CFF - const val ACCENT = 0xFF5AE68C - const val WARNING = 0xFFF59E0B - const val ZONEWORK = 0xFF5A8CFF - const val ZONE_PERSONAL = 0xFF5AE68C - const val ZONE_HEALTH = 0xFFFF6B6B - const val ZONE_ADMIN = 0xFFFECA57 - const val ZONE_LEARNING = 0xFFA66BFF - const val URGENT_BADGE = 0xFFFF6E6E - const val SCHEDULE_ENTRY = 0xFF5A8CFF - const val OVERFLOW_WARNING = 0xFFF59E0B - const val RECOMMENDATION_INFO = 0xFF5A8CFF - const val RECOMMENDATION_WARNING = 0xFFF59E0B - const val RECOMMENDATION_CRITICAL = 0xFFFF6E6E - } - - // ── Spacing (8pt grid) ─────────────────────────────────────────── - object Spacing { - const val X0 = 0 - const val X1 = 4 - const val X2 = 8 - const val X3 = 12 - const val X4 = 16 - const val X5 = 20 - const val X6 = 24 - const val X7 = 28 - const val X8 = 32 - const val X10 = 40 - const val X12 = 48 - const val X16 = 64 - } - - // ── Radius ─────────────────────────────────────────────────────── - object Radius { - const val XS = 8 - const val SM = 12 - const val MD = 16 - const val LG = 20 - const val XL = 24 - const val PILL = 999 - } - - // ── Typography ─────────────────────────────────────────────────── - object Typography { - const val FONT_DISPLAY = "Space Grotesk" - const val FONT_BODY = "DM Sans" - const val FONT_MONO = "IBM Plex Mono" - } - - // ── Motion ─────────────────────────────────────────────────────── - object Motion { - const val INSTANT = 70 - const val FAST = 140 - const val BASE = 220 - const val SLOW = 320 - } -} diff --git a/vendor/bytelyst/design-tokens/generated/native/JarvisJrTheme.generated.swift b/vendor/bytelyst/design-tokens/generated/native/JarvisJrTheme.generated.swift deleted file mode 100644 index 0d88174..0000000 --- a/vendor/bytelyst/design-tokens/generated/native/JarvisJrTheme.generated.swift +++ /dev/null @@ -1,94 +0,0 @@ -// Auto-generated from bytelyst.tokens.json — do not edit manually. -// Product: jarvisjr -// Regenerate: cd packages/design-tokens && tsx scripts/generate.ts - -import SwiftUI - -enum JarvisJrColors { - // MARK: - Semantic (Dark Theme) - static let bgCanvas = Color(hex: 0x06070A) - static let bgElevated = Color(hex: 0x0E1118) - static let surfaceCard = Color(hex: 0x121725) - static let surfaceMuted = Color(hex: 0x1A2335) - static let textPrimary = Color(hex: 0xEFF4FF) - static let textSecondary = Color(hex: 0xA5B1C7) - static let textTertiary = Color(hex: 0x6C7C98) - static let accentPrimary = Color(hex: 0x5A8CFF) - static let accentSecondary = Color(hex: 0x2EE6D6) - static let success = Color(hex: 0x34D399) - static let warning = Color(hex: 0xF59E0B) - static let danger = Color(hex: 0xFF6E6E) - - // MARK: - Jarvisjr Product Colors - static let accentPrimary = Color(hex: 0x7C6BFF) - static let accentSecondary = Color(hex: 0x5AE6C8) - static let accentVoice = Color(hex: 0xFF6B8A) - static let agentCoach = Color(hex: 0x5A8CFF) - static let agentLingua = Color(hex: 0xFFB74D) - static let agentSpark = Color(hex: 0xE040FB) - static let agentMentor = Color(hex: 0x34D399) - static let agentMirror = Color(hex: 0x2EE6D6) - static let agentOrator = Color(hex: 0xFF9F43) - -} - -enum JarvisJrColorsLight { - // MARK: - Semantic (Light Theme) - static let bgCanvas = Color(hex: 0xF6F8FC) - static let bgElevated = Color(hex: 0xEEF2FA) - static let surfaceCard = Color(hex: 0xFFFFFF) - static let surfaceMuted = Color(hex: 0xF3F5FA) - static let textPrimary = Color(hex: 0x0E1320) - static let textSecondary = Color(hex: 0x55637A) - static let textTertiary = Color(hex: 0x6C7C98) - static let accentPrimary = Color(hex: 0x5A8CFF) - static let accentSecondary = Color(hex: 0x2EE6D6) - static let success = Color(hex: 0x13956A) - static let warning = Color(hex: 0xB87504) - static let danger = Color(hex: 0xD24242) -} - -enum JarvisJrSpacing { - static let x0: CGFloat = 0 - static let x1: CGFloat = 4 - static let x2: CGFloat = 8 - static let x3: CGFloat = 12 - static let x4: CGFloat = 16 - static let x5: CGFloat = 20 - static let x6: CGFloat = 24 - static let x7: CGFloat = 28 - static let x8: CGFloat = 32 - static let x10: CGFloat = 40 - static let x12: CGFloat = 48 - static let x16: CGFloat = 64 -} - -enum JarvisJrRadius { - static let xs: CGFloat = 8 - static let sm: CGFloat = 12 - static let md: CGFloat = 16 - static let lg: CGFloat = 20 - static let xl: CGFloat = 24 - static let pill: CGFloat = 999 -} - -enum JarvisJrMotion { - static let instant: Double = 0.07 - static let fast: Double = 0.14 - static let base: Double = 0.22 - static let slow: Double = 0.32 -} - -// MARK: - Color Hex Extension (import if not already defined) - -extension Color { - init(hex: UInt, alpha: Double = 1.0) { - self.init( - .sRGB, - red: Double((hex >> 16) & 0xFF) / 255.0, - green: Double((hex >> 8) & 0xFF) / 255.0, - blue: Double(hex & 0xFF) / 255.0, - opacity: alpha - ) - } -} diff --git a/vendor/bytelyst/design-tokens/generated/native/JarvisJrTokens.generated.kt b/vendor/bytelyst/design-tokens/generated/native/JarvisJrTokens.generated.kt deleted file mode 100644 index 6537682..0000000 --- a/vendor/bytelyst/design-tokens/generated/native/JarvisJrTokens.generated.kt +++ /dev/null @@ -1,93 +0,0 @@ -// Auto-generated from bytelyst.tokens.json — do not edit manually. -// Product: jarvisjr -// Regenerate: cd packages/design-tokens && tsx scripts/generate.ts -package com.jarvisjr.app.theme - -object JarvisJrTokens { - - // ── Semantic Colors (Dark Theme) ───────────────────────────────── - object Dark { - const val BG_CANVAS = 0xFF06070A - const val BG_ELEVATED = 0xFF0E1118 - const val SURFACE_CARD = 0xFF121725 - const val SURFACE_MUTED = 0xFF1A2335 - const val TEXT_PRIMARY = 0xFFEFF4FF - const val TEXT_SECONDARY = 0xFFA5B1C7 - const val TEXT_TERTIARY = 0xFF6C7C98 - const val ACCENT_PRIMARY = 0xFF5A8CFF - const val ACCENT_SECONDARY = 0xFF2EE6D6 - const val SUCCESS = 0xFF34D399 - const val WARNING = 0xFFF59E0B - const val DANGER = 0xFFFF6E6E - } - - // ── Semantic Colors (Light Theme) ──────────────────────────────── - object Light { - const val BG_CANVAS = 0xFFF6F8FC - const val BG_ELEVATED = 0xFFEEF2FA - const val SURFACE_CARD = 0xFFFFFFFF - const val SURFACE_MUTED = 0xFFF3F5FA - const val TEXT_PRIMARY = 0xFF0E1320 - const val TEXT_SECONDARY = 0xFF55637A - const val TEXT_TERTIARY = 0xFF6C7C98 - const val ACCENT_PRIMARY = 0xFF5A8CFF - const val ACCENT_SECONDARY = 0xFF2EE6D6 - const val SUCCESS = 0xFF13956A - const val WARNING = 0xFFB87504 - const val DANGER = 0xFFD24242 - } - - // ── Jarvisjr Product Colors ─────────────────────────────── - object Product { - const val ACCENT_PRIMARY = 0xFF7C6BFF - const val ACCENT_SECONDARY = 0xFF5AE6C8 - const val ACCENT_VOICE = 0xFFFF6B8A - const val AGENT_COACH = 0xFF5A8CFF - const val AGENT_LINGUA = 0xFFFFB74D - const val AGENT_SPARK = 0xFFE040FB - const val AGENT_MENTOR = 0xFF34D399 - const val AGENT_MIRROR = 0xFF2EE6D6 - const val AGENT_ORATOR = 0xFFFF9F43 - } - - // ── Spacing (8pt grid) ─────────────────────────────────────────── - object Spacing { - const val X0 = 0 - const val X1 = 4 - const val X2 = 8 - const val X3 = 12 - const val X4 = 16 - const val X5 = 20 - const val X6 = 24 - const val X7 = 28 - const val X8 = 32 - const val X10 = 40 - const val X12 = 48 - const val X16 = 64 - } - - // ── Radius ─────────────────────────────────────────────────────── - object Radius { - const val XS = 8 - const val SM = 12 - const val MD = 16 - const val LG = 20 - const val XL = 24 - const val PILL = 999 - } - - // ── Typography ─────────────────────────────────────────────────── - object Typography { - const val FONT_DISPLAY = "Space Grotesk" - const val FONT_BODY = "DM Sans" - const val FONT_MONO = "IBM Plex Mono" - } - - // ── Motion ─────────────────────────────────────────────────────── - object Motion { - const val INSTANT = 70 - const val FAST = 140 - const val BASE = 220 - const val SLOW = 320 - } -} diff --git a/vendor/bytelyst/design-tokens/generated/native/LocalLLMLabTheme.generated.swift b/vendor/bytelyst/design-tokens/generated/native/LocalLLMLabTheme.generated.swift deleted file mode 100644 index e75de29..0000000 --- a/vendor/bytelyst/design-tokens/generated/native/LocalLLMLabTheme.generated.swift +++ /dev/null @@ -1,100 +0,0 @@ -// Auto-generated from bytelyst.tokens.json — do not edit manually. -// Product: localllmlab -// Regenerate: cd packages/design-tokens && tsx scripts/generate.ts - -import SwiftUI - -enum LocalLLMLabColors { - // MARK: - Semantic (Dark Theme) - static let bgCanvas = Color(hex: 0x06070A) - static let bgElevated = Color(hex: 0x0E1118) - static let surfaceCard = Color(hex: 0x121725) - static let surfaceMuted = Color(hex: 0x1A2335) - static let textPrimary = Color(hex: 0xEFF4FF) - static let textSecondary = Color(hex: 0xA5B1C7) - static let textTertiary = Color(hex: 0x6C7C98) - static let accentPrimary = Color(hex: 0x5A8CFF) - static let accentSecondary = Color(hex: 0x2EE6D6) - static let success = Color(hex: 0x34D399) - static let warning = Color(hex: 0xF59E0B) - static let danger = Color(hex: 0xFF6E6E) - - // MARK: - Localllmlab Product Colors - static let bgCanvas = Color(hex: 0x06070A) - static let bgElevated = Color(hex: 0x0E1118) - static let surfaceCard = Color(hex: 0x121725) - static let surfaceMuted = Color(hex: 0x1A2335) - static let borderSubtle = Color(hex: 0x1E293B) - static let borderDefault = Color(hex: 0x2A3654) - static let textPrimary = Color(hex: 0xEFF4FF) - static let textSecondary = Color(hex: 0xA5B1C7) - static let textTertiary = Color(hex: 0x6C7C98) - static let accentPrimary = Color(hex: 0x5A8CFF) - static let accentSecondary = Color(hex: 0x2EE6D6) - static let success = Color(hex: 0x34D399) - static let warning = Color(hex: 0xF59E0B) - static let danger = Color(hex: 0xFF6E6E) - static let purple = Color(hex: 0xA78BFA) - -} - -enum LocalLLMLabColorsLight { - // MARK: - Semantic (Light Theme) - static let bgCanvas = Color(hex: 0xF6F8FC) - static let bgElevated = Color(hex: 0xEEF2FA) - static let surfaceCard = Color(hex: 0xFFFFFF) - static let surfaceMuted = Color(hex: 0xF3F5FA) - static let textPrimary = Color(hex: 0x0E1320) - static let textSecondary = Color(hex: 0x55637A) - static let textTertiary = Color(hex: 0x6C7C98) - static let accentPrimary = Color(hex: 0x5A8CFF) - static let accentSecondary = Color(hex: 0x2EE6D6) - static let success = Color(hex: 0x13956A) - static let warning = Color(hex: 0xB87504) - static let danger = Color(hex: 0xD24242) -} - -enum LocalLLMLabSpacing { - static let x0: CGFloat = 0 - static let x1: CGFloat = 4 - static let x2: CGFloat = 8 - static let x3: CGFloat = 12 - static let x4: CGFloat = 16 - static let x5: CGFloat = 20 - static let x6: CGFloat = 24 - static let x7: CGFloat = 28 - static let x8: CGFloat = 32 - static let x10: CGFloat = 40 - static let x12: CGFloat = 48 - static let x16: CGFloat = 64 -} - -enum LocalLLMLabRadius { - static let xs: CGFloat = 8 - static let sm: CGFloat = 12 - static let md: CGFloat = 16 - static let lg: CGFloat = 20 - static let xl: CGFloat = 24 - static let pill: CGFloat = 999 -} - -enum LocalLLMLabMotion { - static let instant: Double = 0.07 - static let fast: Double = 0.14 - static let base: Double = 0.22 - static let slow: Double = 0.32 -} - -// MARK: - Color Hex Extension (import if not already defined) - -extension Color { - init(hex: UInt, alpha: Double = 1.0) { - self.init( - .sRGB, - red: Double((hex >> 16) & 0xFF) / 255.0, - green: Double((hex >> 8) & 0xFF) / 255.0, - blue: Double(hex & 0xFF) / 255.0, - opacity: alpha - ) - } -} diff --git a/vendor/bytelyst/design-tokens/generated/native/LocalLLMLabTokens.generated.kt b/vendor/bytelyst/design-tokens/generated/native/LocalLLMLabTokens.generated.kt deleted file mode 100644 index bac6a9b..0000000 --- a/vendor/bytelyst/design-tokens/generated/native/LocalLLMLabTokens.generated.kt +++ /dev/null @@ -1,99 +0,0 @@ -// Auto-generated from bytelyst.tokens.json — do not edit manually. -// Product: localllmlab -// Regenerate: cd packages/design-tokens && tsx scripts/generate.ts -package com.localllmlab.theme - -object LocalLLMLabTokens { - - // ── Semantic Colors (Dark Theme) ───────────────────────────────── - object Dark { - const val BG_CANVAS = 0xFF06070A - const val BG_ELEVATED = 0xFF0E1118 - const val SURFACE_CARD = 0xFF121725 - const val SURFACE_MUTED = 0xFF1A2335 - const val TEXT_PRIMARY = 0xFFEFF4FF - const val TEXT_SECONDARY = 0xFFA5B1C7 - const val TEXT_TERTIARY = 0xFF6C7C98 - const val ACCENT_PRIMARY = 0xFF5A8CFF - const val ACCENT_SECONDARY = 0xFF2EE6D6 - const val SUCCESS = 0xFF34D399 - const val WARNING = 0xFFF59E0B - const val DANGER = 0xFFFF6E6E - } - - // ── Semantic Colors (Light Theme) ──────────────────────────────── - object Light { - const val BG_CANVAS = 0xFFF6F8FC - const val BG_ELEVATED = 0xFFEEF2FA - const val SURFACE_CARD = 0xFFFFFFFF - const val SURFACE_MUTED = 0xFFF3F5FA - const val TEXT_PRIMARY = 0xFF0E1320 - const val TEXT_SECONDARY = 0xFF55637A - const val TEXT_TERTIARY = 0xFF6C7C98 - const val ACCENT_PRIMARY = 0xFF5A8CFF - const val ACCENT_SECONDARY = 0xFF2EE6D6 - const val SUCCESS = 0xFF13956A - const val WARNING = 0xFFB87504 - const val DANGER = 0xFFD24242 - } - - // ── Localllmlab Product Colors ─────────────────────────────── - object Product { - const val BG_CANVAS = 0xFF06070A - const val BG_ELEVATED = 0xFF0E1118 - const val SURFACE_CARD = 0xFF121725 - const val SURFACE_MUTED = 0xFF1A2335 - const val BORDER_SUBTLE = 0xFF1E293B - const val BORDER_DEFAULT = 0xFF2A3654 - const val TEXT_PRIMARY = 0xFFEFF4FF - const val TEXT_SECONDARY = 0xFFA5B1C7 - const val TEXT_TERTIARY = 0xFF6C7C98 - const val ACCENT_PRIMARY = 0xFF5A8CFF - const val ACCENT_SECONDARY = 0xFF2EE6D6 - const val SUCCESS = 0xFF34D399 - const val WARNING = 0xFFF59E0B - const val DANGER = 0xFFFF6E6E - const val PURPLE = 0xFFA78BFA - } - - // ── Spacing (8pt grid) ─────────────────────────────────────────── - object Spacing { - const val X0 = 0 - const val X1 = 4 - const val X2 = 8 - const val X3 = 12 - const val X4 = 16 - const val X5 = 20 - const val X6 = 24 - const val X7 = 28 - const val X8 = 32 - const val X10 = 40 - const val X12 = 48 - const val X16 = 64 - } - - // ── Radius ─────────────────────────────────────────────────────── - object Radius { - const val XS = 8 - const val SM = 12 - const val MD = 16 - const val LG = 20 - const val XL = 24 - const val PILL = 999 - } - - // ── Typography ─────────────────────────────────────────────────── - object Typography { - const val FONT_DISPLAY = "Space Grotesk" - const val FONT_BODY = "DM Sans" - const val FONT_MONO = "IBM Plex Mono" - } - - // ── Motion ─────────────────────────────────────────────────────── - object Motion { - const val INSTANT = 70 - const val FAST = 140 - const val BASE = 220 - const val SLOW = 320 - } -} diff --git a/vendor/bytelyst/design-tokens/generated/native/LocalMemGPTTheme.generated.swift b/vendor/bytelyst/design-tokens/generated/native/LocalMemGPTTheme.generated.swift deleted file mode 100644 index 6c3d11c..0000000 --- a/vendor/bytelyst/design-tokens/generated/native/LocalMemGPTTheme.generated.swift +++ /dev/null @@ -1,99 +0,0 @@ -// Auto-generated from bytelyst.tokens.json — do not edit manually. -// Product: localmemgpt -// Regenerate: cd packages/design-tokens && tsx scripts/generate.ts - -import SwiftUI - -enum LocalMemGPTColors { - // MARK: - Semantic (Dark Theme) - static let bgCanvas = Color(hex: 0x06070A) - static let bgElevated = Color(hex: 0x0E1118) - static let surfaceCard = Color(hex: 0x121725) - static let surfaceMuted = Color(hex: 0x1A2335) - static let textPrimary = Color(hex: 0xEFF4FF) - static let textSecondary = Color(hex: 0xA5B1C7) - static let textTertiary = Color(hex: 0x6C7C98) - static let accentPrimary = Color(hex: 0x5A8CFF) - static let accentSecondary = Color(hex: 0x2EE6D6) - static let success = Color(hex: 0x34D399) - static let warning = Color(hex: 0xF59E0B) - static let danger = Color(hex: 0xFF6E6E) - - // MARK: - Localmemgpt Product Colors - static let bgPrimary = Color(hex: 0x0A0A0A) - static let bgSecondary = Color(hex: 0x141414) - static let bgTertiary = Color(hex: 0x1E1E1E) - static let bgHover = Color(hex: 0x252525) - static let bgInput = Color(hex: 0x1A1A1A) - static let border = Color(hex: 0x2A2A2A) - static let textPrimary = Color(hex: 0xF0F0F0) - static let textSecondary = Color(hex: 0x999999) - static let textMuted = Color(hex: 0x666666) - static let accent = Color(hex: 0x6366F1) - static let accentHover = Color(hex: 0x818CF8) - static let success = Color(hex: 0x22C55E) - static let warning = Color(hex: 0xF59E0B) - static let error = Color(hex: 0xEF4444) - -} - -enum LocalMemGPTColorsLight { - // MARK: - Semantic (Light Theme) - static let bgCanvas = Color(hex: 0xF6F8FC) - static let bgElevated = Color(hex: 0xEEF2FA) - static let surfaceCard = Color(hex: 0xFFFFFF) - static let surfaceMuted = Color(hex: 0xF3F5FA) - static let textPrimary = Color(hex: 0x0E1320) - static let textSecondary = Color(hex: 0x55637A) - static let textTertiary = Color(hex: 0x6C7C98) - static let accentPrimary = Color(hex: 0x5A8CFF) - static let accentSecondary = Color(hex: 0x2EE6D6) - static let success = Color(hex: 0x13956A) - static let warning = Color(hex: 0xB87504) - static let danger = Color(hex: 0xD24242) -} - -enum LocalMemGPTSpacing { - static let x0: CGFloat = 0 - static let x1: CGFloat = 4 - static let x2: CGFloat = 8 - static let x3: CGFloat = 12 - static let x4: CGFloat = 16 - static let x5: CGFloat = 20 - static let x6: CGFloat = 24 - static let x7: CGFloat = 28 - static let x8: CGFloat = 32 - static let x10: CGFloat = 40 - static let x12: CGFloat = 48 - static let x16: CGFloat = 64 -} - -enum LocalMemGPTRadius { - static let xs: CGFloat = 8 - static let sm: CGFloat = 12 - static let md: CGFloat = 16 - static let lg: CGFloat = 20 - static let xl: CGFloat = 24 - static let pill: CGFloat = 999 -} - -enum LocalMemGPTMotion { - static let instant: Double = 0.07 - static let fast: Double = 0.14 - static let base: Double = 0.22 - static let slow: Double = 0.32 -} - -// MARK: - Color Hex Extension (import if not already defined) - -extension Color { - init(hex: UInt, alpha: Double = 1.0) { - self.init( - .sRGB, - red: Double((hex >> 16) & 0xFF) / 255.0, - green: Double((hex >> 8) & 0xFF) / 255.0, - blue: Double(hex & 0xFF) / 255.0, - opacity: alpha - ) - } -} diff --git a/vendor/bytelyst/design-tokens/generated/native/LocalMemGPTTokens.generated.kt b/vendor/bytelyst/design-tokens/generated/native/LocalMemGPTTokens.generated.kt deleted file mode 100644 index 51ae467..0000000 --- a/vendor/bytelyst/design-tokens/generated/native/LocalMemGPTTokens.generated.kt +++ /dev/null @@ -1,98 +0,0 @@ -// Auto-generated from bytelyst.tokens.json — do not edit manually. -// Product: localmemgpt -// Regenerate: cd packages/design-tokens && tsx scripts/generate.ts -package com.localmemgpt.theme - -object LocalMemGPTTokens { - - // ── Semantic Colors (Dark Theme) ───────────────────────────────── - object Dark { - const val BG_CANVAS = 0xFF06070A - const val BG_ELEVATED = 0xFF0E1118 - const val SURFACE_CARD = 0xFF121725 - const val SURFACE_MUTED = 0xFF1A2335 - const val TEXT_PRIMARY = 0xFFEFF4FF - const val TEXT_SECONDARY = 0xFFA5B1C7 - const val TEXT_TERTIARY = 0xFF6C7C98 - const val ACCENT_PRIMARY = 0xFF5A8CFF - const val ACCENT_SECONDARY = 0xFF2EE6D6 - const val SUCCESS = 0xFF34D399 - const val WARNING = 0xFFF59E0B - const val DANGER = 0xFFFF6E6E - } - - // ── Semantic Colors (Light Theme) ──────────────────────────────── - object Light { - const val BG_CANVAS = 0xFFF6F8FC - const val BG_ELEVATED = 0xFFEEF2FA - const val SURFACE_CARD = 0xFFFFFFFF - const val SURFACE_MUTED = 0xFFF3F5FA - const val TEXT_PRIMARY = 0xFF0E1320 - const val TEXT_SECONDARY = 0xFF55637A - const val TEXT_TERTIARY = 0xFF6C7C98 - const val ACCENT_PRIMARY = 0xFF5A8CFF - const val ACCENT_SECONDARY = 0xFF2EE6D6 - const val SUCCESS = 0xFF13956A - const val WARNING = 0xFFB87504 - const val DANGER = 0xFFD24242 - } - - // ── Localmemgpt Product Colors ─────────────────────────────── - object Product { - const val BG_PRIMARY = 0xFF0A0A0A - const val BG_SECONDARY = 0xFF141414 - const val BG_TERTIARY = 0xFF1E1E1E - const val BG_HOVER = 0xFF252525 - const val BG_INPUT = 0xFF1A1A1A - const val BORDER = 0xFF2A2A2A - const val TEXT_PRIMARY = 0xFFF0F0F0 - const val TEXT_SECONDARY = 0xFF999999 - const val TEXT_MUTED = 0xFF666666 - const val ACCENT = 0xFF6366F1 - const val ACCENT_HOVER = 0xFF818CF8 - const val SUCCESS = 0xFF22C55E - const val WARNING = 0xFFF59E0B - const val ERROR = 0xFFEF4444 - } - - // ── Spacing (8pt grid) ─────────────────────────────────────────── - object Spacing { - const val X0 = 0 - const val X1 = 4 - const val X2 = 8 - const val X3 = 12 - const val X4 = 16 - const val X5 = 20 - const val X6 = 24 - const val X7 = 28 - const val X8 = 32 - const val X10 = 40 - const val X12 = 48 - const val X16 = 64 - } - - // ── Radius ─────────────────────────────────────────────────────── - object Radius { - const val XS = 8 - const val SM = 12 - const val MD = 16 - const val LG = 20 - const val XL = 24 - const val PILL = 999 - } - - // ── Typography ─────────────────────────────────────────────────── - object Typography { - const val FONT_DISPLAY = "Space Grotesk" - const val FONT_BODY = "DM Sans" - const val FONT_MONO = "IBM Plex Mono" - } - - // ── Motion ─────────────────────────────────────────────────────── - object Motion { - const val INSTANT = 70 - const val FAST = 140 - const val BASE = 220 - const val SLOW = 320 - } -} diff --git a/vendor/bytelyst/design-tokens/generated/native/LysnrAITheme.generated.swift b/vendor/bytelyst/design-tokens/generated/native/LysnrAITheme.generated.swift deleted file mode 100644 index ebe950e..0000000 --- a/vendor/bytelyst/design-tokens/generated/native/LysnrAITheme.generated.swift +++ /dev/null @@ -1,92 +0,0 @@ -// Auto-generated from bytelyst.tokens.json — do not edit manually. -// Product: lysnrai -// Regenerate: cd packages/design-tokens && tsx scripts/generate.ts - -import SwiftUI - -enum LysnrAIColors { - // MARK: - Semantic (Dark Theme) - static let bgCanvas = Color(hex: 0x06070A) - static let bgElevated = Color(hex: 0x0E1118) - static let surfaceCard = Color(hex: 0x121725) - static let surfaceMuted = Color(hex: 0x1A2335) - static let textPrimary = Color(hex: 0xEFF4FF) - static let textSecondary = Color(hex: 0xA5B1C7) - static let textTertiary = Color(hex: 0x6C7C98) - static let accentPrimary = Color(hex: 0x5A8CFF) - static let accentSecondary = Color(hex: 0x2EE6D6) - static let success = Color(hex: 0x34D399) - static let warning = Color(hex: 0xF59E0B) - static let danger = Color(hex: 0xFF6E6E) - - // MARK: - Lysnrai Product Colors - static let recordingActive = Color(hex: 0xFF6E6E) - static let recordingPaused = Color(hex: 0xFFD166) - static let processing = Color(hex: 0x5A8CFF) - static let transcribed = Color(hex: 0x34D399) - static let dictationMode = Color(hex: 0x7C6BFF) - static let commandMode = Color(hex: 0x2EE6D6) - static let hotkeyActive = Color(hex: 0xFF6B8A) - -} - -enum LysnrAIColorsLight { - // MARK: - Semantic (Light Theme) - static let bgCanvas = Color(hex: 0xF6F8FC) - static let bgElevated = Color(hex: 0xEEF2FA) - static let surfaceCard = Color(hex: 0xFFFFFF) - static let surfaceMuted = Color(hex: 0xF3F5FA) - static let textPrimary = Color(hex: 0x0E1320) - static let textSecondary = Color(hex: 0x55637A) - static let textTertiary = Color(hex: 0x6C7C98) - static let accentPrimary = Color(hex: 0x5A8CFF) - static let accentSecondary = Color(hex: 0x2EE6D6) - static let success = Color(hex: 0x13956A) - static let warning = Color(hex: 0xB87504) - static let danger = Color(hex: 0xD24242) -} - -enum LysnrAISpacing { - static let x0: CGFloat = 0 - static let x1: CGFloat = 4 - static let x2: CGFloat = 8 - static let x3: CGFloat = 12 - static let x4: CGFloat = 16 - static let x5: CGFloat = 20 - static let x6: CGFloat = 24 - static let x7: CGFloat = 28 - static let x8: CGFloat = 32 - static let x10: CGFloat = 40 - static let x12: CGFloat = 48 - static let x16: CGFloat = 64 -} - -enum LysnrAIRadius { - static let xs: CGFloat = 8 - static let sm: CGFloat = 12 - static let md: CGFloat = 16 - static let lg: CGFloat = 20 - static let xl: CGFloat = 24 - static let pill: CGFloat = 999 -} - -enum LysnrAIMotion { - static let instant: Double = 0.07 - static let fast: Double = 0.14 - static let base: Double = 0.22 - static let slow: Double = 0.32 -} - -// MARK: - Color Hex Extension (import if not already defined) - -extension Color { - init(hex: UInt, alpha: Double = 1.0) { - self.init( - .sRGB, - red: Double((hex >> 16) & 0xFF) / 255.0, - green: Double((hex >> 8) & 0xFF) / 255.0, - blue: Double(hex & 0xFF) / 255.0, - opacity: alpha - ) - } -} diff --git a/vendor/bytelyst/design-tokens/generated/native/LysnrAITokens.generated.kt b/vendor/bytelyst/design-tokens/generated/native/LysnrAITokens.generated.kt deleted file mode 100644 index 1fffbf3..0000000 --- a/vendor/bytelyst/design-tokens/generated/native/LysnrAITokens.generated.kt +++ /dev/null @@ -1,91 +0,0 @@ -// Auto-generated from bytelyst.tokens.json — do not edit manually. -// Product: lysnrai -// Regenerate: cd packages/design-tokens && tsx scripts/generate.ts -package com.saravana.lysnrai.theme - -object LysnrAITokens { - - // ── Semantic Colors (Dark Theme) ───────────────────────────────── - object Dark { - const val BG_CANVAS = 0xFF06070A - const val BG_ELEVATED = 0xFF0E1118 - const val SURFACE_CARD = 0xFF121725 - const val SURFACE_MUTED = 0xFF1A2335 - const val TEXT_PRIMARY = 0xFFEFF4FF - const val TEXT_SECONDARY = 0xFFA5B1C7 - const val TEXT_TERTIARY = 0xFF6C7C98 - const val ACCENT_PRIMARY = 0xFF5A8CFF - const val ACCENT_SECONDARY = 0xFF2EE6D6 - const val SUCCESS = 0xFF34D399 - const val WARNING = 0xFFF59E0B - const val DANGER = 0xFFFF6E6E - } - - // ── Semantic Colors (Light Theme) ──────────────────────────────── - object Light { - const val BG_CANVAS = 0xFFF6F8FC - const val BG_ELEVATED = 0xFFEEF2FA - const val SURFACE_CARD = 0xFFFFFFFF - const val SURFACE_MUTED = 0xFFF3F5FA - const val TEXT_PRIMARY = 0xFF0E1320 - const val TEXT_SECONDARY = 0xFF55637A - const val TEXT_TERTIARY = 0xFF6C7C98 - const val ACCENT_PRIMARY = 0xFF5A8CFF - const val ACCENT_SECONDARY = 0xFF2EE6D6 - const val SUCCESS = 0xFF13956A - const val WARNING = 0xFFB87504 - const val DANGER = 0xFFD24242 - } - - // ── Lysnrai Product Colors ─────────────────────────────── - object Product { - const val RECORDING_ACTIVE = 0xFFFF6E6E - const val RECORDING_PAUSED = 0xFFFFD166 - const val PROCESSING = 0xFF5A8CFF - const val TRANSCRIBED = 0xFF34D399 - const val DICTATION_MODE = 0xFF7C6BFF - const val COMMAND_MODE = 0xFF2EE6D6 - const val HOTKEY_ACTIVE = 0xFFFF6B8A - } - - // ── Spacing (8pt grid) ─────────────────────────────────────────── - object Spacing { - const val X0 = 0 - const val X1 = 4 - const val X2 = 8 - const val X3 = 12 - const val X4 = 16 - const val X5 = 20 - const val X6 = 24 - const val X7 = 28 - const val X8 = 32 - const val X10 = 40 - const val X12 = 48 - const val X16 = 64 - } - - // ── Radius ─────────────────────────────────────────────────────── - object Radius { - const val XS = 8 - const val SM = 12 - const val MD = 16 - const val LG = 20 - const val XL = 24 - const val PILL = 999 - } - - // ── Typography ─────────────────────────────────────────────────── - object Typography { - const val FONT_DISPLAY = "Space Grotesk" - const val FONT_BODY = "DM Sans" - const val FONT_MONO = "IBM Plex Mono" - } - - // ── Motion ─────────────────────────────────────────────────────── - object Motion { - const val INSTANT = 70 - const val FAST = 140 - const val BASE = 220 - const val SLOW = 320 - } -} diff --git a/vendor/bytelyst/design-tokens/generated/native/NomGapTheme.generated.swift b/vendor/bytelyst/design-tokens/generated/native/NomGapTheme.generated.swift deleted file mode 100644 index c681d96..0000000 --- a/vendor/bytelyst/design-tokens/generated/native/NomGapTheme.generated.swift +++ /dev/null @@ -1,95 +0,0 @@ -// Auto-generated from bytelyst.tokens.json — do not edit manually. -// Product: nomgap -// Regenerate: cd packages/design-tokens && tsx scripts/generate.ts - -import SwiftUI - -enum NomGapColors { - // MARK: - Semantic (Dark Theme) - static let bgCanvas = Color(hex: 0x06070A) - static let bgElevated = Color(hex: 0x0E1118) - static let surfaceCard = Color(hex: 0x121725) - static let surfaceMuted = Color(hex: 0x1A2335) - static let textPrimary = Color(hex: 0xEFF4FF) - static let textSecondary = Color(hex: 0xA5B1C7) - static let textTertiary = Color(hex: 0x6C7C98) - static let accentPrimary = Color(hex: 0x5A8CFF) - static let accentSecondary = Color(hex: 0x2EE6D6) - static let success = Color(hex: 0x34D399) - static let warning = Color(hex: 0xF59E0B) - static let danger = Color(hex: 0xFF6E6E) - - // MARK: - Nomgap Product Colors - static let stageFed = Color(hex: 0xFF9F43) - static let stageEarlyFast = Color(hex: 0xFECA57) - static let stageFasted = Color(hex: 0x48DBFB) - static let stageKetosis = Color(hex: 0x5A8CFF) - static let stageDeepAutophagy = Color(hex: 0xA66BFF) - static let stageExtended = Color(hex: 0xFFD700) - static let autophagyMeter = Color(hex: 0x5AE68C) - static let hydrationReminder = Color(hex: 0x48DBFB) - static let electrolyteAlert = Color(hex: 0xFF9F43) - static let safetyWarning = Color(hex: 0xFF6E6E) - -} - -enum NomGapColorsLight { - // MARK: - Semantic (Light Theme) - static let bgCanvas = Color(hex: 0xF6F8FC) - static let bgElevated = Color(hex: 0xEEF2FA) - static let surfaceCard = Color(hex: 0xFFFFFF) - static let surfaceMuted = Color(hex: 0xF3F5FA) - static let textPrimary = Color(hex: 0x0E1320) - static let textSecondary = Color(hex: 0x55637A) - static let textTertiary = Color(hex: 0x6C7C98) - static let accentPrimary = Color(hex: 0x5A8CFF) - static let accentSecondary = Color(hex: 0x2EE6D6) - static let success = Color(hex: 0x13956A) - static let warning = Color(hex: 0xB87504) - static let danger = Color(hex: 0xD24242) -} - -enum NomGapSpacing { - static let x0: CGFloat = 0 - static let x1: CGFloat = 4 - static let x2: CGFloat = 8 - static let x3: CGFloat = 12 - static let x4: CGFloat = 16 - static let x5: CGFloat = 20 - static let x6: CGFloat = 24 - static let x7: CGFloat = 28 - static let x8: CGFloat = 32 - static let x10: CGFloat = 40 - static let x12: CGFloat = 48 - static let x16: CGFloat = 64 -} - -enum NomGapRadius { - static let xs: CGFloat = 8 - static let sm: CGFloat = 12 - static let md: CGFloat = 16 - static let lg: CGFloat = 20 - static let xl: CGFloat = 24 - static let pill: CGFloat = 999 -} - -enum NomGapMotion { - static let instant: Double = 0.07 - static let fast: Double = 0.14 - static let base: Double = 0.22 - static let slow: Double = 0.32 -} - -// MARK: - Color Hex Extension (import if not already defined) - -extension Color { - init(hex: UInt, alpha: Double = 1.0) { - self.init( - .sRGB, - red: Double((hex >> 16) & 0xFF) / 255.0, - green: Double((hex >> 8) & 0xFF) / 255.0, - blue: Double(hex & 0xFF) / 255.0, - opacity: alpha - ) - } -} diff --git a/vendor/bytelyst/design-tokens/generated/native/NomGapTokens.generated.kt b/vendor/bytelyst/design-tokens/generated/native/NomGapTokens.generated.kt deleted file mode 100644 index d7c9964..0000000 --- a/vendor/bytelyst/design-tokens/generated/native/NomGapTokens.generated.kt +++ /dev/null @@ -1,94 +0,0 @@ -// Auto-generated from bytelyst.tokens.json — do not edit manually. -// Product: nomgap -// Regenerate: cd packages/design-tokens && tsx scripts/generate.ts -package com.nomgap.theme - -object NomGapTokens { - - // ── Semantic Colors (Dark Theme) ───────────────────────────────── - object Dark { - const val BG_CANVAS = 0xFF06070A - const val BG_ELEVATED = 0xFF0E1118 - const val SURFACE_CARD = 0xFF121725 - const val SURFACE_MUTED = 0xFF1A2335 - const val TEXT_PRIMARY = 0xFFEFF4FF - const val TEXT_SECONDARY = 0xFFA5B1C7 - const val TEXT_TERTIARY = 0xFF6C7C98 - const val ACCENT_PRIMARY = 0xFF5A8CFF - const val ACCENT_SECONDARY = 0xFF2EE6D6 - const val SUCCESS = 0xFF34D399 - const val WARNING = 0xFFF59E0B - const val DANGER = 0xFFFF6E6E - } - - // ── Semantic Colors (Light Theme) ──────────────────────────────── - object Light { - const val BG_CANVAS = 0xFFF6F8FC - const val BG_ELEVATED = 0xFFEEF2FA - const val SURFACE_CARD = 0xFFFFFFFF - const val SURFACE_MUTED = 0xFFF3F5FA - const val TEXT_PRIMARY = 0xFF0E1320 - const val TEXT_SECONDARY = 0xFF55637A - const val TEXT_TERTIARY = 0xFF6C7C98 - const val ACCENT_PRIMARY = 0xFF5A8CFF - const val ACCENT_SECONDARY = 0xFF2EE6D6 - const val SUCCESS = 0xFF13956A - const val WARNING = 0xFFB87504 - const val DANGER = 0xFFD24242 - } - - // ── Nomgap Product Colors ─────────────────────────────── - object Product { - const val STAGE_FED = 0xFFFF9F43 - const val STAGE_EARLY_FAST = 0xFFFECA57 - const val STAGE_FASTED = 0xFF48DBFB - const val STAGE_KETOSIS = 0xFF5A8CFF - const val STAGE_DEEP_AUTOPHAGY = 0xFFA66BFF - const val STAGE_EXTENDED = 0xFFFFD700 - const val AUTOPHAGY_METER = 0xFF5AE68C - const val HYDRATION_REMINDER = 0xFF48DBFB - const val ELECTROLYTE_ALERT = 0xFFFF9F43 - const val SAFETY_WARNING = 0xFFFF6E6E - } - - // ── Spacing (8pt grid) ─────────────────────────────────────────── - object Spacing { - const val X0 = 0 - const val X1 = 4 - const val X2 = 8 - const val X3 = 12 - const val X4 = 16 - const val X5 = 20 - const val X6 = 24 - const val X7 = 28 - const val X8 = 32 - const val X10 = 40 - const val X12 = 48 - const val X16 = 64 - } - - // ── Radius ─────────────────────────────────────────────────────── - object Radius { - const val XS = 8 - const val SM = 12 - const val MD = 16 - const val LG = 20 - const val XL = 24 - const val PILL = 999 - } - - // ── Typography ─────────────────────────────────────────────────── - object Typography { - const val FONT_DISPLAY = "Space Grotesk" - const val FONT_BODY = "DM Sans" - const val FONT_MONO = "IBM Plex Mono" - } - - // ── Motion ─────────────────────────────────────────────────────── - object Motion { - const val INSTANT = 70 - const val FAST = 140 - const val BASE = 220 - const val SLOW = 320 - } -} diff --git a/vendor/bytelyst/design-tokens/generated/native/NoteLettTheme.generated.swift b/vendor/bytelyst/design-tokens/generated/native/NoteLettTheme.generated.swift deleted file mode 100644 index eacca13..0000000 --- a/vendor/bytelyst/design-tokens/generated/native/NoteLettTheme.generated.swift +++ /dev/null @@ -1,100 +0,0 @@ -// Auto-generated from bytelyst.tokens.json — do not edit manually. -// Product: notelett -// Regenerate: cd packages/design-tokens && tsx scripts/generate.ts - -import SwiftUI - -enum NoteLettColors { - // MARK: - Semantic (Dark Theme) - static let bgCanvas = Color(hex: 0x06070A) - static let bgElevated = Color(hex: 0x0E1118) - static let surfaceCard = Color(hex: 0x121725) - static let surfaceMuted = Color(hex: 0x1A2335) - static let textPrimary = Color(hex: 0xEFF4FF) - static let textSecondary = Color(hex: 0xA5B1C7) - static let textTertiary = Color(hex: 0x6C7C98) - static let accentPrimary = Color(hex: 0x5A8CFF) - static let accentSecondary = Color(hex: 0x2EE6D6) - static let success = Color(hex: 0x34D399) - static let warning = Color(hex: 0xF59E0B) - static let danger = Color(hex: 0xFF6E6E) - - // MARK: - Notelett Product Colors - static let bgCanvas = Color(hex: 0x06070A) - static let bgElevated = Color(hex: 0x0E1118) - static let surfaceCard = Color(hex: 0x121725) - static let surfaceMuted = Color(hex: 0x1A2335) - static let accentPrimary = Color(hex: 0x5A8CFF) - static let accentSecondary = Color(hex: 0x2EE6D6) - static let success = Color(hex: 0x34D399) - static let warning = Color(hex: 0xF59E0B) - static let danger = Color(hex: 0xFF6E6E) - static let focusRing = Color(hex: 0x5A8CFF) - static let agentAction = Color(hex: 0xA66BFF) - static let draftNote = Color(hex: 0xFFD166) - static let linkedNote = Color(hex: 0x2EE6D6) - static let taskPending = Color(hex: 0xF59E0B) - static let taskComplete = Color(hex: 0x34D399) - -} - -enum NoteLettColorsLight { - // MARK: - Semantic (Light Theme) - static let bgCanvas = Color(hex: 0xF6F8FC) - static let bgElevated = Color(hex: 0xEEF2FA) - static let surfaceCard = Color(hex: 0xFFFFFF) - static let surfaceMuted = Color(hex: 0xF3F5FA) - static let textPrimary = Color(hex: 0x0E1320) - static let textSecondary = Color(hex: 0x55637A) - static let textTertiary = Color(hex: 0x6C7C98) - static let accentPrimary = Color(hex: 0x5A8CFF) - static let accentSecondary = Color(hex: 0x2EE6D6) - static let success = Color(hex: 0x13956A) - static let warning = Color(hex: 0xB87504) - static let danger = Color(hex: 0xD24242) -} - -enum NoteLettSpacing { - static let x0: CGFloat = 0 - static let x1: CGFloat = 4 - static let x2: CGFloat = 8 - static let x3: CGFloat = 12 - static let x4: CGFloat = 16 - static let x5: CGFloat = 20 - static let x6: CGFloat = 24 - static let x7: CGFloat = 28 - static let x8: CGFloat = 32 - static let x10: CGFloat = 40 - static let x12: CGFloat = 48 - static let x16: CGFloat = 64 -} - -enum NoteLettRadius { - static let xs: CGFloat = 8 - static let sm: CGFloat = 12 - static let md: CGFloat = 16 - static let lg: CGFloat = 20 - static let xl: CGFloat = 24 - static let pill: CGFloat = 999 -} - -enum NoteLettMotion { - static let instant: Double = 0.07 - static let fast: Double = 0.14 - static let base: Double = 0.22 - static let slow: Double = 0.32 -} - -// MARK: - Color Hex Extension (import if not already defined) - -extension Color { - init(hex: UInt, alpha: Double = 1.0) { - self.init( - .sRGB, - red: Double((hex >> 16) & 0xFF) / 255.0, - green: Double((hex >> 8) & 0xFF) / 255.0, - blue: Double(hex & 0xFF) / 255.0, - opacity: alpha - ) - } -} diff --git a/vendor/bytelyst/design-tokens/generated/native/NoteLettTokens.generated.kt b/vendor/bytelyst/design-tokens/generated/native/NoteLettTokens.generated.kt deleted file mode 100644 index 700b3cc..0000000 --- a/vendor/bytelyst/design-tokens/generated/native/NoteLettTokens.generated.kt +++ /dev/null @@ -1,99 +0,0 @@ -// Auto-generated from bytelyst.tokens.json — do not edit manually. -// Product: notelett -// Regenerate: cd packages/design-tokens && tsx scripts/generate.ts -package com.notelett.theme - -object NoteLettTokens { - - // ── Semantic Colors (Dark Theme) ───────────────────────────────── - object Dark { - const val BG_CANVAS = 0xFF06070A - const val BG_ELEVATED = 0xFF0E1118 - const val SURFACE_CARD = 0xFF121725 - const val SURFACE_MUTED = 0xFF1A2335 - const val TEXT_PRIMARY = 0xFFEFF4FF - const val TEXT_SECONDARY = 0xFFA5B1C7 - const val TEXT_TERTIARY = 0xFF6C7C98 - const val ACCENT_PRIMARY = 0xFF5A8CFF - const val ACCENT_SECONDARY = 0xFF2EE6D6 - const val SUCCESS = 0xFF34D399 - const val WARNING = 0xFFF59E0B - const val DANGER = 0xFFFF6E6E - } - - // ── Semantic Colors (Light Theme) ──────────────────────────────── - object Light { - const val BG_CANVAS = 0xFFF6F8FC - const val BG_ELEVATED = 0xFFEEF2FA - const val SURFACE_CARD = 0xFFFFFFFF - const val SURFACE_MUTED = 0xFFF3F5FA - const val TEXT_PRIMARY = 0xFF0E1320 - const val TEXT_SECONDARY = 0xFF55637A - const val TEXT_TERTIARY = 0xFF6C7C98 - const val ACCENT_PRIMARY = 0xFF5A8CFF - const val ACCENT_SECONDARY = 0xFF2EE6D6 - const val SUCCESS = 0xFF13956A - const val WARNING = 0xFFB87504 - const val DANGER = 0xFFD24242 - } - - // ── Notelett Product Colors ─────────────────────────────── - object Product { - const val BG_CANVAS = 0xFF06070A - const val BG_ELEVATED = 0xFF0E1118 - const val SURFACE_CARD = 0xFF121725 - const val SURFACE_MUTED = 0xFF1A2335 - const val ACCENT_PRIMARY = 0xFF5A8CFF - const val ACCENT_SECONDARY = 0xFF2EE6D6 - const val SUCCESS = 0xFF34D399 - const val WARNING = 0xFFF59E0B - const val DANGER = 0xFFFF6E6E - const val FOCUS_RING = 0xFF5A8CFF - const val AGENT_ACTION = 0xFFA66BFF - const val DRAFT_NOTE = 0xFFFFD166 - const val LINKED_NOTE = 0xFF2EE6D6 - const val TASK_PENDING = 0xFFF59E0B - const val TASK_COMPLETE = 0xFF34D399 - } - - // ── Spacing (8pt grid) ─────────────────────────────────────────── - object Spacing { - const val X0 = 0 - const val X1 = 4 - const val X2 = 8 - const val X3 = 12 - const val X4 = 16 - const val X5 = 20 - const val X6 = 24 - const val X7 = 28 - const val X8 = 32 - const val X10 = 40 - const val X12 = 48 - const val X16 = 64 - } - - // ── Radius ─────────────────────────────────────────────────────── - object Radius { - const val XS = 8 - const val SM = 12 - const val MD = 16 - const val LG = 20 - const val XL = 24 - const val PILL = 999 - } - - // ── Typography ─────────────────────────────────────────────────── - object Typography { - const val FONT_DISPLAY = "Space Grotesk" - const val FONT_BODY = "DM Sans" - const val FONT_MONO = "IBM Plex Mono" - } - - // ── Motion ─────────────────────────────────────────────────────── - object Motion { - const val INSTANT = 70 - const val FAST = 140 - const val BASE = 220 - const val SLOW = 320 - } -} diff --git a/vendor/bytelyst/design-tokens/generated/native/PeakPulseTheme.generated.swift b/vendor/bytelyst/design-tokens/generated/native/PeakPulseTheme.generated.swift deleted file mode 100644 index ffbec17..0000000 --- a/vendor/bytelyst/design-tokens/generated/native/PeakPulseTheme.generated.swift +++ /dev/null @@ -1,95 +0,0 @@ -// Auto-generated from bytelyst.tokens.json — do not edit manually. -// Product: peakpulse -// Regenerate: cd packages/design-tokens && tsx scripts/generate.ts - -import SwiftUI - -enum PeakPulseColors { - // MARK: - Semantic (Dark Theme) - static let bgCanvas = Color(hex: 0x06070A) - static let bgElevated = Color(hex: 0x0E1118) - static let surfaceCard = Color(hex: 0x121725) - static let surfaceMuted = Color(hex: 0x1A2335) - static let textPrimary = Color(hex: 0xEFF4FF) - static let textSecondary = Color(hex: 0xA5B1C7) - static let textTertiary = Color(hex: 0x6C7C98) - static let accentPrimary = Color(hex: 0x5A8CFF) - static let accentSecondary = Color(hex: 0x2EE6D6) - static let success = Color(hex: 0x34D399) - static let warning = Color(hex: 0xF59E0B) - static let danger = Color(hex: 0xFF6E6E) - - // MARK: - Peakpulse Product Colors - static let activityHike = Color(hex: 0x34D399) - static let activitySki = Color(hex: 0x5A8CFF) - static let speedZoneSlow = Color(hex: 0x34D399) - static let speedZoneFast = Color(hex: 0xFFD166) - static let speedZoneDanger = Color(hex: 0xFF6E6E) - static let elevationGain = Color(hex: 0x2EE6D6) - static let elevationLoss = Color(hex: 0xFF9F43) - static let personalBest = Color(hex: 0xFFD700) - static let streakActive = Color(hex: 0x34D399) - static let streakBroken = Color(hex: 0xFF6E6E) - -} - -enum PeakPulseColorsLight { - // MARK: - Semantic (Light Theme) - static let bgCanvas = Color(hex: 0xF6F8FC) - static let bgElevated = Color(hex: 0xEEF2FA) - static let surfaceCard = Color(hex: 0xFFFFFF) - static let surfaceMuted = Color(hex: 0xF3F5FA) - static let textPrimary = Color(hex: 0x0E1320) - static let textSecondary = Color(hex: 0x55637A) - static let textTertiary = Color(hex: 0x6C7C98) - static let accentPrimary = Color(hex: 0x5A8CFF) - static let accentSecondary = Color(hex: 0x2EE6D6) - static let success = Color(hex: 0x13956A) - static let warning = Color(hex: 0xB87504) - static let danger = Color(hex: 0xD24242) -} - -enum PeakPulseSpacing { - static let x0: CGFloat = 0 - static let x1: CGFloat = 4 - static let x2: CGFloat = 8 - static let x3: CGFloat = 12 - static let x4: CGFloat = 16 - static let x5: CGFloat = 20 - static let x6: CGFloat = 24 - static let x7: CGFloat = 28 - static let x8: CGFloat = 32 - static let x10: CGFloat = 40 - static let x12: CGFloat = 48 - static let x16: CGFloat = 64 -} - -enum PeakPulseRadius { - static let xs: CGFloat = 8 - static let sm: CGFloat = 12 - static let md: CGFloat = 16 - static let lg: CGFloat = 20 - static let xl: CGFloat = 24 - static let pill: CGFloat = 999 -} - -enum PeakPulseMotion { - static let instant: Double = 0.07 - static let fast: Double = 0.14 - static let base: Double = 0.22 - static let slow: Double = 0.32 -} - -// MARK: - Color Hex Extension (import if not already defined) - -extension Color { - init(hex: UInt, alpha: Double = 1.0) { - self.init( - .sRGB, - red: Double((hex >> 16) & 0xFF) / 255.0, - green: Double((hex >> 8) & 0xFF) / 255.0, - blue: Double(hex & 0xFF) / 255.0, - opacity: alpha - ) - } -} diff --git a/vendor/bytelyst/design-tokens/generated/native/PeakPulseTokens.generated.kt b/vendor/bytelyst/design-tokens/generated/native/PeakPulseTokens.generated.kt deleted file mode 100644 index 2848bdd..0000000 --- a/vendor/bytelyst/design-tokens/generated/native/PeakPulseTokens.generated.kt +++ /dev/null @@ -1,94 +0,0 @@ -// Auto-generated from bytelyst.tokens.json — do not edit manually. -// Product: peakpulse -// Regenerate: cd packages/design-tokens && tsx scripts/generate.ts -package com.peakpulse.theme - -object PeakPulseTokens { - - // ── Semantic Colors (Dark Theme) ───────────────────────────────── - object Dark { - const val BG_CANVAS = 0xFF06070A - const val BG_ELEVATED = 0xFF0E1118 - const val SURFACE_CARD = 0xFF121725 - const val SURFACE_MUTED = 0xFF1A2335 - const val TEXT_PRIMARY = 0xFFEFF4FF - const val TEXT_SECONDARY = 0xFFA5B1C7 - const val TEXT_TERTIARY = 0xFF6C7C98 - const val ACCENT_PRIMARY = 0xFF5A8CFF - const val ACCENT_SECONDARY = 0xFF2EE6D6 - const val SUCCESS = 0xFF34D399 - const val WARNING = 0xFFF59E0B - const val DANGER = 0xFFFF6E6E - } - - // ── Semantic Colors (Light Theme) ──────────────────────────────── - object Light { - const val BG_CANVAS = 0xFFF6F8FC - const val BG_ELEVATED = 0xFFEEF2FA - const val SURFACE_CARD = 0xFFFFFFFF - const val SURFACE_MUTED = 0xFFF3F5FA - const val TEXT_PRIMARY = 0xFF0E1320 - const val TEXT_SECONDARY = 0xFF55637A - const val TEXT_TERTIARY = 0xFF6C7C98 - const val ACCENT_PRIMARY = 0xFF5A8CFF - const val ACCENT_SECONDARY = 0xFF2EE6D6 - const val SUCCESS = 0xFF13956A - const val WARNING = 0xFFB87504 - const val DANGER = 0xFFD24242 - } - - // ── Peakpulse Product Colors ─────────────────────────────── - object Product { - const val ACTIVITY_HIKE = 0xFF34D399 - const val ACTIVITY_SKI = 0xFF5A8CFF - const val SPEED_ZONE_SLOW = 0xFF34D399 - const val SPEED_ZONE_FAST = 0xFFFFD166 - const val SPEED_ZONE_DANGER = 0xFFFF6E6E - const val ELEVATION_GAIN = 0xFF2EE6D6 - const val ELEVATION_LOSS = 0xFFFF9F43 - const val PERSONAL_BEST = 0xFFFFD700 - const val STREAK_ACTIVE = 0xFF34D399 - const val STREAK_BROKEN = 0xFFFF6E6E - } - - // ── Spacing (8pt grid) ─────────────────────────────────────────── - object Spacing { - const val X0 = 0 - const val X1 = 4 - const val X2 = 8 - const val X3 = 12 - const val X4 = 16 - const val X5 = 20 - const val X6 = 24 - const val X7 = 28 - const val X8 = 32 - const val X10 = 40 - const val X12 = 48 - const val X16 = 64 - } - - // ── Radius ─────────────────────────────────────────────────────── - object Radius { - const val XS = 8 - const val SM = 12 - const val MD = 16 - const val LG = 20 - const val XL = 24 - const val PILL = 999 - } - - // ── Typography ─────────────────────────────────────────────────── - object Typography { - const val FONT_DISPLAY = "Space Grotesk" - const val FONT_BODY = "DM Sans" - const val FONT_MONO = "IBM Plex Mono" - } - - // ── Motion ─────────────────────────────────────────────────────── - object Motion { - const val INSTANT = 70 - const val FAST = 140 - const val BASE = 220 - const val SLOW = 320 - } -} diff --git a/vendor/bytelyst/design-tokens/generated/nomgap.css b/vendor/bytelyst/design-tokens/generated/nomgap.css deleted file mode 100644 index 18294b6..0000000 --- a/vendor/bytelyst/design-tokens/generated/nomgap.css +++ /dev/null @@ -1,89 +0,0 @@ -/* Auto-generated nomgap tokens from bytelyst.tokens.json — do not edit manually */ - -:root { - --ng-bg-canvas: #06070A; - --ng-bg-elevated: #0E1118; - --ng-surface-card: #121725; - --ng-surface-muted: #1A2335; - --ng-border-default: rgba(255,255,255,0.12); - --ng-border-strong: rgba(255,255,255,0.22); - --ng-text-primary: #EFF4FF; - --ng-text-secondary: #A5B1C7; - --ng-text-tertiary: #6C7C98; - --ng-accent-primary: #5A8CFF; - --ng-accent-secondary: #2EE6D6; - --ng-success: #34D399; - --ng-warning: #F59E0B; - --ng-danger: #FF6E6E; - --ng-focus-ring: rgba(90,140,255,0.45); - --ng-overlay-scrim: rgba(5,8,18,0.72); - - /* nomgap product colors */ - --ng-stage-fed: #FF9F43; - --ng-stage-early-fast: #FECA57; - --ng-stage-fasted: #48DBFB; - --ng-stage-ketosis: #5A8CFF; - --ng-stage-deep-autophagy: #A66BFF; - --ng-stage-extended: #FFD700; - --ng-autophagy-meter: #5AE68C; - --ng-hydration-reminder: #48DBFB; - --ng-electrolyte-alert: #FF9F43; - --ng-safety-warning: #FF6E6E; - - --ng-font-display: "Space Grotesk", "SF Pro Display", sans-serif; - --ng-font-body: "DM Sans", "SF Pro Text", sans-serif; - --ng-font-mono: "IBM Plex Mono", "SF Mono", monospace; - - --ng-fs-xs: 12px; - --ng-fs-sm: 14px; - --ng-fs-md: 16px; - --ng-fs-lg: 18px; - --ng-fs-xl: 22px; - --ng-fs-2xl: 28px; - --ng-fs-3xl: 36px; - - --ng-space-0: 0; - --ng-space-1: 4px; - --ng-space-2: 8px; - --ng-space-3: 12px; - --ng-space-4: 16px; - --ng-space-5: 20px; - --ng-space-6: 24px; - --ng-space-7: 28px; - --ng-space-8: 32px; - --ng-space-10: 40px; - --ng-space-12: 48px; - --ng-space-16: 64px; - - --ng-radius-xs: 8px; - --ng-radius-sm: 12px; - --ng-radius-md: 16px; - --ng-radius-lg: 20px; - --ng-radius-xl: 24px; - --ng-radius-pill: 999px; - - --ng-elevation-sm: 0 4px 12px rgba(0,0,0,0.12); - --ng-elevation-md: 0 12px 28px rgba(0,0,0,0.18); - --ng-elevation-lg: 0 20px 48px rgba(0,0,0,0.24); - - --ng-motion-fast: 140ms; - --ng-motion-base: 220ms; - --ng-motion-slow: 320ms; - --ng-easing-standard: cubic-bezier(0.2, 0.0, 0.2, 1); -} - -[data-theme="light"] { - --ng-bg-canvas: #F6F8FC; - --ng-bg-elevated: #EEF2FA; - --ng-surface-card: #FFFFFF; - --ng-surface-muted: #F3F5FA; - --ng-border-default: rgba(14,19,32,0.12); - --ng-border-strong: rgba(14,19,32,0.24); - --ng-text-primary: #0E1320; - --ng-text-secondary: #55637A; - --ng-success: #13956A; - --ng-warning: #B87504; - --ng-danger: #D24242; - --ng-focus-ring: rgba(90,140,255,0.35); - --ng-overlay-scrim: rgba(10,13,23,0.5); -} diff --git a/vendor/bytelyst/design-tokens/generated/notelett.css b/vendor/bytelyst/design-tokens/generated/notelett.css deleted file mode 100644 index c81806f..0000000 --- a/vendor/bytelyst/design-tokens/generated/notelett.css +++ /dev/null @@ -1,94 +0,0 @@ -/* Auto-generated notelett tokens from bytelyst.tokens.json — do not edit manually */ - -:root { - --nl-bg-canvas: #06070A; - --nl-bg-elevated: #0E1118; - --nl-surface-card: #121725; - --nl-surface-muted: #1A2335; - --nl-border-default: rgba(255,255,255,0.12); - --nl-border-strong: rgba(255,255,255,0.22); - --nl-text-primary: #EFF4FF; - --nl-text-secondary: #A5B1C7; - --nl-text-tertiary: #6C7C98; - --nl-accent-primary: #5A8CFF; - --nl-accent-secondary: #2EE6D6; - --nl-success: #34D399; - --nl-warning: #F59E0B; - --nl-danger: #FF6E6E; - --nl-focus-ring: rgba(90,140,255,0.45); - --nl-overlay-scrim: rgba(5,8,18,0.72); - - /* notelett product colors */ - --nl-bg-canvas: #06070A; - --nl-bg-elevated: #0E1118; - --nl-surface-card: #121725; - --nl-surface-muted: #1A2335; - --nl-accent-primary: #5A8CFF; - --nl-accent-secondary: #2EE6D6; - --nl-success: #34D399; - --nl-warning: #F59E0B; - --nl-danger: #FF6E6E; - --nl-focus-ring: #5A8CFF; - --nl-agent-action: #A66BFF; - --nl-draft-note: #FFD166; - --nl-linked-note: #2EE6D6; - --nl-task-pending: #F59E0B; - --nl-task-complete: #34D399; - - --nl-font-display: "Space Grotesk", "SF Pro Display", sans-serif; - --nl-font-body: "DM Sans", "SF Pro Text", sans-serif; - --nl-font-mono: "IBM Plex Mono", "SF Mono", monospace; - - --nl-fs-xs: 12px; - --nl-fs-sm: 14px; - --nl-fs-md: 16px; - --nl-fs-lg: 18px; - --nl-fs-xl: 22px; - --nl-fs-2xl: 28px; - --nl-fs-3xl: 36px; - - --nl-space-0: 0; - --nl-space-1: 4px; - --nl-space-2: 8px; - --nl-space-3: 12px; - --nl-space-4: 16px; - --nl-space-5: 20px; - --nl-space-6: 24px; - --nl-space-7: 28px; - --nl-space-8: 32px; - --nl-space-10: 40px; - --nl-space-12: 48px; - --nl-space-16: 64px; - - --nl-radius-xs: 8px; - --nl-radius-sm: 12px; - --nl-radius-md: 16px; - --nl-radius-lg: 20px; - --nl-radius-xl: 24px; - --nl-radius-pill: 999px; - - --nl-elevation-sm: 0 4px 12px rgba(0,0,0,0.12); - --nl-elevation-md: 0 12px 28px rgba(0,0,0,0.18); - --nl-elevation-lg: 0 20px 48px rgba(0,0,0,0.24); - - --nl-motion-fast: 140ms; - --nl-motion-base: 220ms; - --nl-motion-slow: 320ms; - --nl-easing-standard: cubic-bezier(0.2, 0.0, 0.2, 1); -} - -[data-theme="light"] { - --nl-bg-canvas: #F6F8FC; - --nl-bg-elevated: #EEF2FA; - --nl-surface-card: #FFFFFF; - --nl-surface-muted: #F3F5FA; - --nl-border-default: rgba(14,19,32,0.12); - --nl-border-strong: rgba(14,19,32,0.24); - --nl-text-primary: #0E1320; - --nl-text-secondary: #55637A; - --nl-success: #13956A; - --nl-warning: #B87504; - --nl-danger: #D24242; - --nl-focus-ring: rgba(90,140,255,0.35); - --nl-overlay-scrim: rgba(10,13,23,0.5); -} diff --git a/vendor/bytelyst/design-tokens/generated/peakpulse.css b/vendor/bytelyst/design-tokens/generated/peakpulse.css deleted file mode 100644 index 24cf13a..0000000 --- a/vendor/bytelyst/design-tokens/generated/peakpulse.css +++ /dev/null @@ -1,89 +0,0 @@ -/* Auto-generated peakpulse tokens from bytelyst.tokens.json — do not edit manually */ - -:root { - --pp-bg-canvas: #06070A; - --pp-bg-elevated: #0E1118; - --pp-surface-card: #121725; - --pp-surface-muted: #1A2335; - --pp-border-default: rgba(255,255,255,0.12); - --pp-border-strong: rgba(255,255,255,0.22); - --pp-text-primary: #EFF4FF; - --pp-text-secondary: #A5B1C7; - --pp-text-tertiary: #6C7C98; - --pp-accent-primary: #5A8CFF; - --pp-accent-secondary: #2EE6D6; - --pp-success: #34D399; - --pp-warning: #F59E0B; - --pp-danger: #FF6E6E; - --pp-focus-ring: rgba(90,140,255,0.45); - --pp-overlay-scrim: rgba(5,8,18,0.72); - - /* peakpulse product colors */ - --pp-activity-hike: #34D399; - --pp-activity-ski: #5A8CFF; - --pp-speed-zone-slow: #34D399; - --pp-speed-zone-fast: #FFD166; - --pp-speed-zone-danger: #FF6E6E; - --pp-elevation-gain: #2EE6D6; - --pp-elevation-loss: #FF9F43; - --pp-personal-best: #FFD700; - --pp-streak-active: #34D399; - --pp-streak-broken: #FF6E6E; - - --pp-font-display: "Space Grotesk", "SF Pro Display", sans-serif; - --pp-font-body: "DM Sans", "SF Pro Text", sans-serif; - --pp-font-mono: "IBM Plex Mono", "SF Mono", monospace; - - --pp-fs-xs: 12px; - --pp-fs-sm: 14px; - --pp-fs-md: 16px; - --pp-fs-lg: 18px; - --pp-fs-xl: 22px; - --pp-fs-2xl: 28px; - --pp-fs-3xl: 36px; - - --pp-space-0: 0; - --pp-space-1: 4px; - --pp-space-2: 8px; - --pp-space-3: 12px; - --pp-space-4: 16px; - --pp-space-5: 20px; - --pp-space-6: 24px; - --pp-space-7: 28px; - --pp-space-8: 32px; - --pp-space-10: 40px; - --pp-space-12: 48px; - --pp-space-16: 64px; - - --pp-radius-xs: 8px; - --pp-radius-sm: 12px; - --pp-radius-md: 16px; - --pp-radius-lg: 20px; - --pp-radius-xl: 24px; - --pp-radius-pill: 999px; - - --pp-elevation-sm: 0 4px 12px rgba(0,0,0,0.12); - --pp-elevation-md: 0 12px 28px rgba(0,0,0,0.18); - --pp-elevation-lg: 0 20px 48px rgba(0,0,0,0.24); - - --pp-motion-fast: 140ms; - --pp-motion-base: 220ms; - --pp-motion-slow: 320ms; - --pp-easing-standard: cubic-bezier(0.2, 0.0, 0.2, 1); -} - -[data-theme="light"] { - --pp-bg-canvas: #F6F8FC; - --pp-bg-elevated: #EEF2FA; - --pp-surface-card: #FFFFFF; - --pp-surface-muted: #F3F5FA; - --pp-border-default: rgba(14,19,32,0.12); - --pp-border-strong: rgba(14,19,32,0.24); - --pp-text-primary: #0E1320; - --pp-text-secondary: #55637A; - --pp-success: #13956A; - --pp-warning: #B87504; - --pp-danger: #D24242; - --pp-focus-ring: rgba(90,140,255,0.35); - --pp-overlay-scrim: rgba(10,13,23,0.5); -} diff --git a/vendor/bytelyst/design-tokens/generated/react-native/tokens.ts b/vendor/bytelyst/design-tokens/generated/react-native/tokens.ts deleted file mode 100644 index ddb25ca..0000000 --- a/vendor/bytelyst/design-tokens/generated/react-native/tokens.ts +++ /dev/null @@ -1,139 +0,0 @@ -/** - * React Native Design Tokens — Auto-generated from bytelyst.tokens.json - * Do not edit manually. Run: tsx scripts/generate-react-native.ts - */ - -export const tokens = { - // ── Semantic Colors (Dark Theme) ────────────────────────────────── - colors: { - bgCanvas: '#06070A', - bgElevated: '#0E1118', - surfaceCard: '#121725', - surfaceMuted: '#1A2335', - borderDefault: '#1FFFFFFF', - borderStrong: '#38FFFFFF', - textPrimary: '#EFF4FF', - textSecondary: '#A5B1C7', - textTertiary: '#6C7C98', - accentPrimary: '#5A8CFF', - accentSecondary: '#2EE6D6', - success: '#34D399', - warning: '#F59E0B', - danger: '#FF6E6E', - focusRing: '#735A8CFF', - overlayScrim: '#B8050812', - }, - - // ── NomGap Product Colors ────────────────────────────────────────── - nomgap: { - stageFed: '#FF9F43', - stageEarlyFast: '#FECA57', - stageFasted: '#48DBFB', - stageKetosis: '#5A8CFF', - stageDeepAutophagy: '#A66BFF', - stageExtended: '#FFD700', - autophagyMeter: '#5AE68C', - hydrationReminder: '#48DBFB', - electrolyteAlert: '#FF9F43', - safetyWarning: '#FF6E6E', - }, - - // ── Spacing (8pt grid) ────────────────────────────────────────────── - spacing: { - 0: 0, - 1: 4, - 2: 8, - 3: 12, - 4: 16, - 5: 20, - 6: 24, - 7: 28, - 8: 32, - 10: 40, - 12: 48, - 16: 64, - }, - - // ── Border Radius ─────────────────────────────────────────────────── - radius: { - xs: 8, - sm: 12, - md: 16, - lg: 20, - xl: 24, - pill: 999, - }, - - // ── Typography ───────────────────────────────────────────────────── - typography: { - fontFamily: { - display: 'System', - body: 'System', - mono: 'Courier', - }, - fontSize: { - xs: 12, - sm: 14, - md: 16, - lg: 18, - xl: 22, - '2xl': 28, - '3xl': 36, - }, - fontWeight: { - regular: '400', - medium: '500', - semibold: '600', - bold: '700', - }, - }, - - // ── Icon Sizes ────────────────────────────────────────────────────── - icon: { - xs: 12, - sm: 16, - md: 20, - lg: 24, - xl: 32, - '2xl': 48, - }, - - // ── Z-Index Layers ─────────────────────────────────────────────────── - zIndex: { - hidden: -1, - base: 0, - dropdown: 100, - sticky: 200, - fixed: 300, - overlay: 400, - modal: 500, - popover: 600, - toast: 700, - tooltip: 800, - }, - - // ── Opacity ───────────────────────────────────────────────────────── - opacity: { - '0': 0, - '10': 0.1, - '20': 0.2, - '30': 0.3, - '40': 0.4, - '50': 0.5, - '60': 0.6, - '70': 0.7, - '80': 0.8, - '90': 0.9, - '100': 1, - }, - - // ── Motion ──────────────────────────────────────────────────────────── - motion: { - instant: 70, - fast: 140, - base: 220, - slow: 320, - }, -} as const; - -export type Tokens = typeof tokens; diff --git a/vendor/bytelyst/design-tokens/generated/tokens.css b/vendor/bytelyst/design-tokens/generated/tokens.css deleted file mode 100644 index 27d5710..0000000 --- a/vendor/bytelyst/design-tokens/generated/tokens.css +++ /dev/null @@ -1,78 +0,0 @@ -/* Auto-generated from bytelyst.tokens.json — do not edit manually */ - -:root, -[data-theme="dark"] { - --ml-bg-canvas: #06070A; - --ml-bg-elevated: #0E1118; - --ml-surface-card: #121725; - --ml-surface-muted: #1A2335; - --ml-border-default: rgba(255,255,255,0.12); - --ml-border-strong: rgba(255,255,255,0.22); - --ml-text-primary: #EFF4FF; - --ml-text-secondary: #A5B1C7; - --ml-text-tertiary: #6C7C98; - --ml-accent-primary: #5A8CFF; - --ml-accent-secondary: #2EE6D6; - --ml-success: #34D399; - --ml-warning: #F59E0B; - --ml-danger: #FF6E6E; - --ml-focus-ring: rgba(90,140,255,0.45); - --ml-overlay-scrim: rgba(5,8,18,0.72); - - --ml-font-display: "Space Grotesk", "SF Pro Display", sans-serif; - --ml-font-body: "DM Sans", "SF Pro Text", sans-serif; - --ml-font-mono: "IBM Plex Mono", "SF Mono", monospace; - - --ml-fs-xs: 12px; - --ml-fs-sm: 14px; - --ml-fs-md: 16px; - --ml-fs-lg: 18px; - --ml-fs-xl: 22px; - --ml-fs-2xl: 28px; - --ml-fs-3xl: 36px; - - --ml-space-0: 0; - --ml-space-1: 4px; - --ml-space-2: 8px; - --ml-space-3: 12px; - --ml-space-4: 16px; - --ml-space-5: 20px; - --ml-space-6: 24px; - --ml-space-7: 28px; - --ml-space-8: 32px; - --ml-space-10: 40px; - --ml-space-12: 48px; - --ml-space-16: 64px; - - --ml-radius-xs: 8px; - --ml-radius-sm: 12px; - --ml-radius-md: 16px; - --ml-radius-lg: 20px; - --ml-radius-xl: 24px; - --ml-radius-pill: 999px; - - --ml-elevation-sm: 0 4px 12px rgba(0,0,0,0.12); - --ml-elevation-md: 0 12px 28px rgba(0,0,0,0.18); - --ml-elevation-lg: 0 20px 48px rgba(0,0,0,0.24); - - --ml-motion-fast: 140ms; - --ml-motion-base: 220ms; - --ml-motion-slow: 320ms; - --ml-easing-standard: cubic-bezier(0.2, 0.0, 0.2, 1); -} - -[data-theme="light"] { - --ml-bg-canvas: #F6F8FC; - --ml-bg-elevated: #EEF2FA; - --ml-surface-card: #FFFFFF; - --ml-surface-muted: #F3F5FA; - --ml-border-default: rgba(14,19,32,0.12); - --ml-border-strong: rgba(14,19,32,0.24); - --ml-text-primary: #0E1320; - --ml-text-secondary: #55637A; - --ml-success: #13956A; - --ml-warning: #B87504; - --ml-danger: #D24242; - --ml-focus-ring: rgba(90,140,255,0.35); - --ml-overlay-scrim: rgba(10,13,23,0.5); -} diff --git a/vendor/bytelyst/design-tokens/generated/tokens.ts b/vendor/bytelyst/design-tokens/generated/tokens.ts deleted file mode 100644 index 6a79b38..0000000 --- a/vendor/bytelyst/design-tokens/generated/tokens.ts +++ /dev/null @@ -1,386 +0,0 @@ -// Auto-generated from bytelyst.tokens.json — do not edit manually - -export const tokens = { - "meta": { - "name": "ByteLyst Design Tokens", - "version": "1.1.0", - "updatedAt": "2026-03-03", - "scale": "8pt" - }, - "color": { - "palette": { - "neutral": { - "0": "#FFFFFF", - "50": "#F6F8FC", - "100": "#EEF2FA", - "200": "#DCE4F2", - "300": "#BFCBDE", - "400": "#92A1BA", - "500": "#6C7C98", - "600": "#55637A", - "700": "#3B455A", - "800": "#1A2335", - "900": "#0E1320", - "950": "#06070A" - }, - "brand": { - "blue": "#5A8CFF", - "cyan": "#2EE6D6", - "coral": "#FF6E6E", - "gold": "#FFD166", - "mint": "#34D399", - "warning": "#F59E0B", - "microsoftRed": "#F25022", - "microsoftGreen": "#7FBA00", - "microsoftBlue": "#00A4EF", - "microsoftYellow": "#FFB900", - "googleBlue": "#4285F4", - "googleGreen": "#34A853", - "googleYellow": "#FBBC05", - "googleRed": "#EA4335" - } - }, - "semantic": { - "dark": { - "bgCanvas": "#06070A", - "bgElevated": "#0E1118", - "surfaceCard": "#121725", - "surfaceMuted": "#1A2335", - "borderDefault": "rgba(255,255,255,0.12)", - "borderStrong": "rgba(255,255,255,0.22)", - "textPrimary": "#EFF4FF", - "textSecondary": "#A5B1C7", - "textTertiary": "#6C7C98", - "accentPrimary": "#5A8CFF", - "accentSecondary": "#2EE6D6", - "success": "#34D399", - "warning": "#F59E0B", - "danger": "#FF6E6E", - "focusRing": "rgba(90,140,255,0.45)", - "overlayScrim": "rgba(5,8,18,0.72)" - }, - "light": { - "bgCanvas": "#F6F8FC", - "bgElevated": "#EEF2FA", - "surfaceCard": "#FFFFFF", - "surfaceMuted": "#F3F5FA", - "borderDefault": "rgba(14,19,32,0.12)", - "borderStrong": "rgba(14,19,32,0.24)", - "textPrimary": "#0E1320", - "textSecondary": "#55637A", - "textTertiary": "#6C7C98", - "accentPrimary": "#5A8CFF", - "accentSecondary": "#2EE6D6", - "success": "#13956A", - "warning": "#B87504", - "danger": "#D24242", - "focusRing": "rgba(90,140,255,0.35)", - "overlayScrim": "rgba(10,13,23,0.5)" - } - }, - "brain": { - "work": { - "from": "#5A8CFF", - "to": "#2EE6D6" - }, - "home": { - "from": "#FF6E6E", - "to": "#FFD166" - }, - "money": { - "from": "#34D399", - "to": "#2EE6D6" - }, - "health": { - "from": "#2EE6D6", - "to": "#9FE870" - }, - "global": { - "from": "#7D8FB4", - "to": "#A5B1C7" - } - }, - "jarvisjr": { - "accentPrimary": "#7C6BFF", - "accentSecondary": "#5AE6C8", - "accentVoice": "#FF6B8A", - "agentCoach": "#5A8CFF", - "agentLingua": "#FFB74D", - "agentSpark": "#E040FB", - "agentMentor": "#34D399", - "agentMirror": "#2EE6D6", - "agentOrator": "#FF9F43" - }, - "peakpulse": { - "activityHike": "#34D399", - "activitySki": "#5A8CFF", - "speedZoneSlow": "#34D399", - "speedZoneFast": "#FFD166", - "speedZoneDanger": "#FF6E6E", - "elevationGain": "#2EE6D6", - "elevationLoss": "#FF9F43", - "personalBest": "#FFD700", - "streakActive": "#34D399", - "streakBroken": "#FF6E6E" - }, - "chronomind": { - "urgencyCritical": "#FF6E6E", - "urgencyImportant": "#FFD166", - "urgencyStandard": "#5A8CFF", - "urgencyGentle": "#34D399", - "urgencyPassive": "#A5B1C7", - "focusMode": "#7C6BFF", - "pomodoroWork": "#34D399", - "pomodoroBreak": "#5A8CFF", - "cascadeWarning": "#FF9F43", - "timerComplete": "#34D399" - }, - "nomgap": { - "stageFed": "#FF9F43", - "stageEarlyFast": "#FECA57", - "stageFasted": "#48DBFB", - "stageKetosis": "#5A8CFF", - "stageDeepAutophagy": "#A66BFF", - "stageExtended": "#FFD700", - "autophagyMeter": "#5AE68C", - "hydrationReminder": "#48DBFB", - "electrolyteAlert": "#FF9F43", - "safetyWarning": "#FF6E6E" - }, - "lysnrai": { - "recordingActive": "#FF6E6E", - "recordingPaused": "#FFD166", - "processing": "#5A8CFF", - "transcribed": "#34D399", - "dictationMode": "#7C6BFF", - "commandMode": "#2EE6D6", - "hotkeyActive": "#FF6B8A" - }, - "flowmonk": { - "bg": "#07111F", - "surface": "#0F1B2D", - "surfaceElevated": "#152338", - "border": "#24344D", - "text": "#EFF4FF", - "textMuted": "#A8B4C8", - "primary": "#5A8CFF", - "accent": "#5AE68C", - "warning": "#F59E0B", - "zonework": "#5A8CFF", - "zonePersonal": "#5AE68C", - "zoneHealth": "#FF6B6B", - "zoneAdmin": "#FECA57", - "zoneLearning": "#A66BFF", - "urgentBadge": "#FF6E6E", - "scheduleEntry": "#5A8CFF", - "overflowWarning": "#F59E0B", - "recommendationInfo": "#5A8CFF", - "recommendationWarning": "#F59E0B", - "recommendationCritical": "#FF6E6E" - }, - "actiontrail": { - "bg": "#07111F", - "surface": "#0F1B2D", - "surfaceElevated": "#152338", - "border": "#24344D", - "text": "#EFF4FF", - "textMuted": "#A8B4C8", - "primary": "#5A8CFF", - "accent": "#5AE68C", - "warning": "#F59E0B", - "danger": "#FF6E6E", - "riskLow": "#5AE68C", - "riskMedium": "#F59E0B", - "riskHigh": "#FF8C42", - "riskCritical": "#FF6E6E", - "statusPending": "#F59E0B", - "statusApplied": "#5AE68C", - "statusRejected": "#FF6E6E", - "statusReverted": "#A66BFF" - }, - "notelett": { - "bgCanvas": "#06070A", - "bgElevated": "#0E1118", - "surfaceCard": "#121725", - "surfaceMuted": "#1A2335", - "accentPrimary": "#5A8CFF", - "accentSecondary": "#2EE6D6", - "success": "#34D399", - "warning": "#F59E0B", - "danger": "#FF6E6E", - "focusRing": "#5A8CFF", - "agentAction": "#A66BFF", - "draftNote": "#FFD166", - "linkedNote": "#2EE6D6", - "taskPending": "#F59E0B", - "taskComplete": "#34D399" - }, - "localmemgpt": { - "bgPrimary": "#0A0A0A", - "bgSecondary": "#141414", - "bgTertiary": "#1E1E1E", - "bgHover": "#252525", - "bgInput": "#1A1A1A", - "border": "#2A2A2A", - "textPrimary": "#F0F0F0", - "textSecondary": "#999999", - "textMuted": "#666666", - "accent": "#6366F1", - "accentHover": "#818CF8", - "success": "#22C55E", - "warning": "#F59E0B", - "error": "#EF4444" - }, - "localllmlab": { - "bgCanvas": "#06070A", - "bgElevated": "#0E1118", - "surfaceCard": "#121725", - "surfaceMuted": "#1A2335", - "borderSubtle": "#1E293B", - "borderDefault": "#2A3654", - "textPrimary": "#EFF4FF", - "textSecondary": "#A5B1C7", - "textTertiary": "#6C7C98", - "accentPrimary": "#5A8CFF", - "accentSecondary": "#2EE6D6", - "success": "#34D399", - "warning": "#F59E0B", - "danger": "#FF6E6E", - "purple": "#A78BFA" - } - }, - "typography": { - "fontFamily": { - "display": "'Space Grotesk', 'SF Pro Display', sans-serif", - "body": "'DM Sans', 'SF Pro Text', sans-serif", - "mono": "'IBM Plex Mono', 'SF Mono', monospace" - }, - "fontWeight": { - "regular": 400, - "medium": 500, - "semibold": 600, - "bold": 700 - }, - "fontSize": { - "xs": 12, - "sm": 14, - "md": 16, - "lg": 18, - "xl": 22, - "2xl": 28, - "3xl": 36 - }, - "lineHeight": { - "tight": 1.2, - "normal": 1.45, - "relaxed": 1.65 - }, - "letterSpacing": { - "tight": -0.02, - "normal": 0, - "wide": 0.02 - } - }, - "spacing": { - "0": 0, - "1": 4, - "2": 8, - "3": 12, - "4": 16, - "5": 20, - "6": 24, - "7": 28, - "8": 32, - "10": 40, - "12": 48, - "16": 64 - }, - "radius": { - "xs": 8, - "sm": 12, - "md": 16, - "lg": 20, - "xl": 24, - "pill": 999 - }, - "elevation": { - "none": "0 0 0 rgba(0,0,0,0)", - "sm": "0 4px 12px rgba(0,0,0,0.12)", - "md": "0 12px 28px rgba(0,0,0,0.18)", - "lg": "0 20px 48px rgba(0,0,0,0.24)" - }, - "motion": { - "duration": { - "instant": 70, - "fast": 140, - "base": 220, - "slow": 320 - }, - "easing": { - "standard": "cubic-bezier(0.2, 0.0, 0.2, 1)", - "decelerate": "cubic-bezier(0.0, 0.0, 0.2, 1)", - "accelerate": "cubic-bezier(0.4, 0.0, 1, 1)" - } - }, - "breakpoints": { - "mobile": 0, - "tablet": 768, - "desktop": 1200, - "wide": 1440 - }, - "layout": { - "maxContentWidth": 1280, - "mobileGutter": 16, - "tabletGutter": 24, - "desktopGutter": 32, - "touchTargetMin": 44 - }, - "zIndex": { - "hidden": -1, - "base": 0, - "dropdown": 100, - "sticky": 200, - "fixed": 300, - "overlay": 400, - "modal": 500, - "popover": 600, - "toast": 700, - "tooltip": 800 - }, - "icon": { - "xs": 12, - "sm": 16, - "md": 20, - "lg": 24, - "xl": 32, - "2xl": 48 - }, - "grid": { - "columns": 12, - "gutter": 24, - "maxWidth": 1200, - "breakpoints": { - "xs": 0, - "sm": 576, - "md": 768, - "lg": 992, - "xl": 1200, - "xxl": 1400 - } - }, - "opacity": { - "0": 0, - "10": 0.1, - "20": 0.2, - "30": 0.3, - "40": 0.4, - "50": 0.5, - "60": 0.6, - "70": 0.7, - "80": 0.8, - "90": 0.9, - "100": 1 - } -} as const; - -export type Tokens = typeof tokens; diff --git a/vendor/bytelyst/design-tokens/package.json b/vendor/bytelyst/design-tokens/package.json deleted file mode 100644 index 55a6e3a..0000000 --- a/vendor/bytelyst/design-tokens/package.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "name": "@bytelyst/design-tokens", - "version": "0.1.5", - "type": "module", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - }, - "./tokens.json": "./tokens/bytelyst.tokens.json", - "./generated/tokens": "./generated/tokens.ts", - "./css": "./generated/tokens.css", - "./css/chronomind": "./generated/chronomind.css", - "./css/jarvisjr": "./generated/jarvisjr.css", - "./css/nomgap": "./generated/nomgap.css", - "./css/actiontrail": "./generated/actiontrail.css", - "./css/flowmonk": "./generated/flowmonk.css", - "./css/notelett": "./generated/notelett.css", - "./css/localmemgpt": "./generated/localmemgpt.css", - "./css/localllmlab": "./generated/localllmlab.css", - "./css/lysnrai": "./generated/lysnrai.css", - "./css/peakpulse": "./generated/peakpulse.css", - "./css/mindlyst": "./generated/mindlyst.css" - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist", - "tokens", - "generated", - "scripts" - ], - "scripts": { - "build": "tsc", - "generate": "tsx scripts/generate.ts", - "test": "vitest run --pool forks" - }, - "devDependencies": { - "tsx": "^4.0.0" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/design-tokens/scripts/generate-react-native.ts b/vendor/bytelyst/design-tokens/scripts/generate-react-native.ts deleted file mode 100644 index 55e5ba6..0000000 --- a/vendor/bytelyst/design-tokens/scripts/generate-react-native.ts +++ /dev/null @@ -1,143 +0,0 @@ -/** - * React Native token generator for Expo/NomGap - * Reads bytelyst.tokens.json, outputs RN StyleSheet-compatible tokens - * - * Usage: tsx scripts/generate-react-native.ts - */ - -import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const tokensPath = resolve(__dirname, '../tokens/bytelyst.tokens.json'); -const outDir = resolve(__dirname, '../generated/react-native'); - -mkdirSync(outDir, { recursive: true }); - -const tokens = JSON.parse(readFileSync(tokensPath, 'utf-8')); - -// ── Helpers ─────────────────────────────────────────────────────────── - -function generateReactNative(): string { - const lines: string[] = [ - '/**', - ' * React Native Design Tokens — Auto-generated from bytelyst.tokens.json', - ' * Do not edit manually. Run: tsx scripts/generate-react-native.ts', - ' */', - '', - 'export const tokens = {', - '', - ' // ── Semantic Colors (Dark Theme) ──────────────────────────────────', - ' colors: {', - ]; - - // Semantic dark colors - for (const [key, value] of Object.entries(tokens.color.semantic.dark)) { - if (typeof value === 'string' && value.startsWith('#')) { - lines.push(` ${key}: '${value}',`); - } else if (typeof value === 'string' && value.startsWith('rgba')) { - // Convert rgba to hex8 for React Native - const match = value.match(/rgba?\((\d+),\s*(\d+),\s*(\d+),\s*([\d.]+)\)/); - if (match) { - const [, r, g, b, a] = match; - const alpha = Math.round(parseFloat(a) * 255) - .toString(16) - .padStart(2, '0'); - const hex = - `#${alpha}${parseInt(r).toString(16).padStart(2, '0')}${parseInt(g).toString(16).padStart(2, '0')}${parseInt(b).toString(16).padStart(2, '0')}`.toUpperCase(); - lines.push(` ${key}: '${hex}',`); - } - } - } - lines.push(' },', ''); - - // NomGap-specific colors - lines.push(' // ── NomGap Product Colors ──────────────────────────────────────────'); - lines.push(' nomgap: {'); - for (const [key, value] of Object.entries(tokens.color.nomgap)) { - lines.push(` ${key}: '${value}',`); - } - lines.push(' },', ''); - - // Spacing - lines.push(' // ── Spacing (8pt grid) ──────────────────────────────────────────────'); - lines.push(' spacing: {'); - for (const [key, value] of Object.entries(tokens.spacing)) { - lines.push(` ${key}: ${value},`); - } - lines.push(' },', ''); - - // Radius - lines.push(' // ── Border Radius ───────────────────────────────────────────────────'); - lines.push(' radius: {'); - for (const [key, value] of Object.entries(tokens.radius)) { - lines.push(` ${key}: ${value},`); - } - lines.push(' },', ''); - - // Typography - lines.push(' // ── Typography ─────────────────────────────────────────────────────'); - lines.push(' typography: {'); - lines.push(' fontFamily: {'); - for (const key of Object.keys(tokens.typography.fontFamily)) { - // Use system fonts for React Native - const systemFont = key === 'display' ? 'System' : key === 'mono' ? 'Courier' : 'System'; - lines.push(` ${key}: '${systemFont}',`); - } - lines.push(' },'); - lines.push(' fontSize: {'); - for (const [key, value] of Object.entries(tokens.typography.fontSize)) { - lines.push(` ${key}: ${value},`); - } - lines.push(' },'); - lines.push(' fontWeight: {'); - for (const [key, value] of Object.entries(tokens.typography.fontWeight)) { - lines.push(` ${key}: '${value}',`); - } - lines.push(' },'); - lines.push(' },', ''); - - // Icon sizes - lines.push(' // ── Icon Sizes ──────────────────────────────────────────────────────'); - lines.push(' icon: {'); - for (const [key, value] of Object.entries(tokens.icon)) { - lines.push(` ${key}: ${value},`); - } - lines.push(' },', ''); - - // Z-index (for RN zIndex style prop) - lines.push(' // ── Z-Index Layers ───────────────────────────────────────────────────'); - lines.push(' zIndex: {'); - for (const [key, value] of Object.entries(tokens.zIndex)) { - lines.push(` ${key}: ${value},`); - } - lines.push(' },', ''); - - // Opacity - lines.push(' // ── Opacity ─────────────────────────────────────────────────────────'); - lines.push(' opacity: {'); - for (const [key, value] of Object.entries(tokens.opacity)) { - lines.push(` ${key}: ${value},`); - } - lines.push(' },', ''); - - // Motion (duration in ms) - lines.push(' // ── Motion ────────────────────────────────────────────────────────────'); - lines.push(' motion: {'); - for (const [key, value] of Object.entries(tokens.motion.duration)) { - lines.push(` ${key}: ${value},`); - } - lines.push(' },', ''); - - lines.push('} as const;', ''); - lines.push(''); - lines.push('export type Tokens = typeof tokens;', ''); - - return lines.join('\n'); -} - -// ── Write ────────────────────────────────────────────────────────── -writeFileSync(resolve(outDir, 'tokens.ts'), generateReactNative()); -// eslint-disable-next-line no-console -console.log('Generated React Native tokens in generated/react-native/'); diff --git a/vendor/bytelyst/design-tokens/scripts/generate.ts b/vendor/bytelyst/design-tokens/scripts/generate.ts deleted file mode 100644 index 3675d12..0000000 --- a/vendor/bytelyst/design-tokens/scripts/generate.ts +++ /dev/null @@ -1,750 +0,0 @@ -/** - * Token generator — reads bytelyst.tokens.json, outputs 4 platform formats: - * 1. CSS custom properties (tokens.css) - * 2. TypeScript constants (tokens.ts) - * 3. Kotlin object (MindLystTokens.kt) — for KMP shared module - * 4. Swift structs (MindLystTheme.swift) — for iOS SwiftUI - * - * Output conventions match the hand-written originals in learning_multimodal_memory_agents: - * - Kotlin: SCREAMING_SNAKE_CASE, Palette/Dark/Light/BrainGradient/Typography/Motion/Layout - * - Swift: Color(hex: UInt), dark/light prefixes, Gradient(colors:), MindLystMotion, Color ext - * - CSS: [data-theme], --ml-fs-*, --ml-elevation-*, --ml-motion-* - * - * Usage: tsx scripts/generate.ts - */ - -/* eslint-disable no-console -- This generator is a CLI; console output confirms generated artifacts. */ - -import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const tokensPath = resolve(__dirname, '../tokens/bytelyst.tokens.json'); -const outDir = resolve(__dirname, '../generated'); - -mkdirSync(outDir, { recursive: true }); - -const tokens = JSON.parse(readFileSync(tokensPath, 'utf-8')); - -// ── Product CSS mapping ───────────────────────────────────────────── -const PRODUCT_CSS_MAP: Record = { - mindlyst: { prefix: 'ml', colorsKey: 'brain' }, - chronomind: { prefix: 'cm', colorsKey: 'chronomind' }, - jarvisjr: { prefix: 'jj', colorsKey: 'jarvisjr' }, - nomgap: { prefix: 'ng', colorsKey: 'nomgap' }, - actiontrail: { prefix: 'at', colorsKey: 'actiontrail' }, - flowmonk: { prefix: 'fm', colorsKey: 'flowmonk' }, - notelett: { prefix: 'nl', colorsKey: 'notelett' }, - localmemgpt: { prefix: 'lmg', colorsKey: 'localmemgpt' }, - localllmlab: { prefix: 'llm', colorsKey: 'localllmlab' }, - lysnrai: { prefix: 'lys', colorsKey: 'lysnrai' }, - peakpulse: { prefix: 'pp', colorsKey: 'peakpulse' }, -}; - -// ── Helpers ────────────────────────────────────────────────────────── - -function camelToKebab(str: string): string { - return str.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase(); -} - -function camelToScreamingSnake(str: string): string { - return str.replace(/([a-z])([A-Z])/g, '$1_$2').toUpperCase(); -} - -function hexToUInt(hex: string): string { - return `0x${hex.replace('#', '').toUpperCase()}`; -} - -function capitalize(s: string): string { - return s.charAt(0).toUpperCase() + s.slice(1); -} - -// ── 1. CSS ─────────────────────────────────────────────────────────── -function generateCSS(): string { - const lines: string[] = [ - '/* Auto-generated from bytelyst.tokens.json — do not edit manually */', - '', - ':root,', - '[data-theme="dark"] {', - ]; - - // Semantic colors (dark theme as default) - for (const [key, value] of Object.entries(tokens.color.semantic.dark)) { - lines.push(` --ml-${camelToKebab(key)}: ${value};`); - } - lines.push(''); - - // Typography - for (const [key, value] of Object.entries(tokens.typography.fontFamily)) { - // Swap single quotes → double quotes for CSS - const cssVal = typeof value === 'string' ? value.replace(/'/g, '"') : value; - lines.push(` --ml-font-${key}: ${cssVal};`); - } - lines.push(''); - - // Font sizes (--ml-fs-* to match existing convention) - for (const [key, value] of Object.entries(tokens.typography.fontSize)) { - lines.push(` --ml-fs-${key}: ${value}px;`); - } - lines.push(''); - - // Spacing - for (const [key, value] of Object.entries(tokens.spacing)) { - lines.push(` --ml-space-${key}: ${value === 0 ? '0' : `${value}px`};`); - } - lines.push(''); - - // Radius - for (const [key, value] of Object.entries(tokens.radius)) { - lines.push(` --ml-radius-${key}: ${value}px;`); - } - lines.push(''); - - // Elevation (--ml-elevation-* to match existing) - for (const [key, value] of Object.entries(tokens.elevation)) { - if (key === 'none') continue; - lines.push(` --ml-elevation-${key}: ${value};`); - } - lines.push(''); - - // Motion - for (const [key, value] of Object.entries(tokens.motion.duration)) { - if (key === 'instant') continue; // not used in CSS - lines.push(` --ml-motion-${key}: ${value}ms;`); - } - lines.push(` --ml-easing-standard: ${tokens.motion.easing.standard};`); - - lines.push('}', ''); - - // Light theme overrides - lines.push('[data-theme="light"] {'); - for (const [key, value] of Object.entries(tokens.color.semantic.light)) { - // Only emit overrides where light differs from dark - const darkVal = tokens.color.semantic.dark[key]; - if (value !== darkVal) { - lines.push(` --ml-${camelToKebab(key)}: ${value};`); - } - } - lines.push('}', ''); - - return lines.join('\n'); -} - -// ── 2. TypeScript ──────────────────────────────────────────────────── -function generateTS(): string { - return [ - '// Auto-generated from bytelyst.tokens.json — do not edit manually', - '', - `export const tokens = ${JSON.stringify(tokens, null, 2)} as const;`, - '', - 'export type Tokens = typeof tokens;', - '', - ].join('\n'); -} - -// ── 3. Kotlin ──────────────────────────────────────────────────────── -function generateKotlin(): string { - const lines: string[] = [ - '// Auto-generated from bytelyst.tokens.json — do not edit manually', - 'package com.mindlyst.shared.theme', - '', - '/**', - ' * Cross-platform design tokens from bytelyst.tokens.json.', - ' * Single source of truth consumed by both Android (Compose) and iOS (SwiftUI).', - ' */', - 'object MindLystTokens {', - '', - ]; - - // ── Palette - lines.push(' // ── Color Palette ────────────────────────────────────────────────'); - lines.push(' object Palette {'); - for (const [key, value] of Object.entries(tokens.color.palette.neutral)) { - lines.push( - ` const val NEUTRAL_${key} = 0xFF${(value as string).replace('#', '').toUpperCase()}` - ); - } - lines.push(''); - for (const [key, value] of Object.entries(tokens.color.palette.brand)) { - lines.push( - ` const val ${key.toUpperCase()} = 0xFF${(value as string).replace('#', '').toUpperCase()}` - ); - } - lines.push(' }', ''); - - // ── Dark semantic - lines.push(' // ── Semantic Colors (Dark Theme) ─────────────────────────────────'); - lines.push(' object Dark {'); - for (const [key, value] of Object.entries(tokens.color.semantic.dark)) { - if (typeof value === 'string' && value.startsWith('#')) { - lines.push( - ` const val ${camelToScreamingSnake(key)} = 0xFF${value.replace('#', '').toUpperCase()}` - ); - } - } - lines.push(' }', ''); - - // ── Light semantic - lines.push(' // ── Semantic Colors (Light Theme) ────────────────────────────────'); - lines.push(' object Light {'); - for (const [key, value] of Object.entries(tokens.color.semantic.light)) { - if (typeof value === 'string' && value.startsWith('#')) { - lines.push( - ` const val ${camelToScreamingSnake(key)} = 0xFF${value.replace('#', '').toUpperCase()}` - ); - } - } - lines.push(' }', ''); - - // ── Brain gradients - lines.push(' // ── Brain Identity Gradients ─────────────────────────────────────'); - lines.push(' data class BrainGradient(val from: Long, val to: Long)'); - lines.push(''); - for (const [name, grad] of Object.entries(tokens.color.brain) as [ - string, - { from: string; to: string }, - ][]) { - lines.push( - ` val BRAIN_${name.toUpperCase()} = BrainGradient(from = 0xFF${grad.from.replace('#', '').toUpperCase()}, to = 0xFF${grad.to.replace('#', '').toUpperCase()})` - ); - } - lines.push(''); - - // ── Spacing - lines.push(' // ── Spacing (8pt grid) ───────────────────────────────────────────'); - lines.push(' object Spacing {'); - for (const [key, value] of Object.entries(tokens.spacing)) { - lines.push(` const val X${key} = ${value}`); - } - lines.push(' }', ''); - - // ── Radius - lines.push(' // ── Radius ───────────────────────────────────────────────────────'); - lines.push(' object Radius {'); - for (const [key, value] of Object.entries(tokens.radius)) { - lines.push(` const val ${key.toUpperCase()} = ${value}`); - } - lines.push(' }', ''); - - // ── Typography - lines.push(' // ── Typography ───────────────────────────────────────────────────'); - lines.push(' object Typography {'); - for (const [key, value] of Object.entries(tokens.typography.fontFamily)) { - // Extract just the primary font name (first in the list) - const fontName = - typeof value === 'string' ? value.split(',')[0].replace(/'/g, '').trim() : value; - lines.push(` const val FONT_${key.toUpperCase()} = "${fontName}"`); - } - lines.push(''); - for (const [key, value] of Object.entries(tokens.typography.fontSize)) { - const sizeKey = key.toUpperCase().replace('-', ''); - lines.push(` const val SIZE_${sizeKey} = ${value}`); - } - lines.push(' }', ''); - - // ── Motion - lines.push(' // ── Motion ───────────────────────────────────────────────────────'); - lines.push(' object Motion {'); - for (const [key, value] of Object.entries(tokens.motion.duration)) { - lines.push(` const val ${key.toUpperCase()} = ${value}`); - } - lines.push(' }', ''); - - // ── Layout - lines.push(' // ── Layout ───────────────────────────────────────────────────────'); - lines.push(' object Layout {'); - lines.push(` const val TOUCH_TARGET_MIN = ${tokens.layout.touchTargetMin}`); - lines.push(` const val MOBILE_GUTTER = ${tokens.layout.mobileGutter}`); - lines.push(` const val MAX_WIDTH = ${tokens.layout.maxContentWidth}`); - lines.push(' }'); - - lines.push('}', ''); - return lines.join('\n'); -} - -// ── 4. Swift ───────────────────────────────────────────────────────── -function generateSwift(): string { - const lines: string[] = [ - '// Auto-generated from bytelyst.tokens.json — do not edit manually', - 'import SwiftUI', - '', - '// MARK: - MindLyst Design Tokens (from shared KMP MindLystTokens)', - '// These values mirror MindLystTokens.kt exactly.', - '', - 'struct MindLystColors {', - ]; - - // Dark colors - lines.push(' // Dark'); - for (const [key, value] of Object.entries(tokens.color.semantic.dark)) { - if (typeof value === 'string' && value.startsWith('#')) { - lines.push(` static let dark${capitalize(key)} = Color(hex: ${hexToUInt(value)})`); - } else if (typeof value === 'string' && value.startsWith('rgba')) { - // border/overlay → special handling - if (key === 'borderDefault') { - lines.push(' static let darkBorder = Color.white.opacity(0.12)'); - } - } - } - lines.push(''); - - // Light colors - lines.push(' // Light'); - for (const [key, value] of Object.entries(tokens.color.semantic.light)) { - if (typeof value === 'string' && value.startsWith('#')) { - if (value === '#FFFFFF') { - lines.push(` static let light${capitalize(key)} = Color.white`); - } else { - lines.push(` static let light${capitalize(key)} = Color(hex: ${hexToUInt(value)})`); - } - } - } - lines.push(''); - - // Brain gradients - lines.push(' // Brain Gradients'); - for (const [name, grad] of Object.entries(tokens.color.brain) as [ - string, - { from: string; to: string }, - ][]) { - lines.push( - ` static let brain${capitalize(name)} = Gradient(colors: [Color(hex: ${hexToUInt(grad.from)}), Color(hex: ${hexToUInt(grad.to)})])` - ); - } - lines.push('}', ''); - - // Spacing - lines.push('struct MindLystSpacing {'); - for (const [key, value] of Object.entries(tokens.spacing)) { - const pad = key.length === 1 ? ' ' : ''; - lines.push(` static let x${key}: ${pad}CGFloat = ${value}`); - } - lines.push('}', ''); - - // Radius - lines.push('struct MindLystRadius {'); - for (const [key, value] of Object.entries(tokens.radius)) { - const pad = key.length < 4 ? ' '.repeat(4 - key.length) : ''; - lines.push(` static let ${key}:${pad} CGFloat = ${value}`); - } - lines.push('}', ''); - - // Motion (durations in seconds) - lines.push('struct MindLystMotion {'); - for (const [key, value] of Object.entries(tokens.motion.duration)) { - const seconds = (value as number) / 1000; - const pad = key.length < 7 ? ' '.repeat(7 - key.length) : ''; - lines.push(` static let ${key}:${pad} Double = ${seconds.toFixed(2)}`); - } - lines.push('}', ''); - - // Color hex extension - lines.push('// MARK: - Color Hex Extension'); - lines.push(''); - lines.push('extension Color {'); - lines.push(' init(hex: UInt, alpha: Double = 1.0) {'); - lines.push(' self.init('); - lines.push(' .sRGB,'); - lines.push(' red: Double((hex >> 16) & 0xFF) / 255.0,'); - lines.push(' green: Double((hex >> 8) & 0xFF) / 255.0,'); - lines.push(' blue: Double(hex & 0xFF) / 255.0,'); - lines.push(' opacity: alpha'); - lines.push(' )'); - lines.push(' }'); - lines.push('}', ''); - - return lines.join('\n'); -} - -// ── 5. Per-product CSS ────────────────────────────────────────────── -function generateProductCSS(productId: string, prefix: string, colorsKey: string): string { - const productColors = tokens.color[colorsKey]; - if (!productColors) return `/* No palette found for ${productId} (color.${colorsKey}) */\n`; - - const lines: string[] = [ - `/* Auto-generated ${productId} tokens from bytelyst.tokens.json — do not edit manually */`, - '', - ':root {', - ]; - - // Semantic colors (dark as default) — shared across all products - for (const [key, value] of Object.entries(tokens.color.semantic.dark)) { - lines.push(` --${prefix}-${camelToKebab(key)}: ${value};`); - } - lines.push(''); - - // Product-specific colors - lines.push(` /* ${productId} product colors */`); - for (const [key, value] of Object.entries(productColors)) { - if (typeof value === 'string') { - lines.push(` --${prefix}-${camelToKebab(key)}: ${value};`); - } else if (typeof value === 'object' && value !== null && 'from' in value && 'to' in value) { - const grad = value as unknown as { from: string; to: string }; - lines.push(` --${prefix}-${camelToKebab(key)}-from: ${grad.from};`); - lines.push(` --${prefix}-${camelToKebab(key)}-to: ${grad.to};`); - } - } - lines.push(''); - - // Typography - for (const [key, value] of Object.entries(tokens.typography.fontFamily)) { - const cssVal = typeof value === 'string' ? value.replace(/'/g, '"') : value; - lines.push(` --${prefix}-font-${key}: ${cssVal};`); - } - lines.push(''); - - // Font sizes - for (const [key, value] of Object.entries(tokens.typography.fontSize)) { - lines.push(` --${prefix}-fs-${key}: ${value}px;`); - } - lines.push(''); - - // Spacing - for (const [key, value] of Object.entries(tokens.spacing)) { - lines.push(` --${prefix}-space-${key}: ${value === 0 ? '0' : `${value}px`};`); - } - lines.push(''); - - // Radius - for (const [key, value] of Object.entries(tokens.radius)) { - lines.push(` --${prefix}-radius-${key}: ${value}px;`); - } - lines.push(''); - - // Elevation - for (const [key, value] of Object.entries(tokens.elevation)) { - if (key === 'none') continue; - lines.push(` --${prefix}-elevation-${key}: ${value};`); - } - lines.push(''); - - // Motion - for (const [key, value] of Object.entries(tokens.motion.duration)) { - if (key === 'instant') continue; - lines.push(` --${prefix}-motion-${key}: ${value}ms;`); - } - lines.push(` --${prefix}-easing-standard: ${tokens.motion.easing.standard};`); - - lines.push('}', ''); - - // Light theme overrides - lines.push('[data-theme="light"] {'); - for (const [key, value] of Object.entries(tokens.color.semantic.light)) { - const darkVal = (tokens.color.semantic.dark as Record)[key]; - if (value !== darkVal) { - lines.push(` --${prefix}-${camelToKebab(key)}: ${value};`); - } - } - lines.push('}', ''); - - return lines.join('\n'); -} - -// ── Product native mapping (products with iOS/Android apps) ───────── -interface ProductNativeConfig { - colorsKey: string; - swiftEnum: string; // e.g. 'PeakPulseColors' - kotlinObject: string; // e.g. 'PeakPulseTokens' - kotlinPackage: string; // e.g. 'com.peakpulse.theme' - swiftFile: string; // e.g. 'PeakPulseTheme.swift' - kotlinFile: string; // e.g. 'PeakPulseTokens.kt' -} - -const PRODUCT_NATIVE_MAP: Record = { - chronomind: { - colorsKey: 'chronomind', - swiftEnum: 'CMColors', - kotlinObject: 'ChronoMindTokens', - kotlinPackage: 'com.chronomind.app.theme', - swiftFile: 'ChronoMindTheme.generated.swift', - kotlinFile: 'ChronoMindTokens.generated.kt', - }, - jarvisjr: { - colorsKey: 'jarvisjr', - swiftEnum: 'JarvisJrColors', - kotlinObject: 'JarvisJrTokens', - kotlinPackage: 'com.jarvisjr.app.theme', - swiftFile: 'JarvisJrTheme.generated.swift', - kotlinFile: 'JarvisJrTokens.generated.kt', - }, - peakpulse: { - colorsKey: 'peakpulse', - swiftEnum: 'PeakPulseColors', - kotlinObject: 'PeakPulseTokens', - kotlinPackage: 'com.peakpulse.theme', - swiftFile: 'PeakPulseTheme.generated.swift', - kotlinFile: 'PeakPulseTokens.generated.kt', - }, - lysnrai: { - colorsKey: 'lysnrai', - swiftEnum: 'LysnrAIColors', - kotlinObject: 'LysnrAITokens', - kotlinPackage: 'com.saravana.lysnrai.theme', - swiftFile: 'LysnrAITheme.generated.swift', - kotlinFile: 'LysnrAITokens.generated.kt', - }, - nomgap: { - colorsKey: 'nomgap', - swiftEnum: 'NomGapColors', - kotlinObject: 'NomGapTokens', - kotlinPackage: 'com.nomgap.theme', - swiftFile: 'NomGapTheme.generated.swift', - kotlinFile: 'NomGapTokens.generated.kt', - }, - actiontrail: { - colorsKey: 'actiontrail', - swiftEnum: 'ActionTrailColors', - kotlinObject: 'ActionTrailTokens', - kotlinPackage: 'com.actiontrail.theme', - swiftFile: 'ActionTrailTheme.generated.swift', - kotlinFile: 'ActionTrailTokens.generated.kt', - }, - flowmonk: { - colorsKey: 'flowmonk', - swiftEnum: 'FlowMonkColors', - kotlinObject: 'FlowMonkTokens', - kotlinPackage: 'com.flowmonk.theme', - swiftFile: 'FlowMonkTheme.generated.swift', - kotlinFile: 'FlowMonkTokens.generated.kt', - }, - notelett: { - colorsKey: 'notelett', - swiftEnum: 'NoteLettColors', - kotlinObject: 'NoteLettTokens', - kotlinPackage: 'com.notelett.theme', - swiftFile: 'NoteLettTheme.generated.swift', - kotlinFile: 'NoteLettTokens.generated.kt', - }, - localmemgpt: { - colorsKey: 'localmemgpt', - swiftEnum: 'LocalMemGPTColors', - kotlinObject: 'LocalMemGPTTokens', - kotlinPackage: 'com.localmemgpt.theme', - swiftFile: 'LocalMemGPTTheme.generated.swift', - kotlinFile: 'LocalMemGPTTokens.generated.kt', - }, - localllmlab: { - colorsKey: 'localllmlab', - swiftEnum: 'LocalLLMLabColors', - kotlinObject: 'LocalLLMLabTokens', - kotlinPackage: 'com.localllmlab.theme', - swiftFile: 'LocalLLMLabTheme.generated.swift', - kotlinFile: 'LocalLLMLabTokens.generated.kt', - }, -}; - -// ── 6. Per-product Swift ───────────────────────────────────────────── -function generateProductSwift(productId: string, config: ProductNativeConfig): string { - const productColors = tokens.color[config.colorsKey]; - const lines: string[] = [ - `// Auto-generated from bytelyst.tokens.json — do not edit manually.`, - `// Product: ${productId}`, - `// Regenerate: cd packages/design-tokens && tsx scripts/generate.ts`, - '', - 'import SwiftUI', - '', - `enum ${config.swiftEnum} {`, - ]; - - // Semantic dark colors - lines.push(' // MARK: - Semantic (Dark Theme)'); - for (const [key, value] of Object.entries(tokens.color.semantic.dark)) { - if (typeof value === 'string' && value.startsWith('#')) { - lines.push(` static let ${key} = Color(hex: ${hexToUInt(value)})`); - } - } - lines.push(''); - - // Product-specific colors - if (productColors) { - lines.push(` // MARK: - ${capitalize(productId)} Product Colors`); - for (const [key, value] of Object.entries(productColors)) { - if (typeof value === 'string' && value.startsWith('#')) { - lines.push(` static let ${key} = Color(hex: ${hexToUInt(value)})`); - } else if (typeof value === 'object' && value !== null && 'from' in value) { - const grad = value as { from: string; to: string }; - lines.push( - ` static let ${key} = Gradient(colors: [Color(hex: ${hexToUInt(grad.from)}), Color(hex: ${hexToUInt(grad.to)})])` - ); - } - } - lines.push(''); - } - - lines.push('}', ''); - - // Semantic light colors - lines.push(`enum ${config.swiftEnum}Light {`); - lines.push(' // MARK: - Semantic (Light Theme)'); - for (const [key, value] of Object.entries(tokens.color.semantic.light)) { - if (typeof value === 'string' && value.startsWith('#')) { - lines.push(` static let ${key} = Color(hex: ${hexToUInt(value)})`); - } - } - lines.push('}', ''); - - // Spacing - lines.push(`enum ${config.swiftEnum.replace('Colors', '')}Spacing {`); - for (const [key, value] of Object.entries(tokens.spacing)) { - const pad = key.length === 1 ? ' ' : ''; - lines.push(` static let x${key}: ${pad}CGFloat = ${value}`); - } - lines.push('}', ''); - - // Radius - lines.push(`enum ${config.swiftEnum.replace('Colors', '')}Radius {`); - for (const [key, value] of Object.entries(tokens.radius)) { - lines.push(` static let ${key}: CGFloat = ${value}`); - } - lines.push('}', ''); - - // Motion - lines.push(`enum ${config.swiftEnum.replace('Colors', '')}Motion {`); - for (const [key, value] of Object.entries(tokens.motion.duration)) { - const seconds = (value as number) / 1000; - lines.push(` static let ${key}: Double = ${seconds.toFixed(2)}`); - } - lines.push('}', ''); - - // Color hex extension (only include if not already in project) - lines.push('// MARK: - Color Hex Extension (import if not already defined)'); - lines.push(''); - lines.push('extension Color {'); - lines.push(' init(hex: UInt, alpha: Double = 1.0) {'); - lines.push(' self.init('); - lines.push(' .sRGB,'); - lines.push(' red: Double((hex >> 16) & 0xFF) / 255.0,'); - lines.push(' green: Double((hex >> 8) & 0xFF) / 255.0,'); - lines.push(' blue: Double(hex & 0xFF) / 255.0,'); - lines.push(' opacity: alpha'); - lines.push(' )'); - lines.push(' }'); - lines.push('}', ''); - - return lines.join('\n'); -} - -// ── 7. Per-product Kotlin ──────────────────────────────────────────── -function generateProductKotlin(productId: string, config: ProductNativeConfig): string { - const productColors = tokens.color[config.colorsKey]; - const lines: string[] = [ - '// Auto-generated from bytelyst.tokens.json — do not edit manually.', - `// Product: ${productId}`, - '// Regenerate: cd packages/design-tokens && tsx scripts/generate.ts', - `package ${config.kotlinPackage}`, - '', - `object ${config.kotlinObject} {`, - '', - ]; - - // Semantic dark - lines.push(' // ── Semantic Colors (Dark Theme) ─────────────────────────────────'); - lines.push(' object Dark {'); - for (const [key, value] of Object.entries(tokens.color.semantic.dark)) { - if (typeof value === 'string' && value.startsWith('#')) { - lines.push( - ` const val ${camelToScreamingSnake(key)} = 0xFF${value.replace('#', '').toUpperCase()}` - ); - } - } - lines.push(' }', ''); - - // Semantic light - lines.push(' // ── Semantic Colors (Light Theme) ────────────────────────────────'); - lines.push(' object Light {'); - for (const [key, value] of Object.entries(tokens.color.semantic.light)) { - if (typeof value === 'string' && value.startsWith('#')) { - lines.push( - ` const val ${camelToScreamingSnake(key)} = 0xFF${value.replace('#', '').toUpperCase()}` - ); - } - } - lines.push(' }', ''); - - // Product-specific colors - if (productColors) { - lines.push(` // ── ${capitalize(productId)} Product Colors ───────────────────────────────`); - lines.push(' object Product {'); - for (const [key, value] of Object.entries(productColors)) { - if (typeof value === 'string' && value.startsWith('#')) { - lines.push( - ` const val ${camelToScreamingSnake(key)} = 0xFF${value.replace('#', '').toUpperCase()}` - ); - } else if (typeof value === 'object' && value !== null && 'from' in value) { - const grad = value as { from: string; to: string }; - lines.push( - ` const val ${camelToScreamingSnake(key)}_FROM = 0xFF${grad.from.replace('#', '').toUpperCase()}` - ); - lines.push( - ` const val ${camelToScreamingSnake(key)}_TO = 0xFF${grad.to.replace('#', '').toUpperCase()}` - ); - } - } - lines.push(' }', ''); - } - - // Spacing - lines.push(' // ── Spacing (8pt grid) ───────────────────────────────────────────'); - lines.push(' object Spacing {'); - for (const [key, value] of Object.entries(tokens.spacing)) { - lines.push(` const val X${key} = ${value}`); - } - lines.push(' }', ''); - - // Radius - lines.push(' // ── Radius ───────────────────────────────────────────────────────'); - lines.push(' object Radius {'); - for (const [key, value] of Object.entries(tokens.radius)) { - lines.push(` const val ${key.toUpperCase()} = ${value}`); - } - lines.push(' }', ''); - - // Typography - lines.push(' // ── Typography ───────────────────────────────────────────────────'); - lines.push(' object Typography {'); - for (const [key, value] of Object.entries(tokens.typography.fontFamily)) { - const fontName = - typeof value === 'string' ? value.split(',')[0].replace(/'/g, '').trim() : value; - lines.push(` const val FONT_${key.toUpperCase()} = "${fontName}"`); - } - lines.push(' }', ''); - - // Motion - lines.push(' // ── Motion ───────────────────────────────────────────────────────'); - lines.push(' object Motion {'); - for (const [key, value] of Object.entries(tokens.motion.duration)) { - lines.push(` const val ${key.toUpperCase()} = ${value}`); - } - lines.push(' }'); - - lines.push('}', ''); - return lines.join('\n'); -} - -// ── Write all ──────────────────────────────────────────────────────── -// Shared semantic tokens (backward compatible) -writeFileSync(resolve(outDir, 'tokens.css'), generateCSS()); -writeFileSync(resolve(outDir, 'tokens.ts'), generateTS()); -writeFileSync(resolve(outDir, 'MindLystTokens.kt'), generateKotlin()); -writeFileSync(resolve(outDir, 'MindLystTheme.swift'), generateSwift()); - -// Per-product CSS -for (const [productId, config] of Object.entries(PRODUCT_CSS_MAP)) { - const css = generateProductCSS(productId, config.prefix, config.colorsKey); - writeFileSync(resolve(outDir, `${productId}.css`), css); -} - -// Per-product Swift + Kotlin -const nativeDir = resolve(outDir, 'native'); -mkdirSync(nativeDir, { recursive: true }); -for (const [productId, config] of Object.entries(PRODUCT_NATIVE_MAP)) { - const swift = generateProductSwift(productId, config); - writeFileSync(resolve(nativeDir, config.swiftFile), swift); - const kotlin = generateProductKotlin(productId, config); - writeFileSync(resolve(nativeDir, config.kotlinFile), kotlin); -} - -console.log( - `Generated 4 shared + ${Object.keys(PRODUCT_CSS_MAP).length} product CSS + ${Object.keys(PRODUCT_NATIVE_MAP).length * 2} native token files in generated/` -); diff --git a/vendor/bytelyst/design-tokens/scripts/token-coverage.cjs b/vendor/bytelyst/design-tokens/scripts/token-coverage.cjs deleted file mode 100755 index 447852e..0000000 --- a/vendor/bytelyst/design-tokens/scripts/token-coverage.cjs +++ /dev/null @@ -1,184 +0,0 @@ -#!/usr/bin/env node -/** - * Token coverage report — analyzes how well a product uses design tokens - * - * Usage: node scripts/token-coverage.js - */ - -const { readFileSync, readdirSync, statSync } = require('fs'); -const { join, resolve } = require('path'); - -const EXCLUDED_DIRS = ['node_modules', 'dist', 'build', '.git', 'generated', '__mocks__']; -const INCLUDED_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.swift', '.kt']; - -const TOKEN_PATTERNS = { - web: { - cssVar: /--ml-[a-z-]+/g, - tokensImport: /@bytelyst\/design-tokens/, - }, - ios: { - mindLystColors: /MindLystColors\./g, - colorExtension: /Color\(hex:/g, - }, - kmp: { - mindLystTokens: /MindLystTokens\./g, - }, -}; - -function findFiles(dir, files = []) { - try { - const items = readdirSync(dir); - for (const item of items) { - const fullPath = join(dir, item); - if (EXCLUDED_DIRS.some(ex => fullPath.includes(ex))) continue; - - const stat = statSync(fullPath); - if (stat.isDirectory()) { - findFiles(fullPath, files); - } else if (INCLUDED_EXTENSIONS.some(ext => item.endsWith(ext))) { - files.push(fullPath); - } - } - } catch { - // Directory might not exist - } - return files; -} - -function detectPlatform(files) { - const hasSwift = files.some(f => f.endsWith('.swift')); - const hasKotlin = files.some(f => f.endsWith('.kt')); - const hasTSX = files.some(f => f.endsWith('.tsx')); - - if (hasSwift) return 'ios'; - if (hasKotlin) return 'kmp'; - if (hasTSX) return 'web'; - return 'unknown'; -} - -function analyzeCoverage(files, platform) { - let tokenUsages = 0; - let hardcodedColors = 0; - let filesUsingTokens = 0; - let filesWithHardcoded = 0; - - const patterns = TOKEN_PATTERNS[platform] || {}; - - for (const file of files) { - const content = readFileSync(file, 'utf-8'); - let hasTokens = false; - let hasHardcoded = false; - - // Check token usage - if (patterns.cssVar) { - const matches = content.match(patterns.cssVar); - if (matches) { - tokenUsages += matches.length; - hasTokens = true; - } - } - if (patterns.mindLystColors) { - const matches = content.match(patterns.mindLystColors); - if (matches) { - tokenUsages += matches.length; - hasTokens = true; - } - } - if (patterns.mindLystTokens) { - const matches = content.match(patterns.mindLystTokens); - if (matches) { - tokenUsages += matches.length; - hasTokens = true; - } - } - - // Check hardcoded colors - const colorMatches = content.match(/#[0-9A-Fa-f]{6}\b/g); - if (colorMatches) { - // Filter out likely non-color hex (like #FFFFFF in different contexts) - const likelyColors = colorMatches.filter(c => { - const hex = c.slice(1); - // Skip pure grays that might be intentional - if (hex[0] === hex[2] && hex[2] === hex[4]) return false; - return true; - }); - - if (likelyColors.length > 0) { - hardcodedColors += likelyColors.length; - hasHardcoded = true; - } - } - - if (hasTokens) filesUsingTokens++; - if (hasHardcoded) filesWithHardcoded++; - } - - return { - tokenUsages, - hardcodedColors, - filesUsingTokens, - filesWithHardcoded, - totalFiles: files.length, - }; -} - -function main() { - const targetPath = process.argv[2]; - if (!targetPath) { - console.log('Usage: node scripts/token-coverage.js '); - console.log(''); - console.log('Examples:'); - console.log(' node scripts/token-coverage.js ../../mindlyst-native/iosApp'); - console.log(' node scripts/token-coverage.js ../../learning_ai_clock/web/src'); - process.exit(1); - } - - const absolutePath = resolve(targetPath); - console.log(`📊 Analyzing token coverage for ${absolutePath}\n`); - - const files = findFiles(absolutePath); - const platform = detectPlatform(files); - - console.log(`Detected platform: ${platform}`); - console.log(`Total files: ${files.length}\n`); - - if (files.length === 0) { - console.log('❌ No source files found'); - process.exit(1); - } - - const coverage = analyzeCoverage(files, platform); - - console.log(`${'='.repeat(50)}`); - console.log('📈 Coverage Report'); - console.log(`${'='.repeat(50)}`); - console.log( - `Files using tokens: ${coverage.filesUsingTokens}/${coverage.totalFiles} (${((coverage.filesUsingTokens / coverage.totalFiles) * 100).toFixed(1)}%)` - ); - console.log( - `Files with hardcoded: ${coverage.filesWithHardcoded}/${coverage.totalFiles} (${((coverage.filesWithHardcoded / coverage.totalFiles) * 100).toFixed(1)}%)` - ); - console.log(`Token usages: ${coverage.tokenUsages}`); - console.log(`Hardcoded colors: ${coverage.hardcodedColors}`); - console.log(`${'='.repeat(50)}`); - - const tokenRatio = coverage.tokenUsages + coverage.hardcodedColors; - const tokenPercentage = - tokenRatio > 0 ? ((coverage.tokenUsages / tokenRatio) * 100).toFixed(1) : 'N/A'; - - console.log(`\n🎯 Token Adoption: ${tokenPercentage}%`); - - if (coverage.hardcodedColors > 0) { - console.log(`\n⚠️ Found ${coverage.hardcodedColors} hardcoded colors.`); - console.log(' Run validate-tokens.js for details.'); - } - - if (coverage.filesUsingTokens === 0) { - console.log('\n❌ No token usage detected. Product needs token integration.'); - process.exit(1); - } - - console.log('\n✅ Coverage analysis complete.'); -} - -main(); diff --git a/vendor/bytelyst/design-tokens/scripts/validate-tokens.cjs b/vendor/bytelyst/design-tokens/scripts/validate-tokens.cjs deleted file mode 100755 index 13e22b9..0000000 --- a/vendor/bytelyst/design-tokens/scripts/validate-tokens.cjs +++ /dev/null @@ -1,113 +0,0 @@ -#!/usr/bin/env node -/** - * Token validation script — checks for hardcoded colors in source files - * and reports token coverage per product. - * - * Usage: node scripts/validate-tokens.js [product-path] - */ - -const { readFileSync, readdirSync, statSync } = require('fs'); -const { join, resolve } = require('path'); - -const HARD_COLOR_REGEX = /#[0-9A-Fa-f]{3,8}\b|rgb\([^)]+\)|rgba\([^)]+\)|hsl\([^)]+\)/g; -const EXCLUDED_DIRS = ['node_modules', 'dist', 'build', '.git', 'generated', '__mocks__']; -const INCLUDED_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.swift', '.kt']; - -function findFiles(dir, files = []) { - try { - const items = readdirSync(dir); - for (const item of items) { - const fullPath = join(dir, item); - if (EXCLUDED_DIRS.some(ex => fullPath.includes(ex))) continue; - - const stat = statSync(fullPath); - if (stat.isDirectory()) { - findFiles(fullPath, files); - } else if (INCLUDED_EXTENSIONS.some(ext => item.endsWith(ext))) { - files.push(fullPath); - } - } - } catch { - // Directory might not exist or be accessible - } - return files; -} - -function analyzeFile(filePath) { - const content = readFileSync(filePath, 'utf-8'); - const lines = content.split('\n'); - const issues = []; - - lines.forEach((line, index) => { - // Skip comments - if (line.trim().startsWith('//') || line.trim().startsWith('*') || line.trim().startsWith('/*')) - return; - - const matches = line.match(HARD_COLOR_REGEX); - if (matches) { - // Filter out legitimate uses (like transparency values) - const suspicious = matches.filter(m => { - if (m.startsWith('#') && (m.length === 9 || m.length === 5)) return false; // Skip alpha hex - if (m.includes('0.0') || m.includes('1.0')) return false; // Skip clear/opaque - // Skip CSS-variable token usage (already tokenized) - if ((m.startsWith('hsl(') || m.startsWith('rgb(') || m.startsWith('rgba(')) && m.includes('var(--')) - return false; - return true; - }); - - if (suspicious.length > 0) { - issues.push({ - line: index + 1, - colors: suspicious, - content: line.trim().slice(0, 80), - }); - } - } - }); - - return issues; -} - -function main() { - const targetPath = process.argv[2] || '.'; - const absolutePath = resolve(targetPath); - - console.log(`🔍 Scanning ${absolutePath} for hardcoded colors...\n`); - - const files = findFiles(absolutePath); - let totalIssues = 0; - let filesWithIssues = 0; - - for (const file of files) { - const issues = analyzeFile(file); - if (issues.length > 0) { - filesWithIssues++; - totalIssues += issues.length; - const relativePath = file.replace(absolutePath, '').slice(1); - console.log(`\n📄 ${relativePath}`); - issues.forEach(issue => { - console.log(` Line ${issue.line}: ${issue.colors.join(', ')}`); - console.log(` ${issue.content}`); - }); - } - } - - console.log(`\n${'='.repeat(60)}`); - console.log(`📊 Summary:`); - console.log(` Files scanned: ${files.length}`); - console.log(` Files with hardcoded colors: ${filesWithIssues}`); - console.log(` Total hardcoded colors found: ${totalIssues}`); - - if (totalIssues > 0) { - console.log(`\n⚠️ Consider replacing hardcoded colors with design tokens:`); - console.log(` Web: var(--ml-) from @bytelyst/design-tokens`); - console.log(` iOS: MindLystColors.dark / MindLystColors.light`); - console.log(` KMP: MindLystTokens.Dark. / MindLystTokens.Light.`); - process.exit(1); - } else { - console.log(`\n✅ No hardcoded colors found! All colors use design tokens.`); - process.exit(0); - } -} - -main(); diff --git a/vendor/bytelyst/design-tokens/src/__tests__/tokens.test.ts b/vendor/bytelyst/design-tokens/src/__tests__/tokens.test.ts deleted file mode 100644 index 93c91f6..0000000 --- a/vendor/bytelyst/design-tokens/src/__tests__/tokens.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { readFileSync, existsSync } from 'node:fs'; -import { resolve, dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { loadTokens } from '../index.js'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const generatedDir = resolve(__dirname, '../../generated'); - -describe('loadTokens', () => { - it('returns a valid DesignTokens object', () => { - const tokens = loadTokens(); - expect(tokens).toBeDefined(); - expect(tokens.meta).toBeDefined(); - expect(tokens.meta.name).toBeTruthy(); - expect(tokens.meta.version).toBeTruthy(); - }); - - it('has color palette with expected keys', () => { - const tokens = loadTokens(); - expect(tokens.color).toBeDefined(); - expect(tokens.color.palette).toBeDefined(); - expect(tokens.color.semantic).toBeDefined(); - expect(tokens.color.semantic.dark).toBeDefined(); - expect(tokens.color.brain).toBeDefined(); - }); - - it('has typography with font families', () => { - const tokens = loadTokens(); - expect(tokens.typography.fontFamily).toBeDefined(); - expect(Object.keys(tokens.typography.fontFamily).length).toBeGreaterThan(0); - }); - - it('has spacing values on 4pt grid', () => { - const tokens = loadTokens(); - expect(tokens.spacing).toBeDefined(); - // All spacing values should be multiples of 4 - for (const [, value] of Object.entries(tokens.spacing)) { - expect(value % 4).toBe(0); - } - }); - - it('has radius values', () => { - const tokens = loadTokens(); - expect(tokens.radius).toBeDefined(); - expect(Object.keys(tokens.radius).length).toBeGreaterThan(0); - }); - - it('has motion durations and easings', () => { - const tokens = loadTokens(); - expect(tokens.motion).toBeDefined(); - expect(tokens.motion.duration).toBeDefined(); - expect(tokens.motion.easing).toBeDefined(); - }); - - it('caches after first load', () => { - const t1 = loadTokens(); - const t2 = loadTokens(); - expect(t1).toBe(t2); - }); -}); - -describe('generated files', () => { - it('tokens.css exists and contains --ml- properties', () => { - const cssPath = resolve(generatedDir, 'tokens.css'); - expect(existsSync(cssPath)).toBe(true); - const css = readFileSync(cssPath, 'utf-8'); - expect(css).toContain('--ml-'); - }); - - it('tokens.ts exists and exports token values', () => { - const tsPath = resolve(generatedDir, 'tokens.ts'); - expect(existsSync(tsPath)).toBe(true); - const ts = readFileSync(tsPath, 'utf-8'); - expect(ts).toContain('export'); - }); - - it('MindLystTokens.kt exists and contains object declaration', () => { - const ktPath = resolve(generatedDir, 'MindLystTokens.kt'); - expect(existsSync(ktPath)).toBe(true); - const kt = readFileSync(ktPath, 'utf-8'); - expect(kt).toContain('object MindLystTokens'); - }); - - it('MindLystTheme.swift exists and contains struct', () => { - const swiftPath = resolve(generatedDir, 'MindLystTheme.swift'); - expect(existsSync(swiftPath)).toBe(true); - const swift = readFileSync(swiftPath, 'utf-8'); - expect(swift).toContain('MindLyst'); - }); -}); diff --git a/vendor/bytelyst/design-tokens/src/index.ts b/vendor/bytelyst/design-tokens/src/index.ts deleted file mode 100644 index 0ee2faa..0000000 --- a/vendor/bytelyst/design-tokens/src/index.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * Design tokens — programmatic access to the canonical token JSON. - * For generated platform files, see the `generated/` directory. - * For the canonical JSON source, see `tokens/bytelyst.tokens.json`. - */ - -import { readFileSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -export interface DesignTokens { - meta: { name: string; version: string; updatedAt: string; scale: string }; - color: { - palette: Record>; - semantic: { - dark: Record; - light: Record; - }; - brain: Record; - }; - typography: { - fontFamily: Record; - fontWeight: Record; - fontSize: Record; - lineHeight: Record; - letterSpacing: Record; - }; - spacing: Record; - radius: Record; - elevation: Record; - motion: { - duration: Record; - easing: Record; - }; - breakpoints: Record; - layout: Record; -} - -let _cached: DesignTokens | null = null; - -export function loadTokens(): DesignTokens { - if (_cached) return _cached; - const tokenPath = resolve(__dirname, '../tokens/bytelyst.tokens.json'); - const raw = readFileSync(tokenPath, 'utf-8'); - _cached = JSON.parse(raw) as DesignTokens; - return _cached; -} diff --git a/vendor/bytelyst/design-tokens/tokens/bytelyst.tokens.json b/vendor/bytelyst/design-tokens/tokens/bytelyst.tokens.json deleted file mode 100644 index 007e896..0000000 --- a/vendor/bytelyst/design-tokens/tokens/bytelyst.tokens.json +++ /dev/null @@ -1,369 +0,0 @@ -{ - "meta": { - "name": "ByteLyst Design Tokens", - "version": "1.1.0", - "updatedAt": "2026-03-03", - "scale": "8pt" - }, - "color": { - "palette": { - "neutral": { - "0": "#FFFFFF", - "50": "#F6F8FC", - "100": "#EEF2FA", - "200": "#DCE4F2", - "300": "#BFCBDE", - "400": "#92A1BA", - "500": "#6C7C98", - "600": "#55637A", - "700": "#3B455A", - "800": "#1A2335", - "900": "#0E1320", - "950": "#06070A" - }, - "brand": { - "blue": "#5A8CFF", - "cyan": "#2EE6D6", - "coral": "#FF6E6E", - "gold": "#FFD166", - "mint": "#34D399", - "warning": "#F59E0B", - - "microsoftRed": "#F25022", - "microsoftGreen": "#7FBA00", - "microsoftBlue": "#00A4EF", - "microsoftYellow": "#FFB900", - - "googleBlue": "#4285F4", - "googleGreen": "#34A853", - "googleYellow": "#FBBC05", - "googleRed": "#EA4335" - } - }, - "semantic": { - "dark": { - "bgCanvas": "#06070A", - "bgElevated": "#0E1118", - "surfaceCard": "#121725", - "surfaceMuted": "#1A2335", - "borderDefault": "rgba(255,255,255,0.12)", - "borderStrong": "rgba(255,255,255,0.22)", - "textPrimary": "#EFF4FF", - "textSecondary": "#A5B1C7", - "textTertiary": "#6C7C98", - "accentPrimary": "#5A8CFF", - "accentSecondary": "#2EE6D6", - "success": "#34D399", - "warning": "#F59E0B", - "danger": "#FF6E6E", - "focusRing": "rgba(90,140,255,0.45)", - "overlayScrim": "rgba(5,8,18,0.72)" - }, - "light": { - "bgCanvas": "#F6F8FC", - "bgElevated": "#EEF2FA", - "surfaceCard": "#FFFFFF", - "surfaceMuted": "#F3F5FA", - "borderDefault": "rgba(14,19,32,0.12)", - "borderStrong": "rgba(14,19,32,0.24)", - "textPrimary": "#0E1320", - "textSecondary": "#55637A", - "textTertiary": "#6C7C98", - "accentPrimary": "#5A8CFF", - "accentSecondary": "#2EE6D6", - "success": "#13956A", - "warning": "#B87504", - "danger": "#D24242", - "focusRing": "rgba(90,140,255,0.35)", - "overlayScrim": "rgba(10,13,23,0.5)" - } - }, - "brain": { - "work": { "from": "#5A8CFF", "to": "#2EE6D6" }, - "home": { "from": "#FF6E6E", "to": "#FFD166" }, - "money": { "from": "#34D399", "to": "#2EE6D6" }, - "health": { "from": "#2EE6D6", "to": "#9FE870" }, - "global": { "from": "#7D8FB4", "to": "#A5B1C7" } - }, - "jarvisjr": { - "accentPrimary": "#7C6BFF", - "accentSecondary": "#5AE6C8", - "accentVoice": "#FF6B8A", - "agentCoach": "#5A8CFF", - "agentLingua": "#FFB74D", - "agentSpark": "#E040FB", - "agentMentor": "#34D399", - "agentMirror": "#2EE6D6", - "agentOrator": "#FF9F43" - }, - "peakpulse": { - "activityHike": "#34D399", - "activitySki": "#5A8CFF", - "speedZoneSlow": "#34D399", - "speedZoneFast": "#FFD166", - "speedZoneDanger": "#FF6E6E", - "elevationGain": "#2EE6D6", - "elevationLoss": "#FF9F43", - "personalBest": "#FFD700", - "streakActive": "#34D399", - "streakBroken": "#FF6E6E" - }, - "chronomind": { - "urgencyCritical": "#FF6E6E", - "urgencyImportant": "#FFD166", - "urgencyStandard": "#5A8CFF", - "urgencyGentle": "#34D399", - "urgencyPassive": "#A5B1C7", - "focusMode": "#7C6BFF", - "pomodoroWork": "#34D399", - "pomodoroBreak": "#5A8CFF", - "cascadeWarning": "#FF9F43", - "timerComplete": "#34D399" - }, - "nomgap": { - "stageFed": "#FF9F43", - "stageEarlyFast": "#FECA57", - "stageFasted": "#48DBFB", - "stageKetosis": "#5A8CFF", - "stageDeepAutophagy": "#A66BFF", - "stageExtended": "#FFD700", - "autophagyMeter": "#5AE68C", - "hydrationReminder": "#48DBFB", - "electrolyteAlert": "#FF9F43", - "safetyWarning": "#FF6E6E" - }, - "lysnrai": { - "recordingActive": "#FF6E6E", - "recordingPaused": "#FFD166", - "processing": "#5A8CFF", - "transcribed": "#34D399", - "dictationMode": "#7C6BFF", - "commandMode": "#2EE6D6", - "hotkeyActive": "#FF6B8A" - }, - "flowmonk": { - "bg": "#07111F", - "surface": "#0F1B2D", - "surfaceElevated": "#152338", - "border": "#24344D", - "text": "#EFF4FF", - "textMuted": "#A8B4C8", - "primary": "#5A8CFF", - "accent": "#5AE68C", - "warning": "#F59E0B", - "zonework": "#5A8CFF", - "zonePersonal": "#5AE68C", - "zoneHealth": "#FF6B6B", - "zoneAdmin": "#FECA57", - "zoneLearning": "#A66BFF", - "urgentBadge": "#FF6E6E", - "scheduleEntry": "#5A8CFF", - "overflowWarning": "#F59E0B", - "recommendationInfo": "#5A8CFF", - "recommendationWarning": "#F59E0B", - "recommendationCritical": "#FF6E6E" - }, - "actiontrail": { - "bg": "#07111F", - "surface": "#0F1B2D", - "surfaceElevated": "#152338", - "border": "#24344D", - "text": "#EFF4FF", - "textMuted": "#A8B4C8", - "primary": "#5A8CFF", - "accent": "#5AE68C", - "warning": "#F59E0B", - "danger": "#FF6E6E", - "riskLow": "#5AE68C", - "riskMedium": "#F59E0B", - "riskHigh": "#FF8C42", - "riskCritical": "#FF6E6E", - "statusPending": "#F59E0B", - "statusApplied": "#5AE68C", - "statusRejected": "#FF6E6E", - "statusReverted": "#A66BFF" - }, - "notelett": { - "bgCanvas": "#06070A", - "bgElevated": "#0E1118", - "surfaceCard": "#121725", - "surfaceMuted": "#1A2335", - "accentPrimary": "#5A8CFF", - "accentSecondary": "#2EE6D6", - "success": "#34D399", - "warning": "#F59E0B", - "danger": "#FF6E6E", - "focusRing": "#5A8CFF", - "agentAction": "#A66BFF", - "draftNote": "#FFD166", - "linkedNote": "#2EE6D6", - "taskPending": "#F59E0B", - "taskComplete": "#34D399" - }, - "localmemgpt": { - "bgPrimary": "#0A0A0A", - "bgSecondary": "#141414", - "bgTertiary": "#1E1E1E", - "bgHover": "#252525", - "bgInput": "#1A1A1A", - "border": "#2A2A2A", - "textPrimary": "#F0F0F0", - "textSecondary": "#999999", - "textMuted": "#666666", - "accent": "#6366F1", - "accentHover": "#818CF8", - "success": "#22C55E", - "warning": "#F59E0B", - "error": "#EF4444" - }, - "localllmlab": { - "bgCanvas": "#06070A", - "bgElevated": "#0E1118", - "surfaceCard": "#121725", - "surfaceMuted": "#1A2335", - "borderSubtle": "#1E293B", - "borderDefault": "#2A3654", - "textPrimary": "#EFF4FF", - "textSecondary": "#A5B1C7", - "textTertiary": "#6C7C98", - "accentPrimary": "#5A8CFF", - "accentSecondary": "#2EE6D6", - "success": "#34D399", - "warning": "#F59E0B", - "danger": "#FF6E6E", - "purple": "#A78BFA" - } - }, - "typography": { - "fontFamily": { - "display": "'Space Grotesk', 'SF Pro Display', sans-serif", - "body": "'DM Sans', 'SF Pro Text', sans-serif", - "mono": "'IBM Plex Mono', 'SF Mono', monospace" - }, - "fontWeight": { - "regular": 400, - "medium": 500, - "semibold": 600, - "bold": 700 - }, - "fontSize": { - "xs": 12, - "sm": 14, - "md": 16, - "lg": 18, - "xl": 22, - "2xl": 28, - "3xl": 36 - }, - "lineHeight": { - "tight": 1.2, - "normal": 1.45, - "relaxed": 1.65 - }, - "letterSpacing": { - "tight": -0.02, - "normal": 0, - "wide": 0.02 - } - }, - "spacing": { - "0": 0, - "1": 4, - "2": 8, - "3": 12, - "4": 16, - "5": 20, - "6": 24, - "7": 28, - "8": 32, - "10": 40, - "12": 48, - "16": 64 - }, - "radius": { - "xs": 8, - "sm": 12, - "md": 16, - "lg": 20, - "xl": 24, - "pill": 999 - }, - "elevation": { - "none": "0 0 0 rgba(0,0,0,0)", - "sm": "0 4px 12px rgba(0,0,0,0.12)", - "md": "0 12px 28px rgba(0,0,0,0.18)", - "lg": "0 20px 48px rgba(0,0,0,0.24)" - }, - "motion": { - "duration": { - "instant": 70, - "fast": 140, - "base": 220, - "slow": 320 - }, - "easing": { - "standard": "cubic-bezier(0.2, 0.0, 0.2, 1)", - "decelerate": "cubic-bezier(0.0, 0.0, 0.2, 1)", - "accelerate": "cubic-bezier(0.4, 0.0, 1, 1)" - } - }, - "breakpoints": { - "mobile": 0, - "tablet": 768, - "desktop": 1200, - "wide": 1440 - }, - "layout": { - "maxContentWidth": 1280, - "mobileGutter": 16, - "tabletGutter": 24, - "desktopGutter": 32, - "touchTargetMin": 44 - }, - "zIndex": { - "hidden": -1, - "base": 0, - "dropdown": 100, - "sticky": 200, - "fixed": 300, - "overlay": 400, - "modal": 500, - "popover": 600, - "toast": 700, - "tooltip": 800 - }, - "icon": { - "xs": 12, - "sm": 16, - "md": 20, - "lg": 24, - "xl": 32, - "2xl": 48 - }, - "grid": { - "columns": 12, - "gutter": 24, - "maxWidth": 1200, - "breakpoints": { - "xs": 0, - "sm": 576, - "md": 768, - "lg": 992, - "xl": 1200, - "xxl": 1400 - } - }, - "opacity": { - "0": 0, - "10": 0.1, - "20": 0.2, - "30": 0.3, - "40": 0.4, - "50": 0.5, - "60": 0.6, - "70": 0.7, - "80": 0.8, - "90": 0.9, - "100": 1 - } -} diff --git a/vendor/bytelyst/design-tokens/tsconfig.json b/vendor/bytelyst/design-tokens/tsconfig.json deleted file mode 100644 index 5edad81..0000000 --- a/vendor/bytelyst/design-tokens/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/vendor/bytelyst/diagnostics-client/package.json b/vendor/bytelyst/diagnostics-client/package.json deleted file mode 100644 index 079dbd5..0000000 --- a/vendor/bytelyst/diagnostics-client/package.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "name": "@bytelyst/diagnostics-client", - "version": "0.1.5", - "description": "TypeScript client for remote diagnostics and debug tracing", - "type": "module", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks" - }, - "dependencies": { - "@bytelyst/api-client": "workspace:*" - }, - "peerDependencies": { - "zod": "^3.22.0" - }, - "devDependencies": { - "@types/node": "^22.0.0", - "typescript": "^5.7.0", - "vitest": "^3.0.0" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/diagnostics-client/src/__tests__/client.test.ts b/vendor/bytelyst/diagnostics-client/src/__tests__/client.test.ts deleted file mode 100644 index 4b6364c..0000000 --- a/vendor/bytelyst/diagnostics-client/src/__tests__/client.test.ts +++ /dev/null @@ -1,227 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { - DiagnosticsClient, - BreadcrumbTrail, - NetworkInterceptor, -} from '../index.js'; - -describe('DiagnosticsClient', () => { - const mockConfig = { - productId: 'test-app', - anonymousInstallId: 'install_123', - platform: 'web', - channel: 'web_app', - osFamily: 'macos', - appVersion: '1.0.0', - buildNumber: '100', - releaseChannel: 'beta', - serverUrl: 'https://api.test.com', - pollIntervalMs: 100, // Fast polling for tests - logger: { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }, - }; - - let fetchMock: ReturnType; - - beforeEach(() => { - DiagnosticsClient.reset(); - vi.clearAllMocks(); - - // Mock fetch to return empty session (no active debug session) - fetchMock = vi.fn().mockResolvedValue({ - ok: true, - status: 200, - headers: new Map(), - json: async () => null, // No active session - }); - globalThis.fetch = fetchMock; - }); - - afterEach(() => { - DiagnosticsClient.reset(); - vi.restoreAllMocks(); - }); - - describe('singleton', () => { - it('should create instance with getInstance', () => { - const client = DiagnosticsClient.getInstance(mockConfig); - expect(client).toBeDefined(); - expect(DiagnosticsClient.isInitialized()).toBe(true); - }); - - it('should return same instance on subsequent calls', () => { - const client1 = DiagnosticsClient.getInstance(mockConfig); - const client2 = DiagnosticsClient.getInstance(); - expect(client1).toBe(client2); - }); - - it('should throw if getInstance called without config first', () => { - expect(() => DiagnosticsClient.getInstance()).toThrow( - 'must be initialized with config first' - ); - }); - - it('should reset instance', () => { - DiagnosticsClient.getInstance(mockConfig); - expect(DiagnosticsClient.isInitialized()).toBe(true); - DiagnosticsClient.reset(); - expect(DiagnosticsClient.isInitialized()).toBe(false); - }); - }); - - describe('lifecycle', () => { - it('should start and stop', async () => { - const client = DiagnosticsClient.getInstance(mockConfig); - await client.start(); - // After start, state should be polling (no active session from mock) - expect(client.getState().type).toBe('polling'); - client.stop(); - expect(client.getState().type).toBe('idle'); - }); - - it('should warn if started twice', async () => { - const client = DiagnosticsClient.getInstance(mockConfig); - await client.start(); - // First clear any calls from the first start - mockConfig.logger.warn.mockClear(); - // Second start should warn - await client.start(); - expect(mockConfig.logger.warn).toHaveBeenCalledWith( - '[diagnostics] Already started' - ); - }); - }); - - describe('session state', () => { - it('should report no active session initially', () => { - const client = DiagnosticsClient.getInstance(mockConfig); - expect(client.isSessionActive()).toBe(false); - expect(client.getCurrentSession()).toBeNull(); - }); - }); - - describe('logging', () => { - it('should record log entries', () => { - const client = DiagnosticsClient.getInstance(mockConfig); - client.log('info', 'Test message', { foo: 'bar' }); - // Logs are buffered, no immediate assertion - expect(client.getBreadcrumbs().length).toBeGreaterThan(0); - }); - - it('should add breadcrumb on log', () => { - const client = DiagnosticsClient.getInstance(mockConfig); - client.log('warn', 'Warning message'); - const crumbs = client.getBreadcrumbs(); - expect(crumbs.some(c => c.message.includes('Warning'))).toBe(true); - }); - }); - - describe('tracing', () => { - it('should trace successful operation', async () => { - const client = DiagnosticsClient.getInstance(mockConfig); - const result = await client.trace('test-op', () => 'success'); - expect(result).toBe('success'); - }); - - it('should trace async operation', async () => { - const client = DiagnosticsClient.getInstance(mockConfig); - const result = await client.trace('async-op', async () => { - await new Promise(r => setTimeout(r, 10)); - return 42; - }); - expect(result).toBe(42); - }); - - it('should propagate errors', async () => { - const client = DiagnosticsClient.getInstance(mockConfig); - await expect( - client.trace('failing-op', () => { - throw new Error('test error'); - }) - ).rejects.toThrow('test error'); - }); - }); - - describe('breadcrumbs', () => { - it('should add breadcrumbs', () => { - const client = DiagnosticsClient.getInstance(mockConfig); - client.breadcrumb('navigation', 'Page loaded', { path: '/' }); - const crumbs = client.getBreadcrumbs(); - expect(crumbs.length).toBe(1); - expect(crumbs[0].category).toBe('navigation'); - expect(crumbs[0].message).toBe('Page loaded'); - }); - - it('should include data in breadcrumbs', () => { - const client = DiagnosticsClient.getInstance(mockConfig); - client.breadcrumb('user', 'Clicked', { button: 'submit' }); - const crumbs = client.getBreadcrumbs(); - expect(crumbs[0].data).toEqual({ button: 'submit' }); - }); - }); -}); - -describe('BreadcrumbTrail', () => { - it('should add breadcrumbs', () => { - const trail = new BreadcrumbTrail(); - trail.add('test', 'message'); - expect(trail.size()).toBe(1); - }); - - it('should evict oldest when over limit', () => { - const trail = new BreadcrumbTrail({ maxSize: 3 }); - trail.add('a', '1'); - trail.add('b', '2'); - trail.add('c', '3'); - trail.add('d', '4'); // Should evict 'a' - expect(trail.size()).toBe(3); - const all = trail.getAll(); - expect(all[0].category).toBe('b'); - }); - - it('should get last N breadcrumbs', () => { - const trail = new BreadcrumbTrail(); - trail.add('a', '1'); - trail.add('b', '2'); - trail.add('c', '3'); - const last2 = trail.getLast(2); - expect(last2.length).toBe(2); - expect(last2[0].category).toBe('b'); - }); - - it('should get most recent', () => { - const trail = new BreadcrumbTrail(); - trail.add('a', '1'); - trail.add('b', '2'); - const recent = trail.getMostRecent(); - expect(recent?.category).toBe('b'); - }); - - it('should clear all', () => { - const trail = new BreadcrumbTrail(); - trail.add('a', '1'); - trail.clear(); - expect(trail.size()).toBe(0); - }); -}); - -describe('NetworkInterceptor', () => { - it('should start and stop', () => { - const onRequest = vi.fn(); - const interceptor = new NetworkInterceptor(onRequest); - interceptor.start(); - expect(interceptor.isRunning()).toBe(true); - interceptor.stop(); - expect(interceptor.isRunning()).toBe(false); - }); - - it('should not capture when stopped', () => { - const onRequest = vi.fn(); - const interceptor = new NetworkInterceptor(onRequest); - expect(interceptor.isRunning()).toBe(false); - }); -}); diff --git a/vendor/bytelyst/diagnostics-client/src/breadcrumbs.ts b/vendor/bytelyst/diagnostics-client/src/breadcrumbs.ts deleted file mode 100644 index 4898498..0000000 --- a/vendor/bytelyst/diagnostics-client/src/breadcrumbs.ts +++ /dev/null @@ -1,78 +0,0 @@ -/** - * Breadcrumb trail — ring buffer for timeline navigation - * - * @module breadcrumbs - */ - -import type { Breadcrumb } from './types.js'; - -export interface BreadcrumbTrailOptions { - /** Maximum number of breadcrumbs to keep (default: 100) */ - maxSize?: number; -} - -/** - * Ring buffer for breadcrumbs with fixed max size - */ -export class BreadcrumbTrail { - private breadcrumbs: Breadcrumb[] = []; - private maxSize: number; - - constructor(options: BreadcrumbTrailOptions = {}) { - this.maxSize = options.maxSize ?? 100; - } - - /** - * Add a breadcrumb to the trail - */ - add(category: string, message: string, data?: Record): void { - const breadcrumb: Breadcrumb = { - timestamp: new Date().toISOString(), - category, - message, - data, - }; - - this.breadcrumbs.push(breadcrumb); - - // Evict oldest if over limit - if (this.breadcrumbs.length > this.maxSize) { - this.breadcrumbs.shift(); - } - } - - /** - * Get all breadcrumbs (oldest first) - */ - getAll(): Breadcrumb[] { - return [...this.breadcrumbs]; - } - - /** - * Get last N breadcrumbs - */ - getLast(n: number): Breadcrumb[] { - return this.breadcrumbs.slice(-n); - } - - /** - * Get most recent breadcrumb - */ - getMostRecent(): Breadcrumb | null { - return this.breadcrumbs[this.breadcrumbs.length - 1] ?? null; - } - - /** - * Clear all breadcrumbs - */ - clear(): void { - this.breadcrumbs = []; - } - - /** - * Get current size - */ - size(): number { - return this.breadcrumbs.length; - } -} diff --git a/vendor/bytelyst/diagnostics-client/src/client.ts b/vendor/bytelyst/diagnostics-client/src/client.ts deleted file mode 100644 index 0118e8b..0000000 --- a/vendor/bytelyst/diagnostics-client/src/client.ts +++ /dev/null @@ -1,573 +0,0 @@ -/** - * Main DiagnosticsClient — singleton for remote diagnostics collection - * - * @module client - */ - -import type { - DiagnosticsConfig, - DiagnosticsSession, - ClientState, - LogLevel, - TraceSpan, - LogEntry, - Breadcrumb, - NetworkRequest, - DeviceState, -} from './types.js'; -import { BreadcrumbTrail } from './breadcrumbs.js'; -import { NetworkInterceptor } from './network.js'; -import { collectDeviceState } from './device.js'; - -// DOM type declarations for ESLint -type ErrorEvent = { - message: string; - filename: string; - lineno: number; - colno: number; - error?: { stack?: string }; -}; - -export interface DiagnosticsClientOptions extends DiagnosticsConfig { - /** Custom logger */ - logger?: { - debug: (msg: string, meta?: Record) => void; - info: (msg: string, meta?: Record) => void; - warn: (msg: string, meta?: Record) => void; - error: (msg: string, meta?: Record) => void; - }; -} - -/** - * Diagnostics client for remote debug session collection - */ -export class DiagnosticsClient { - private static instance: DiagnosticsClient | null = null; - - private config: DiagnosticsClientOptions & { - pollIntervalMs: number; - maxBreadcrumbs: number; - captureConsole: boolean; - captureErrors: boolean; - captureNetwork: boolean; - networkExcludePatterns: RegExp[]; - logger: NonNullable; - }; - private state: ClientState = { type: 'idle' }; - private breadcrumbs: BreadcrumbTrail; - private networkInterceptor: NetworkInterceptor | null = null; - private pollTimer: ReturnType | null = null; - private logBuffer: LogEntry[] = []; - private traceBuffer: TraceSpan[] = []; - private networkBuffer: NetworkRequest[] = []; - private flushTimer: ReturnType | null = null; - private lastEtag: string | null = null; - - private constructor(config: DiagnosticsClientOptions) { - this.config = { - ...config, - pollIntervalMs: config.pollIntervalMs ?? 5000, - maxBreadcrumbs: config.maxBreadcrumbs ?? 100, - captureConsole: config.captureConsole ?? true, - captureErrors: config.captureErrors ?? true, - captureNetwork: config.captureNetwork ?? true, - networkExcludePatterns: config.networkExcludePatterns ?? [], - logger: config.logger ?? { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - }, - }; - - this.breadcrumbs = new BreadcrumbTrail({ - maxSize: this.config.maxBreadcrumbs, - }); - } - - /** - * Get singleton instance - */ - static getInstance(config?: DiagnosticsClientOptions): DiagnosticsClient { - if (!DiagnosticsClient.instance) { - if (!config) { - throw new Error('DiagnosticsClient must be initialized with config first'); - } - DiagnosticsClient.instance = new DiagnosticsClient(config); - } - return DiagnosticsClient.instance; - } - - /** - * Check if client is initialized - */ - static isInitialized(): boolean { - return DiagnosticsClient.instance !== null; - } - - /** - * Reset singleton (for testing) - */ - static reset(): void { - DiagnosticsClient.instance?.stop(); - DiagnosticsClient.instance = null; - } - - /** - * Start polling for active debug sessions - */ - async start(): Promise { - if (this.state.type === 'polling' || this.state.type === 'active') { - this.config.logger.warn('[diagnostics] Already started'); - return; - } - - this.state = { type: 'polling', session: null }; - this.config.logger.info('[diagnostics] Starting diagnostics client'); - - // Initial poll - await this.pollForSession(); - - // Start polling timer - this.pollTimer = setInterval(() => { - this.pollForSession().catch(err => { - this.config.logger.error('[diagnostics] Poll error', { error: err.message }); - }); - }, this.config.pollIntervalMs); - - // Start auto-flush timer (every 30 seconds) - this.flushTimer = setInterval(() => { - this.flush().catch(err => { - this.config.logger.error('[diagnostics] Flush error', { error: err.message }); - }); - }, 30000); - - // Setup auto-capture if configured - if (this.config.captureNetwork) { - this.setupNetworkCapture(); - } - - if (this.config.captureConsole) { - this.setupConsoleCapture(); - } - - if (this.config.captureErrors) { - this.setupErrorCapture(); - } - - this.breadcrumbs.add('diagnostics', 'Client started'); - } - - /** - * Stop polling and cleanup - */ - stop(): void { - this.config.logger.info('[diagnostics] Stopping diagnostics client'); - - if (this.pollTimer) { - clearInterval(this.pollTimer); - this.pollTimer = null; - } - - if (this.flushTimer) { - clearInterval(this.flushTimer); - this.flushTimer = null; - } - - this.networkInterceptor?.stop(); - this.networkInterceptor = null; - - // Final flush - this.flush().catch(() => {}); - - this.state = { type: 'idle' }; - this.breadcrumbs.add('diagnostics', 'Client stopped'); - } - - /** - * Check if a debug session is currently active - */ - isSessionActive(): boolean { - return this.state.type === 'active'; - } - - /** - * Get current session if active - */ - getCurrentSession(): DiagnosticsSession | null { - return this.state.type === 'active' || this.state.type === 'polling' - ? (this.state as { session: DiagnosticsSession | null }).session - : null; - } - - /** - * Get current client state - */ - getState(): ClientState { - return this.state; - } - - /** - * Record a log entry - */ - log(level: LogLevel, message: string, context: Record = {}): void { - const entry: LogEntry = { - level, - message, - timestamp: new Date().toISOString(), - module: (context.module as string) ?? 'unknown', - context, - correlationId: context.correlationId as string, - }; - - this.logBuffer.push(entry); - this.breadcrumbs.add('log', `[${level.toUpperCase()}] ${message.slice(0, 100)}`, { level }); - - // Auto-flush on fatal - if (level === 'fatal') { - this.flush().catch(() => {}); - } - } - - /** - * Record a trace span (auto-instrumented) - */ - async trace(name: string, operation: () => Promise): Promise; - async trace(name: string, operation: () => T): Promise; - async trace(name: string, operation: () => T | Promise): Promise { - const span: TraceSpan = { - spanId: this.generateId(), - name, - kind: 'internal', - startTime: new Date().toISOString(), - attributes: {}, - status: 'unset', - }; - - this.breadcrumbs.add('trace', `Starting: ${name}`, { spanId: span.spanId }); - - try { - const result = await operation(); - span.endTime = new Date().toISOString(); - span.durationMs = new Date(span.endTime).getTime() - new Date(span.startTime).getTime(); - span.status = 'ok'; - this.traceBuffer.push(span); - this.breadcrumbs.add('trace', `Completed: ${name}`, { - spanId: span.spanId, - durationMs: span.durationMs, - }); - return result; - } catch (error) { - span.endTime = new Date().toISOString(); - span.durationMs = new Date(span.endTime).getTime() - new Date(span.startTime).getTime(); - span.status = 'error'; - span.statusMessage = error instanceof Error ? error.message : String(error); - this.traceBuffer.push(span); - this.breadcrumbs.add('trace', `Failed: ${name}`, { - spanId: span.spanId, - error: span.statusMessage, - }); - throw error; - } - } - - /** - * Add a manual breadcrumb - */ - breadcrumb(category: string, message: string, data?: Record): void { - this.breadcrumbs.add(category, message, data); - } - - /** - * Get all breadcrumbs - */ - getBreadcrumbs(): Breadcrumb[] { - return this.breadcrumbs.getAll(); - } - - /** - * Collect and return device state - */ - collectDeviceState(): DeviceState { - return collectDeviceState(); - } - - /** - * Poll server for active session config - */ - private async pollForSession(): Promise { - try { - const url = new URL('/api/diagnostics/config', this.config.serverUrl); - url.searchParams.set('productId', this.config.productId); - url.searchParams.set('installId', this.config.anonymousInstallId); - - const headers: Record = { - Accept: 'application/json', - }; - - if (this.lastEtag) { - headers['If-None-Match'] = this.lastEtag; - } - - const token = await this.getAuthToken(); - if (token) { - headers['Authorization'] = `Bearer ${token}`; - } - - const response = await fetch(url.toString(), { headers }); - - if (response.status === 304) { - // No change - return; - } - - if (!response.ok) { - throw new Error(`HTTP ${response.status}`); - } - - // Store ETag for caching - const etag = response.headers.get('ETag'); - if (etag) { - this.lastEtag = etag; - } - - const session: DiagnosticsSession | null = await response.json(); - - // Update state - if (session && session.status === 'active') { - if (this.state.type !== 'active') { - this.config.logger.info('[diagnostics] Session activated', { sessionId: session.id }); - this.breadcrumbs.add('diagnostics', 'Session activated', { sessionId: session.id }); - } - this.state = { type: 'active', session }; - } else { - if (this.state.type === 'active') { - this.config.logger.info('[diagnostics] Session ended'); - this.breadcrumbs.add('diagnostics', 'Session ended'); - } - this.state = { type: 'polling', session: null }; - } - } catch (error) { - this.config.logger.error('[diagnostics] Failed to poll for session', { - error: error instanceof Error ? error.message : String(error), - }); - this.state = { - type: 'error', - error: error instanceof Error ? error : new Error(String(error)), - }; - } - } - - /** - * Flush buffered data to server - */ - private async flush(): Promise { - const session = this.getCurrentSession(); - if (!session) { - // No active session, clear buffers - this.logBuffer = []; - this.traceBuffer = []; - this.networkBuffer = []; - return; - } - - const sessionId = session.id; - - const logs = this.logBuffer.splice(0, 50); // Server max: 50 - const traces = this.traceBuffer.splice(0, 50); // Server max: 50 - const network = this.networkBuffer.splice(0, 50); - const crumbs = this.breadcrumbs.getAll(); - this.breadcrumbs.clear(); - - // Encode breadcrumbs + network captures as log entries so we can ingest - // without requiring additional server-side schemas/endpoints. - const synthesizedLogs = [] as LogEntry[]; - - for (const c of crumbs) { - synthesizedLogs.push({ - level: 'info', - message: `[breadcrumb] ${c.category}: ${c.message}`, - timestamp: c.timestamp, - module: 'diagnostics.breadcrumb', - context: c.data ?? {}, - }); - } - - for (const n of network) { - synthesizedLogs.push({ - level: n.error ? 'error' : 'info', - message: `[network] ${n.method} ${n.url} ${n.status ?? ''}`.trim(), - timestamp: n.startTime, - module: 'diagnostics.network', - context: { - requestHeaders: n.requestHeaders, - requestBody: n.requestBody, - status: n.status, - responseHeaders: n.responseHeaders, - responseBody: n.responseBody, - startTime: n.startTime, - endTime: n.endTime, - durationMs: n.durationMs, - error: n.error, - }, - }); - } - - const allLogs = [...logs, ...synthesizedLogs]; - - if (allLogs.length === 0 && traces.length === 0) { - return; - } - - const token = await this.getAuthToken(); - const headers: Record = { - 'Content-Type': 'application/json', - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }; - - try { - if (allLogs.length > 0) { - const url = new URL( - `/api/diagnostics/sessions/${encodeURIComponent(sessionId)}/logs`, - this.config.serverUrl - ); - const response = await fetch(url.toString(), { - method: 'POST', - headers, - body: JSON.stringify({ sessionId, logs: allLogs }), - }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - } - - if (traces.length > 0) { - const url = new URL( - `/api/diagnostics/sessions/${encodeURIComponent(sessionId)}/traces`, - this.config.serverUrl - ); - const response = await fetch(url.toString(), { - method: 'POST', - headers, - body: JSON.stringify({ sessionId, traces }), - }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - } - - this.config.logger.debug('[diagnostics] Flushed batch', { - logs: allLogs.length, - traces: traces.length, - }); - } catch (error) { - this.config.logger.error('[diagnostics] Failed to flush batch', { - error: error instanceof Error ? error.message : String(error), - }); - - // Put items back in buffers for retry - if (logs.length > 0) this.logBuffer.unshift(...logs); - if (traces.length > 0) this.traceBuffer.unshift(...traces); - if (network.length > 0) this.networkBuffer.unshift(...network); - - // Breadcrumbs were converted; keep a small breadcrumb trail hint for later flush. - for (const c of crumbs.slice(-10)) { - this.breadcrumbs.add(c.category, c.message, c.data); - } - } - } - - /** - * Setup network capture - */ - private setupNetworkCapture(): void { - this.networkInterceptor = new NetworkInterceptor( - request => { - this.networkBuffer.push(request); - }, - { - excludePatterns: this.config.networkExcludePatterns, - } - ); - this.networkInterceptor.start(); - this.breadcrumbs.add('diagnostics', 'Network capture enabled'); - } - - /** - * Setup console capture - */ - /* eslint-disable no-console -- This method intentionally wraps console APIs to capture diagnostics, then forwards to the originals. */ - private setupConsoleCapture(): void { - const originalConsole = { - log: console.log.bind(console), - info: console.info.bind(console), - warn: console.warn.bind(console), - error: console.error.bind(console), - }; - - const capture = (level: LogLevel, args: unknown[]) => { - const message = args - .map(a => (typeof a === 'object' ? JSON.stringify(a) : String(a))) - .join(' '); - this.log(level, message, { module: 'console', source: 'captured' }); - }; - - console.log = (...args: unknown[]) => { - capture('debug', args); - originalConsole.log(...args); - }; - console.info = (...args: unknown[]) => { - capture('info', args); - originalConsole.info(...args); - }; - console.warn = (...args: unknown[]) => { - capture('warn', args); - originalConsole.warn(...args); - }; - console.error = (...args: unknown[]) => { - capture('error', args); - originalConsole.error(...args); - }; - - this.breadcrumbs.add('diagnostics', 'Console capture enabled'); - } - /* eslint-enable no-console */ - - /** - * Setup error capture - */ - private setupErrorCapture(): void { - if (typeof window === 'undefined') return; - - const handler = (event: ErrorEvent) => { - this.log('error', event.message, { - module: 'window.onerror', - source: 'captured', - filename: event.filename, - lineno: event.lineno, - colno: event.colno, - error: event.error?.stack, - }); - this.breadcrumbs.add('error', `Uncaught: ${event.message.slice(0, 100)}`); - }; - - window.addEventListener('error', handler); - this.breadcrumbs.add('diagnostics', 'Error capture enabled'); - } - - /** - * Get auth token - */ - private async getAuthToken(): Promise { - if (!this.config.getAuthToken) return null; - try { - const token = await this.config.getAuthToken(); - return token; - } catch { - return null; - } - } - - /** - * Generate unique ID - */ - private generateId(): string { - return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 9)}`; - } -} diff --git a/vendor/bytelyst/diagnostics-client/src/device.ts b/vendor/bytelyst/diagnostics-client/src/device.ts deleted file mode 100644 index efe43b7..0000000 --- a/vendor/bytelyst/diagnostics-client/src/device.ts +++ /dev/null @@ -1,86 +0,0 @@ -/** - * Device state collector — memory, battery, storage, network - * - * @module device - */ - -import type { DeviceState } from './types.js'; - -// DOM type declarations for ESLint -type Navigator = { - onLine: boolean; - connection?: { effectiveType?: string }; - getBattery?: () => Promise<{ charging: boolean; level: number }>; - storage?: { estimate(): Promise<{ usage?: number }> }; -}; -declare const navigator: Navigator; -declare const performance: { memory?: { usedJSHeapSize: number } }; -interface Window { - addEventListener: (type: string, listener: () => void) => void; - removeEventListener: (type: string, listener: () => void) => void; -} -declare const window: Window; - -/** - * Collect current device state - * Best-effort: some APIs may not be available in all environments - */ -export function collectDeviceState(): DeviceState { - const state: DeviceState = { - isOnline: navigator.onLine ?? true, - }; - - // Network type (experimental API) - const connection = (navigator as { connection?: { effectiveType?: string } }).connection; - if (connection) { - state.networkType = connection.effectiveType ?? 'unknown'; - } - - // Battery API (experimental, not widely supported) - // Note: Battery API is deprecated but still useful for diagnostics - const battery = ( - navigator as { getBattery?: () => Promise<{ charging: boolean; level: number }> } - ).getBattery; - if (battery) { - // We'll return a promise, but sync API can't wait - // Store last known value if available - } - - // Memory (Chrome-only experimental) - const memory = (performance as { memory?: { usedJSHeapSize: number } }).memory; - if (memory) { - state.memoryMB = Math.round(memory.usedJSHeapSize / 1024 / 1024); - } - - // Storage (async, but we'll fire-and-forget) - if (navigator.storage && navigator.storage.estimate) { - navigator.storage - .estimate() - .then(estimate => { - if (estimate.usage !== undefined) { - state.storageMB = Math.round(estimate.usage / 1024 / 1024); - } - }) - .catch(() => { - // Ignore errors - }); - } - - return state; -} - -/** - * Subscribe to online/offline events - */ -export function subscribeToConnectivity(callback: (isOnline: boolean) => void): () => void { - const handleOnline = () => callback(true); - const handleOffline = () => callback(false); - - window.addEventListener('online', handleOnline); - window.addEventListener('offline', handleOffline); - - return () => { - window.removeEventListener('online', handleOnline); - window.removeEventListener('offline', handleOffline); - }; -} diff --git a/vendor/bytelyst/diagnostics-client/src/index.ts b/vendor/bytelyst/diagnostics-client/src/index.ts deleted file mode 100644 index 426000b..0000000 --- a/vendor/bytelyst/diagnostics-client/src/index.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * @bytelyst/diagnostics-client - * - * Remote diagnostics and debug tracing client for the ByteLyst ecosystem. - * Provides polling, logging, tracing, network capture, and breadcrumbs. - * - * @example - * ```typescript - * import { DiagnosticsClient } from '@bytelyst/diagnostics-client'; - * - * const client = DiagnosticsClient.getInstance({ - * productId: 'myapp', - * anonymousInstallId: 'install_123', - * platform: 'web', - * channel: 'web_app', - * osFamily: 'macos', - * appVersion: '1.0.0', - * buildNumber: '100', - * releaseChannel: 'stable', - * serverUrl: 'https://api.bytelyst.com', - * }); - * - * await client.start(); - * - * // Auto-instrumented trace - * const result = await client.trace('fetchUser', async () => { - * return await fetch('/api/user').then(r => r.json()); - * }); - * - * // Manual breadcrumb - * client.breadcrumb('user', 'Clicked submit button', { formId: 'signup' }); - * - * // Manual log - * client.log('info', 'User signed up', { userId: '123' }); - * ``` - */ - -export { DiagnosticsClient, type DiagnosticsClientOptions } from './client.js'; - -export { createWebDiagnostics, type WebDiagnosticsConfig } from './web.js'; - -export { BreadcrumbTrail, type BreadcrumbTrailOptions } from './breadcrumbs.js'; - -export { NetworkInterceptor, type NetworkInterceptorOptions } from './network.js'; - -export { collectDeviceState, subscribeToConnectivity } from './device.js'; - -export type { - LogLevel, - SessionStatus, - CollectionLevel, - DiagnosticsSession, - TraceSpan, - LogEntry, - Breadcrumb, - NetworkRequest, - DeviceState, - DiagnosticsConfig, - ClientState, - IngestBatch, -} from './types.js'; diff --git a/vendor/bytelyst/diagnostics-client/src/network.ts b/vendor/bytelyst/diagnostics-client/src/network.ts deleted file mode 100644 index dfc3956..0000000 --- a/vendor/bytelyst/diagnostics-client/src/network.ts +++ /dev/null @@ -1,214 +0,0 @@ -/** - * Network interceptor — capture HTTP requests/responses - * - * @module network - */ - -import type { NetworkRequest } from './types.js'; - -// DOM type declarations for ESLint -type RequestInfo = string | Request | URL; -type HeadersInit = Headers | Record | string[][]; - -export interface NetworkInterceptorOptions { - /** URL patterns to include (default: all) */ - includePatterns?: RegExp[]; - /** URL patterns to exclude */ - excludePatterns?: RegExp[]; - /** Max request body size to capture (default: 100KB) */ - maxBodySize?: number; - /** Whether to capture request headers (default: true) */ - captureRequestHeaders?: boolean; - /** Whether to capture response headers (default: true) */ - captureResponseHeaders?: boolean; - /** Sanitize header values matching these patterns */ - sensitiveHeaderPatterns?: RegExp[]; -} - -/** - * Interceptor for capturing network requests - */ -export class NetworkInterceptor { - private options: Required; - private originalFetch: typeof fetch; - private isActive = false; - private pendingRequests = new Map(); - private onRequest: (request: NetworkRequest) => void; - - constructor( - onRequest: (request: NetworkRequest) => void, - options: NetworkInterceptorOptions = {} - ) { - this.onRequest = onRequest; - this.options = { - includePatterns: options.includePatterns ?? [], - excludePatterns: options.excludePatterns ?? [], - maxBodySize: options.maxBodySize ?? 100 * 1024, - captureRequestHeaders: options.captureRequestHeaders ?? true, - captureResponseHeaders: options.captureResponseHeaders ?? true, - sensitiveHeaderPatterns: options.sensitiveHeaderPatterns ?? [ - /authorization/i, - /cookie/i, - /token/i, - /api-key/i, - ], - }; - this.originalFetch = globalThis.fetch.bind(globalThis); - } - - /** - * Start intercepting fetch calls - */ - start(): void { - if (this.isActive) return; - this.isActive = true; - - globalThis.fetch = this.interceptedFetch.bind(this); - } - - /** - * Stop intercepting fetch calls - */ - stop(): void { - if (!this.isActive) return; - this.isActive = false; - - globalThis.fetch = this.originalFetch; - } - - /** - * Check if URL should be captured - */ - private shouldCapture(url: string): boolean { - // Check excludes first - for (const pattern of this.options.excludePatterns) { - if (pattern.test(url)) return false; - } - - // If includes specified, must match one - if (this.options.includePatterns.length > 0) { - for (const pattern of this.options.includePatterns) { - if (pattern.test(url)) return true; - } - return false; - } - - return true; - } - - /** - * Sanitize headers - */ - private sanitizeHeaders( - headers: HeadersInit | undefined - ): Record { - const sanitized: Record = {}; - const headerEntries = headers instanceof Headers - ? Array.from(headers.entries()) - : typeof headers === 'object' && headers !== null - ? Object.entries(headers) - : []; - - for (const [key, value] of headerEntries) { - const isSensitive = this.options.sensitiveHeaderPatterns.some(p => - p.test(key) - ); - sanitized[key] = isSensitive ? '[REDACTED]' : value; - } - - return sanitized; - } - - /** - * Generate request ID - */ - private generateId(): string { - return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 9)}`; - } - - /** - * Intercepted fetch implementation - */ - private async interceptedFetch( - input: RequestInfo | URL, - init?: RequestInit - ): Promise { - const url = input.toString(); - const shouldCapture = this.shouldCapture(url); - - const requestId = this.generateId(); - const startTime = new Date().toISOString(); - - // Create request record if capturing - if (shouldCapture) { - const request: NetworkRequest = { - id: requestId, - url: url.slice(0, 2048), // Limit URL length - method: (init?.method ?? 'GET').toUpperCase(), - requestHeaders: this.options.captureRequestHeaders - ? this.sanitizeHeaders(init?.headers) - : {}, - startTime, - }; - - // Capture request body if present and not too large - if (init?.body && typeof init.body === 'string') { - if (init.body.length <= this.options.maxBodySize) { - request.requestBody = init.body.slice(0, this.options.maxBodySize); - } - } - - this.pendingRequests.set(requestId, request); - } - - try { - const response = await this.originalFetch(input, init); - - // Update with response info if capturing - if (shouldCapture) { - const request = this.pendingRequests.get(requestId); - if (request) { - request.status = response.status; - request.endTime = new Date().toISOString(); - request.durationMs = new Date(request.endTime).getTime() - new Date(startTime).getTime(); - - if (this.options.captureResponseHeaders) { - request.responseHeaders = this.sanitizeHeaders( - Object.fromEntries(response.headers.entries()) - ); - } - - // Don't capture response body (too large/complex) - // Just record that we received a response - - this.pendingRequests.delete(requestId); - this.onRequest(request); - } - } - - return response; - } catch (error) { - // Record error if capturing - if (shouldCapture) { - const request = this.pendingRequests.get(requestId); - if (request) { - request.endTime = new Date().toISOString(); - request.durationMs = new Date(request.endTime).getTime() - new Date(startTime).getTime(); - request.error = error instanceof Error ? error.message : String(error); - - this.pendingRequests.delete(requestId); - this.onRequest(request); - } - } - - throw error; - } - } - - /** - * Check if interceptor is active - */ - isRunning(): boolean { - return this.isActive; - } -} diff --git a/vendor/bytelyst/diagnostics-client/src/types.ts b/vendor/bytelyst/diagnostics-client/src/types.ts deleted file mode 100644 index c7d3c47..0000000 --- a/vendor/bytelyst/diagnostics-client/src/types.ts +++ /dev/null @@ -1,233 +0,0 @@ -/** - * Core types for @bytelyst/diagnostics-client - * - * @module types - */ - -/** - * Log severity levels (matches syslog/OpenTelemetry) - */ -export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'fatal'; - -/** - * Session status from the server - */ -export type SessionStatus = 'pending' | 'active' | 'paused' | 'completed' | 'cancelled'; - -/** - * Collection level determines verbosity of captured data - */ -export type CollectionLevel = 'standard' | 'debug' | 'trace'; - -/** - * Diagnostic session configuration from server - */ -export interface DiagnosticsSession { - /** Session ID */ - id: string; - /** Product identifier */ - productId: string; - /** Current session status */ - status: SessionStatus; - /** Collection verbosity level */ - collectionLevel: CollectionLevel; - /** Whether to capture logs */ - captureLogs: boolean; - /** Whether to capture network traces */ - captureNetwork: boolean; - /** Whether to capture screenshots */ - captureScreenshots: boolean; - /** Auto-capture screenshot on error */ - screenshotOnError: boolean; - /** Maximum session duration in minutes */ - maxDurationMinutes: number; - /** Session creation time */ - createdAt: string; - /** Session expiry time */ - expiresAt: string; -} - -/** - * OpenTelemetry-compatible trace span - */ -export interface TraceSpan { - /** Unique span ID */ - spanId: string; - /** Parent span ID (null for root) */ - parentId?: string; - /** Operation name */ - name: string; - /** Span kind */ - kind?: 'internal' | 'server' | 'client' | 'producer' | 'consumer'; - /** Start time (ISO 8601) */ - startTime: string; - /** End time (ISO 8601) */ - endTime?: string; - /** Duration in milliseconds */ - durationMs?: number; - /** Custom attributes */ - attributes: Record; - /** Status */ - status: 'ok' | 'error' | 'unset'; - /** Error message if status=error */ - statusMessage?: string; - /** Nested events within this span */ - events?: Array<{ - name: string; - timestamp: string; - attributes?: Record; - }>; -} - -/** - * Structured log entry - */ -export interface LogEntry { - /** Log level */ - level: LogLevel; - /** Log message (PII redacted server-side) */ - message: string; - /** Timestamp (ISO 8601) */ - timestamp: string; - /** Module/component name */ - module: string; - /** Source file path */ - file?: string; - /** Line number */ - line?: number; - /** Function name */ - function?: string; - /** Additional context */ - context: Record; - /** Correlation ID for related operations */ - correlationId?: string; -} - -/** - * Breadcrumb for timeline navigation - */ -export interface Breadcrumb { - /** Timestamp */ - timestamp: string; - /** Category (e.g., 'navigation', 'user', 'error') */ - category: string; - /** Message */ - message: string; - /** Associated data */ - data?: Record; -} - -/** - * Network request/response capture - */ -export interface NetworkRequest { - /** Unique request ID */ - id: string; - /** URL */ - url: string; - /** HTTP method */ - method: string; - /** Request headers (sanitized) */ - requestHeaders: Record; - /** Request body (if captured) */ - requestBody?: string; - /** Response status */ - status?: number; - /** Response headers */ - responseHeaders?: Record; - /** Response body (if captured) */ - responseBody?: string; - /** Start timestamp */ - startTime: string; - /** End timestamp */ - endTime?: string; - /** Duration in milliseconds */ - durationMs?: number; - /** Error if request failed */ - error?: string; -} - -/** - * Device state snapshot - */ -export interface DeviceState { - /** Memory usage in MB */ - memoryMB?: number; - /** Battery level (0-1) */ - batteryLevel?: number; - /** Is battery charging */ - isCharging?: boolean; - /** Available storage in MB */ - storageMB?: number; - /** Network type (wifi, cellular, offline) */ - networkType?: string; - /** Is device online */ - isOnline: boolean; - /** Thermal state (nominal, fair, serious, critical) */ - thermalState?: 'nominal' | 'fair' | 'serious' | 'critical'; -} - -/** - * Client configuration options - */ -export interface DiagnosticsConfig { - /** Product ID */ - productId: string; - /** User ID (if authenticated) */ - userId?: string; - /** Anonymous install ID */ - anonymousInstallId: string; - /** Platform name */ - platform: string; - /** Platform channel */ - channel: string; - /** OS family */ - osFamily: string; - /** App version */ - appVersion: string; - /** Build number */ - buildNumber: string; - /** Release channel */ - releaseChannel: string; - /** Server base URL */ - serverUrl: string; - /** Auth token provider */ - getAuthToken?: () => string | Promise; - /** Polling interval in ms (default: 5000) */ - pollIntervalMs?: number; - /** Max breadcrumbs to keep (default: 100) */ - maxBreadcrumbs?: number; - /** Auto-capture console logs */ - captureConsole?: boolean; - /** Auto-capture uncaught errors */ - captureErrors?: boolean; - /** Auto-capture network requests */ - captureNetwork?: boolean; - /** URL patterns to exclude from network capture */ - networkExcludePatterns?: RegExp[]; -} - -/** - * Client state - */ -export type ClientState = - | { type: 'idle' } - | { type: 'polling'; session: DiagnosticsSession | null } - | { type: 'active'; session: DiagnosticsSession } - | { type: 'error'; error: Error }; - -/** - * Ingest batch for sending to server - */ -export interface IngestBatch { - /** Session ID */ - sessionId: string; - /** Traces to ingest */ - traces?: TraceSpan[]; - /** Logs to ingest */ - logs?: LogEntry[]; - /** Breadcrumbs to ingest */ - breadcrumbs?: Breadcrumb[]; - /** Network requests to ingest */ - network?: NetworkRequest[]; -} diff --git a/vendor/bytelyst/diagnostics-client/src/web.ts b/vendor/bytelyst/diagnostics-client/src/web.ts deleted file mode 100644 index 25aac86..0000000 --- a/vendor/bytelyst/diagnostics-client/src/web.ts +++ /dev/null @@ -1,111 +0,0 @@ -/** - * Convenience factory for web dashboard diagnostics. - * - * Eliminates ~40 lines of boilerplate per web app by wrapping - * DiagnosticsClient.getInstance() with sensible web defaults. - * - * @example - * ```ts - * import { createWebDiagnostics } from '@bytelyst/diagnostics-client'; - * - * const { init, stop } = createWebDiagnostics({ - * productId: 'nomgap', - * channel: 'nomgap_web', - * serverUrl: 'http://localhost:4003', - * getAuthToken: () => localStorage.getItem('nomgap_access_token') ?? '', - * }); - * export { init as initDiagnostics, stop as stopDiagnostics }; - * ``` - */ - -import { DiagnosticsClient } from './client.js'; - -export interface WebDiagnosticsConfig { - /** Product identifier (e.g. 'nomgap', 'chronomind'). */ - productId: string; - /** Channel identifier (e.g. 'nomgap_web', 'pwa'). */ - channel: string; - /** Platform-service origin URL (no trailing /api). */ - serverUrl: string; - /** Function that returns the current auth token. */ - getAuthToken: () => string; - /** App version string. Default: '0.1.0'. */ - appVersion?: string; - /** Build number. Default: '1'. */ - buildNumber?: string; - /** Release channel. Default: 'dev'. */ - releaseChannel?: string; - /** OS family. Default: 'unknown'. */ - osFamily?: string; - /** Poll interval in ms. Default: 30000. */ - pollIntervalMs?: number; - /** Capture console logs. Default: false. */ - captureConsole?: boolean; - /** Capture JS errors. Default: true. */ - captureErrors?: boolean; - /** Capture network requests. Default: false. */ - captureNetwork?: boolean; -} - -export interface WebDiagnostics { - /** Initialize diagnostics. Safe to call on server (no-ops). Idempotent. */ - init(): void; - /** Stop diagnostics polling. */ - stop(): void; -} - -function getOrCreateInstallId(productId: string): string { - const key = `${productId}_diag_install_id`; - let id = localStorage.getItem(key); - if (!id) { - id = - typeof crypto?.randomUUID === 'function' - ? crypto.randomUUID() - : Math.random().toString(36).slice(2) + Date.now().toString(36); - localStorage.setItem(key, id); - } - return id; -} - -export function createWebDiagnostics(config: WebDiagnosticsConfig): WebDiagnostics { - let started = false; - - function init(): void { - if (typeof window === 'undefined') return; - if (started) return; - - DiagnosticsClient.getInstance({ - productId: config.productId, - anonymousInstallId: getOrCreateInstallId(config.productId), - platform: 'web', - channel: config.channel, - osFamily: config.osFamily ?? 'unknown', - appVersion: config.appVersion ?? '0.1.0', - buildNumber: config.buildNumber ?? '1', - releaseChannel: config.releaseChannel ?? 'dev', - serverUrl: config.serverUrl, - getAuthToken: config.getAuthToken, - pollIntervalMs: config.pollIntervalMs ?? 30_000, - captureConsole: config.captureConsole ?? false, - captureErrors: config.captureErrors ?? true, - captureNetwork: config.captureNetwork ?? false, - }); - - DiagnosticsClient.getInstance() - .start() - .catch(() => { - // Diagnostics is best-effort - }); - started = true; - } - - function stop(): void { - try { - DiagnosticsClient.getInstance().stop(); - } catch { - // not initialized - } - } - - return { init, stop }; -} diff --git a/vendor/bytelyst/diagnostics-client/tsconfig.json b/vendor/bytelyst/diagnostics-client/tsconfig.json deleted file mode 100644 index c63c563..0000000 --- a/vendor/bytelyst/diagnostics-client/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "./dist", - "rootDir": "./src", - "lib": ["ES2022", "DOM", "DOM.Iterable"] - }, - "include": ["src/**/*"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/vendor/bytelyst/errors/package.json b/vendor/bytelyst/errors/package.json deleted file mode 100644 index 1782252..0000000 --- a/vendor/bytelyst/errors/package.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "name": "@bytelyst/errors", - "version": "0.1.6", - "type": "module", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/errors/src/__tests__/errors.test.ts b/vendor/bytelyst/errors/src/__tests__/errors.test.ts deleted file mode 100644 index b9aa6a0..0000000 --- a/vendor/bytelyst/errors/src/__tests__/errors.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - BadRequestError, - ConflictError, - ForbiddenError, - NotFoundError, - ServiceError, - TooManyRequestsError, - UnauthorizedError, -} from '../index.js'; - -describe('ServiceError', () => { - it('sets statusCode and message', () => { - const err = new ServiceError(500, 'boom'); - expect(err.statusCode).toBe(500); - expect(err.message).toBe('boom'); - expect(err).toBeInstanceOf(Error); - expect(err).toBeInstanceOf(ServiceError); - }); - - it('supports optional details', () => { - const err = new ServiceError(500, 'boom', { field: 'name' }); - expect(err.details).toEqual({ field: 'name' }); - }); -}); - -describe('HTTP error classes', () => { - const cases: [string, new () => ServiceError, number, string][] = [ - ['BadRequestError', BadRequestError, 400, 'Bad request'], - ['UnauthorizedError', UnauthorizedError, 401, 'Unauthorized'], - ['ForbiddenError', ForbiddenError, 403, 'Forbidden'], - ['NotFoundError', NotFoundError, 404, 'Not found'], - ['ConflictError', ConflictError, 409, 'Conflict'], - ['TooManyRequestsError', TooManyRequestsError, 429, 'Too many requests'], - ]; - - for (const [name, Ctor, expectedStatus, expectedMessage] of cases) { - it(`${name} has status ${expectedStatus}`, () => { - const err = new Ctor(); - expect(err.statusCode).toBe(expectedStatus); - expect(err.message).toBe(expectedMessage); - expect(err).toBeInstanceOf(ServiceError); - }); - } - - it('accepts custom message', () => { - const err = new NotFoundError('User not found'); - expect(err.message).toBe('User not found'); - expect(err.statusCode).toBe(404); - }); - - it('accepts details', () => { - const err = new TooManyRequestsError('Rate limited', { retryAfter: 60 }); - expect(err.details).toEqual({ retryAfter: 60 }); - }); -}); diff --git a/vendor/bytelyst/errors/src/http-errors.ts b/vendor/bytelyst/errors/src/http-errors.ts deleted file mode 100644 index 3638046..0000000 --- a/vendor/bytelyst/errors/src/http-errors.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { ServiceError } from './service-error.js'; - -export class BadRequestError extends ServiceError { - constructor(message = 'Bad request', details?: Record) { - super(400, message, details); - } -} - -export class UnauthorizedError extends ServiceError { - constructor(message = 'Unauthorized', details?: Record) { - super(401, message, details); - } -} - -export class ForbiddenError extends ServiceError { - constructor(message = 'Forbidden', details?: Record) { - super(403, message, details); - } -} - -export class NotFoundError extends ServiceError { - constructor(message = 'Not found', details?: Record) { - super(404, message, details); - } -} - -export class ConflictError extends ServiceError { - constructor(message = 'Conflict', details?: Record) { - super(409, message, details); - } -} - -export class TooManyRequestsError extends ServiceError { - constructor(message = 'Too many requests', details?: Record) { - super(429, message, details); - } -} diff --git a/vendor/bytelyst/errors/src/index.ts b/vendor/bytelyst/errors/src/index.ts deleted file mode 100644 index 2464537..0000000 --- a/vendor/bytelyst/errors/src/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -export { ServiceError } from './service-error.js'; -export { - BadRequestError, - UnauthorizedError, - ForbiddenError, - NotFoundError, - ConflictError, - TooManyRequestsError, -} from './http-errors.js'; diff --git a/vendor/bytelyst/errors/src/service-error.ts b/vendor/bytelyst/errors/src/service-error.ts deleted file mode 100644 index 1bc957f..0000000 --- a/vendor/bytelyst/errors/src/service-error.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Base error class for typed HTTP service errors. - * All specific error types extend this class. - */ -export class ServiceError extends Error { - constructor( - public statusCode: number, - message: string, - public details?: Record - ) { - super(message); - this.name = 'ServiceError'; - } -} diff --git a/vendor/bytelyst/errors/tsconfig.json b/vendor/bytelyst/errors/tsconfig.json deleted file mode 100644 index 5edad81..0000000 --- a/vendor/bytelyst/errors/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/vendor/bytelyst/event-store/package.json b/vendor/bytelyst/event-store/package.json deleted file mode 100644 index 5f94b35..0000000 --- a/vendor/bytelyst/event-store/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "@bytelyst/event-store", - "version": "0.1.5", - "description": "Persistent event store with pluggable backends (in-memory, file, Cosmos) for ByteLyst product backends", - "type": "module", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/event-store/src/file-store.test.ts b/vendor/bytelyst/event-store/src/file-store.test.ts deleted file mode 100644 index 1ee1810..0000000 --- a/vendor/bytelyst/event-store/src/file-store.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; -import { rm } from 'node:fs/promises'; -import { FileEventStore } from './file-store.js'; -import type { StoredEvent } from './types.js'; - -function makeEvent(overrides?: Partial): StoredEvent { - return { - id: crypto.randomUUID(), - type: 'test.event', - userId: 'u1', - productId: 'testprod', - timestamp: new Date().toISOString(), - payload: {}, - ...overrides, - }; -} - -describe('FileEventStore', () => { - let store: FileEventStore; - let filePath: string; - - beforeEach(() => { - filePath = join( - tmpdir(), - `event-store-test-${Date.now()}-${Math.random().toString(36).slice(2)}.jsonl` - ); - store = new FileEventStore({ filePath }); - }); - - afterEach(async () => { - try { - await rm(filePath); - } catch { - /* may not exist */ - } - }); - - it('appends and retrieves events', async () => { - await store.append(makeEvent()); - expect(await store.count()).toBe(1); - const recent = await store.recent(); - expect(recent).toHaveLength(1); - }); - - it('persists multiple events across reads', async () => { - await store.append(makeEvent({ id: 'e1' })); - await store.append(makeEvent({ id: 'e2' })); - await store.append(makeEvent({ id: 'e3' })); - expect(await store.count()).toBe(3); - const recent = await store.recent(2); - expect(recent).toHaveLength(2); - expect(recent[0].id).toBe('e2'); - }); - - it('queries by userId', async () => { - await store.append(makeEvent({ userId: 'u1' })); - await store.append(makeEvent({ userId: 'u2' })); - await store.append(makeEvent({ userId: 'u1' })); - const results = await store.query({ userId: 'u1' }); - expect(results).toHaveLength(2); - }); - - it('queries by type', async () => { - await store.append(makeEvent({ type: 'task.created' })); - await store.append(makeEvent({ type: 'schedule.generated' })); - const results = await store.query({ type: 'task.created' }); - expect(results).toHaveLength(1); - }); - - it('queries with time range', async () => { - await store.append(makeEvent({ timestamp: '2026-01-01T00:00:00Z' })); - await store.append(makeEvent({ timestamp: '2026-03-01T00:00:00Z' })); - await store.append(makeEvent({ timestamp: '2026-06-01T00:00:00Z' })); - const results = await store.query({ - after: '2026-02-01T00:00:00Z', - before: '2026-04-01T00:00:00Z', - }); - expect(results).toHaveLength(1); - }); - - it('queries with limit', async () => { - for (let i = 0; i < 10; i++) { - await store.append(makeEvent()); - } - const results = await store.query({ limit: 3 }); - expect(results).toHaveLength(3); - }); - - it('clears all events', async () => { - await store.append(makeEvent()); - await store.append(makeEvent()); - await store.clear(); - expect(await store.count()).toBe(0); - }); - - it('returns empty for non-existent file', async () => { - const fresh = new FileEventStore({ - filePath: join(tmpdir(), 'nonexistent-' + Date.now() + '.jsonl'), - }); - expect(await fresh.count()).toBe(0); - expect(await fresh.recent()).toEqual([]); - }); - - it('creates parent directory if needed', async () => { - const nested = join(tmpdir(), `nested-${Date.now()}`, 'sub', 'events.jsonl'); - const nestedStore = new FileEventStore({ filePath: nested }); - await nestedStore.append(makeEvent()); - expect(await nestedStore.count()).toBe(1); - await rm(join(tmpdir(), `nested-${Date.now()}`), { recursive: true }).catch(() => {}); - }); -}); diff --git a/vendor/bytelyst/event-store/src/file-store.ts b/vendor/bytelyst/event-store/src/file-store.ts deleted file mode 100644 index 88b8df1..0000000 --- a/vendor/bytelyst/event-store/src/file-store.ts +++ /dev/null @@ -1,68 +0,0 @@ -/** - * File-based event store implementation. - * Appends events as JSON lines to a file on disk. - * Suitable for single-instance dev/staging deployments. - */ - -import { readFile, appendFile, writeFile, mkdir } from 'node:fs/promises'; -import { dirname } from 'node:path'; -import type { EventStore, StoredEvent, EventStoreQuery } from './types.js'; - -export interface FileStoreOptions { - filePath: string; -} - -export class FileEventStore implements EventStore { - private readonly filePath: string; - - constructor(options: FileStoreOptions) { - this.filePath = options.filePath; - } - - async append(event: StoredEvent): Promise { - await mkdir(dirname(this.filePath), { recursive: true }); - await appendFile(this.filePath, JSON.stringify(event) + '\n', 'utf-8'); - } - - async query(q: EventStoreQuery): Promise { - const all = await this.readAll(); - let results = all; - - if (q.userId) results = results.filter(e => e.userId === q.userId); - if (q.type) results = results.filter(e => e.type === q.type); - if (q.after) results = results.filter(e => e.timestamp > q.after!); - if (q.before) results = results.filter(e => e.timestamp < q.before!); - - if (q.limit && q.limit > 0) { - results = results.slice(-q.limit); - } - - return results; - } - - async recent(limit = 50): Promise { - const all = await this.readAll(); - return all.slice(-limit); - } - - async count(): Promise { - const all = await this.readAll(); - return all.length; - } - - async clear(): Promise { - await writeFile(this.filePath, '', 'utf-8'); - } - - private async readAll(): Promise { - try { - const content = await readFile(this.filePath, 'utf-8'); - return content - .split('\n') - .filter(line => line.trim()) - .map(line => JSON.parse(line) as StoredEvent); - } catch { - return []; - } - } -} diff --git a/vendor/bytelyst/event-store/src/index.ts b/vendor/bytelyst/event-store/src/index.ts deleted file mode 100644 index 594f451..0000000 --- a/vendor/bytelyst/event-store/src/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export type { EventStore, StoredEvent, EventStoreQuery } from './types.js'; -export { MemoryEventStore } from './memory-store.js'; -export type { MemoryStoreOptions } from './memory-store.js'; -export { FileEventStore } from './file-store.js'; -export type { FileStoreOptions } from './file-store.js'; diff --git a/vendor/bytelyst/event-store/src/memory-store.test.ts b/vendor/bytelyst/event-store/src/memory-store.test.ts deleted file mode 100644 index 953abfc..0000000 --- a/vendor/bytelyst/event-store/src/memory-store.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { MemoryEventStore } from './memory-store.js'; -import type { StoredEvent } from './types.js'; - -function makeEvent(overrides?: Partial): StoredEvent { - return { - id: crypto.randomUUID(), - type: 'test.event', - userId: 'u1', - productId: 'testprod', - timestamp: new Date().toISOString(), - payload: {}, - ...overrides, - }; -} - -describe('MemoryEventStore', () => { - let store: MemoryEventStore; - - beforeEach(() => { - store = new MemoryEventStore({ maxEvents: 100 }); - }); - - it('appends and retrieves events', async () => { - await store.append(makeEvent()); - expect(await store.count()).toBe(1); - const recent = await store.recent(); - expect(recent).toHaveLength(1); - }); - - it('caps at maxEvents', async () => { - for (let i = 0; i < 120; i++) { - await store.append(makeEvent({ id: `e${i}` })); - } - expect(await store.count()).toBe(100); - }); - - it('queries by userId', async () => { - await store.append(makeEvent({ userId: 'u1' })); - await store.append(makeEvent({ userId: 'u2' })); - await store.append(makeEvent({ userId: 'u1' })); - const results = await store.query({ userId: 'u1' }); - expect(results).toHaveLength(2); - }); - - it('queries by type', async () => { - await store.append(makeEvent({ type: 'task.created' })); - await store.append(makeEvent({ type: 'schedule.generated' })); - await store.append(makeEvent({ type: 'task.created' })); - const results = await store.query({ type: 'task.created' }); - expect(results).toHaveLength(2); - }); - - it('queries with time range', async () => { - await store.append(makeEvent({ timestamp: '2026-01-01T00:00:00Z' })); - await store.append(makeEvent({ timestamp: '2026-03-01T00:00:00Z' })); - await store.append(makeEvent({ timestamp: '2026-06-01T00:00:00Z' })); - const results = await store.query({ - after: '2026-02-01T00:00:00Z', - before: '2026-04-01T00:00:00Z', - }); - expect(results).toHaveLength(1); - }); - - it('queries with limit', async () => { - for (let i = 0; i < 10; i++) { - await store.append(makeEvent()); - } - const results = await store.query({ limit: 3 }); - expect(results).toHaveLength(3); - }); - - it('clears all events', async () => { - await store.append(makeEvent()); - await store.append(makeEvent()); - await store.clear(); - expect(await store.count()).toBe(0); - }); - - it('recent returns last N events', async () => { - for (let i = 0; i < 10; i++) { - await store.append(makeEvent({ id: `e${i}` })); - } - const recent = await store.recent(3); - expect(recent).toHaveLength(3); - expect(recent[0].id).toBe('e7'); - }); -}); diff --git a/vendor/bytelyst/event-store/src/memory-store.ts b/vendor/bytelyst/event-store/src/memory-store.ts deleted file mode 100644 index 69ab390..0000000 --- a/vendor/bytelyst/event-store/src/memory-store.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * In-memory event store implementation. - * Useful for development, testing, and as a fallback when no persistent backend is configured. - * Caps at maxEvents to prevent unbounded memory growth. - */ - -import type { EventStore, StoredEvent, EventStoreQuery } from './types.js'; - -export interface MemoryStoreOptions { - maxEvents?: number; -} - -export class MemoryEventStore implements EventStore { - private events: StoredEvent[] = []; - private readonly maxEvents: number; - - constructor(options?: MemoryStoreOptions) { - this.maxEvents = options?.maxEvents ?? 10_000; - } - - async append(event: StoredEvent): Promise { - this.events.push(event); - if (this.events.length > this.maxEvents) { - this.events = this.events.slice(-this.maxEvents); - } - } - - async query(q: EventStoreQuery): Promise { - let results = this.events; - - if (q.userId) results = results.filter(e => e.userId === q.userId); - if (q.type) results = results.filter(e => e.type === q.type); - if (q.after) results = results.filter(e => e.timestamp > q.after!); - if (q.before) results = results.filter(e => e.timestamp < q.before!); - - if (q.limit && q.limit > 0) { - results = results.slice(-q.limit); - } - - return results; - } - - async recent(limit = 50): Promise { - return this.events.slice(-limit); - } - - async count(): Promise { - return this.events.length; - } - - async clear(): Promise { - this.events = []; - } -} diff --git a/vendor/bytelyst/event-store/src/types.ts b/vendor/bytelyst/event-store/src/types.ts deleted file mode 100644 index 5b11418..0000000 --- a/vendor/bytelyst/event-store/src/types.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Pluggable event store interface for ByteLyst product backends. - * Products define their own event shapes; the store handles persistence. - */ - -export interface StoredEvent { - id: string; - type: string; - userId: string; - productId: string; - timestamp: string; - payload: Record; -} - -export interface EventStoreQuery { - userId?: string; - type?: string; - after?: string; - before?: string; - limit?: number; -} - -export interface EventStore { - append(event: StoredEvent): Promise; - query(q: EventStoreQuery): Promise; - recent(limit?: number): Promise; - count(): Promise; - clear(): Promise; -} diff --git a/vendor/bytelyst/event-store/tsconfig.json b/vendor/bytelyst/event-store/tsconfig.json deleted file mode 100644 index 5edad81..0000000 --- a/vendor/bytelyst/event-store/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/vendor/bytelyst/events/fixtures/ecosystem/phase1/artifact-created.event.json b/vendor/bytelyst/events/fixtures/ecosystem/phase1/artifact-created.event.json deleted file mode 100644 index 97a233a..0000000 --- a/vendor/bytelyst/events/fixtures/ecosystem/phase1/artifact-created.event.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "eventId": "evt_phase1_002", - "eventName": "artifact.created", - "eventVersion": 1, - "occurredAt": "2026-04-03T18:17:01.000Z", - "productId": "notelett", - "sourceSurface": "web", - "userId": "user_saravana", - "orgId": null, - "sessionId": "sess_lysnr_001", - "runId": "run_notes_001", - "artifactId": "art_note_001", - "actor": { - "actorType": "agent", - "actorId": "notes_ingest_agent" - }, - "trace": { - "correlationId": "corr_phase1_001", - "causationId": "evt_phase1_001", - "parentEventId": "evt_phase1_001" - }, - "payload": { - "artifactType": "note", - "title": "Standup follow-up", - "status": "draft" - } -} diff --git a/vendor/bytelyst/events/fixtures/ecosystem/phase1/artifact-linked.event.json b/vendor/bytelyst/events/fixtures/ecosystem/phase1/artifact-linked.event.json deleted file mode 100644 index 14afb36..0000000 --- a/vendor/bytelyst/events/fixtures/ecosystem/phase1/artifact-linked.event.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "eventId": "evt_phase1_003", - "eventName": "artifact.linked", - "eventVersion": 1, - "occurredAt": "2026-04-03T18:17:02.000Z", - "productId": "notelett", - "sourceSurface": "web", - "userId": "user_saravana", - "orgId": null, - "sessionId": "sess_lysnr_001", - "runId": "run_notes_001", - "artifactId": "art_note_001", - "actor": { - "actorType": "agent", - "actorId": "notes_ingest_agent" - }, - "trace": { - "correlationId": "corr_phase1_001", - "causationId": "evt_phase1_002", - "parentEventId": "evt_phase1_002" - }, - "payload": { - "sourceArtifactId": "art_note_001", - "targetArtifactId": "art_transcript_001", - "relation": "summarizes" - } -} diff --git a/vendor/bytelyst/events/fixtures/ecosystem/phase1/capture-transcript-created.event.json b/vendor/bytelyst/events/fixtures/ecosystem/phase1/capture-transcript-created.event.json deleted file mode 100644 index a1bd3f2..0000000 --- a/vendor/bytelyst/events/fixtures/ecosystem/phase1/capture-transcript-created.event.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "eventId": "evt_phase1_001", - "eventName": "capture.transcript.created", - "eventVersion": 1, - "occurredAt": "2026-04-03T18:15:01.000Z", - "productId": "lysnrai", - "sourceSurface": "mobile", - "userId": "user_saravana", - "orgId": null, - "sessionId": "sess_lysnr_001", - "runId": null, - "artifactId": "art_transcript_001", - "actor": { - "actorType": "user", - "actorId": "user_saravana" - }, - "trace": { - "correlationId": "corr_phase1_001", - "causationId": null, - "parentEventId": null - }, - "payload": { - "artifactId": "art_transcript_001", - "durationMs": 42150, - "language": "en", - "transcriptSource": "microphone" - } -} diff --git a/vendor/bytelyst/events/fixtures/ecosystem/phase1/memory-artifact.json b/vendor/bytelyst/events/fixtures/ecosystem/phase1/memory-artifact.json deleted file mode 100644 index 44d219d..0000000 --- a/vendor/bytelyst/events/fixtures/ecosystem/phase1/memory-artifact.json +++ /dev/null @@ -1,69 +0,0 @@ -{ - "id": "art_memory_001", - "artifactType": "memory", - "schemaVersion": 1, - "productId": "mindlyst", - "sourceSurface": "service", - "title": "Saravana prefers deployment checklist reminders after standup", - "summary": "Memory candidate inferred from the standup transcript and note.", - "createdAt": "2026-04-03T18:20:00.000Z", - "updatedAt": "2026-04-03T18:20:00.000Z", - "createdBy": { - "actorType": "agent", - "actorId": "memory_ingest_agent" - }, - "ownership": { - "userId": "user_saravana", - "orgId": null - }, - "visibility": { - "scope": "private" - }, - "status": "proposed", - "tags": ["memory", "workflow"], - "links": [ - { - "relation": "derived-from", - "targetArtifactId": "art_transcript_001" - }, - { - "relation": "generated-memory", - "targetArtifactId": "art_note_001" - } - ], - "provenance": { - "originProductId": "lysnrai", - "originActionId": "capture_001", - "sessionId": "sess_lysnr_001", - "runId": "run_memory_001", - "approvalId": null, - "correlationId": "corr_phase1_001", - "lineage": [ - { - "stepType": "captured", - "productId": "lysnrai", - "actorType": "user", - "timestamp": "2026-04-03T18:15:00.000Z" - }, - { - "stepType": "note-created", - "productId": "notelett", - "actorType": "agent", - "timestamp": "2026-04-03T18:17:00.000Z" - }, - { - "stepType": "memory-proposed", - "productId": "mindlyst", - "actorType": "agent", - "timestamp": "2026-04-03T18:20:00.000Z" - } - ] - }, - "payload": { - "memoryKind": "preference", - "text": "Saravana benefits from deployment checklist reminders immediately after standup capture.", - "confidence": 0.82, - "sourceArtifactIds": ["art_transcript_001", "art_note_001"], - "reviewState": "proposed" - } -} diff --git a/vendor/bytelyst/events/fixtures/ecosystem/phase1/memory-entry-created.event.json b/vendor/bytelyst/events/fixtures/ecosystem/phase1/memory-entry-created.event.json deleted file mode 100644 index 81bfd92..0000000 --- a/vendor/bytelyst/events/fixtures/ecosystem/phase1/memory-entry-created.event.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "eventId": "evt_phase1_004", - "eventName": "memory.entry.created", - "eventVersion": 1, - "occurredAt": "2026-04-03T18:20:01.000Z", - "productId": "mindlyst", - "sourceSurface": "service", - "userId": "user_saravana", - "orgId": null, - "sessionId": "sess_lysnr_001", - "runId": "run_memory_001", - "artifactId": "art_memory_001", - "actor": { - "actorType": "agent", - "actorId": "memory_ingest_agent" - }, - "trace": { - "correlationId": "corr_phase1_001", - "causationId": "evt_phase1_003", - "parentEventId": "evt_phase1_003" - }, - "payload": { - "artifactId": "art_memory_001", - "memoryKind": "preference", - "reviewState": "proposed", - "confidence": 0.82, - "sourceArtifactIds": ["art_transcript_001", "art_note_001"] - } -} diff --git a/vendor/bytelyst/events/fixtures/ecosystem/phase1/note-artifact.json b/vendor/bytelyst/events/fixtures/ecosystem/phase1/note-artifact.json deleted file mode 100644 index c785876..0000000 --- a/vendor/bytelyst/events/fixtures/ecosystem/phase1/note-artifact.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "id": "art_note_001", - "artifactType": "note", - "schemaVersion": 1, - "productId": "notelett", - "sourceSurface": "web", - "title": "Standup follow-up", - "summary": "Structured note created from a LysnrAI transcript.", - "createdAt": "2026-04-03T18:17:00.000Z", - "updatedAt": "2026-04-03T18:17:00.000Z", - "createdBy": { - "actorType": "agent", - "actorId": "notes_ingest_agent" - }, - "ownership": { - "userId": "user_saravana", - "orgId": null - }, - "visibility": { - "scope": "private" - }, - "status": "draft", - "tags": ["derived", "standup"], - "links": [ - { - "relation": "summarizes", - "targetArtifactId": "art_transcript_001" - } - ], - "provenance": { - "originProductId": "lysnrai", - "originActionId": "capture_001", - "sessionId": "sess_lysnr_001", - "runId": "run_notes_001", - "approvalId": null, - "correlationId": "corr_phase1_001", - "lineage": [ - { - "stepType": "captured", - "productId": "lysnrai", - "actorType": "user", - "timestamp": "2026-04-03T18:15:00.000Z" - }, - { - "stepType": "note-created", - "productId": "notelett", - "actorType": "agent", - "timestamp": "2026-04-03T18:17:00.000Z" - } - ] - }, - "payload": { - "noteFormat": "markdown", - "body": "# Standup follow-up\n\n- Billing sync finished\n- Clean up follow-up notes\n- Review deployment checklist", - "excerpt": "Billing sync finished; follow-up notes and deployment checklist remain." - } -} diff --git a/vendor/bytelyst/events/fixtures/ecosystem/phase1/transcript-artifact.json b/vendor/bytelyst/events/fixtures/ecosystem/phase1/transcript-artifact.json deleted file mode 100644 index f696a05..0000000 --- a/vendor/bytelyst/events/fixtures/ecosystem/phase1/transcript-artifact.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "id": "art_transcript_001", - "artifactType": "transcript", - "schemaVersion": 1, - "productId": "lysnrai", - "sourceSurface": "mobile", - "title": "Daily standup voice capture", - "summary": "Transcript captured from Saravana's daily standup reflection.", - "createdAt": "2026-04-03T18:15:00.000Z", - "updatedAt": "2026-04-03T18:15:00.000Z", - "createdBy": { - "actorType": "user", - "actorId": "user_saravana" - }, - "ownership": { - "userId": "user_saravana", - "orgId": null - }, - "visibility": { - "scope": "private", - "allowedProducts": ["learning_ai_notes", "learning_multimodal_memory_agents"] - }, - "status": "completed", - "tags": ["voice", "standup"], - "links": [], - "provenance": { - "originProductId": "lysnrai", - "originActionId": "capture_001", - "sessionId": "sess_lysnr_001", - "runId": null, - "approvalId": null, - "correlationId": "corr_phase1_001", - "lineage": [ - { - "stepType": "captured", - "productId": "lysnrai", - "actorType": "user", - "timestamp": "2026-04-03T18:15:00.000Z" - } - ] - }, - "payload": { - "transcriptText": "Today I finished the billing sync, I need to clean up follow-up notes, and I should remember to review the deployment checklist.", - "transcriptSource": "microphone", - "language": "en", - "durationMs": 42150, - "segments": [ - { - "speaker": null, - "startedAtMs": 0, - "endedAtMs": 42150, - "text": "Today I finished the billing sync, I need to clean up follow-up notes, and I should remember to review the deployment checklist." - } - ] - } -} diff --git a/vendor/bytelyst/events/package.json b/vendor/bytelyst/events/package.json deleted file mode 100644 index 444153f..0000000 --- a/vendor/bytelyst/events/package.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "name": "@bytelyst/events", - "version": "0.1.5", - "type": "module", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks" - }, - "dependencies": { - "@bytelyst/queue": "workspace:*" - }, - "devDependencies": { - "@types/node": "^22.12.0", - "vitest": "^3.0.5" - }, - "peerDependencies": { - "zod": "^3.0.0" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/events/src/agent-runtime.test.ts b/vendor/bytelyst/events/src/agent-runtime.test.ts deleted file mode 100644 index 8ae570d..0000000 --- a/vendor/bytelyst/events/src/agent-runtime.test.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - AgentActionLogSchema, - AgentCheckpointSchema, - AgentApprovalCheckpointSchema, - AgentDispatchRequestSchema, - AgentRunSchema, - AgentSessionSchema, - AgentTaskSchema, - AgentTodoSchema, -} from './agent-runtime.js'; - -describe('agent runtime contract baseline', () => { - it('validates a dispatched Cowork session with task and approval checkpoint', () => { - const session = AgentSessionSchema.parse({ - sessionId: 'sess_cowork_1', - productId: 'cowork', - userId: 'saravana', - status: 'waiting-approval', - startedAt: '2026-04-03T16:00:00.000Z', - updatedAt: '2026-04-03T16:05:00.000Z', - resumable: true, - currentTaskId: 'task_cowork_1', - memoryRefs: ['mem_proj_1'], - artifactRefs: ['art_note_1'], - approvalRefs: ['approval_1'], - dispatchContext: { - originSurface: 'browser', - originProductId: 'notelett', - dispatchMode: 'remote', - initiatedAt: '2026-04-03T15:59:00.000Z', - }, - }); - - const task = AgentTaskSchema.parse({ - taskId: 'task_cowork_1', - sessionId: session.sessionId, - title: 'Investigate imported roadmap risks', - intent: 'Audit the imported note and produce findings', - status: 'blocked', - priority: 'high', - createdAt: '2026-04-03T16:00:00.000Z', - updatedAt: '2026-04-03T16:05:00.000Z', - }); - - const approval = AgentApprovalCheckpointSchema.parse({ - approvalId: 'approval_1', - sessionId: session.sessionId, - runId: 'run_cowork_1', - actionLabel: 'Delete temporary export', - riskLevel: 'high', - status: 'requested', - requestedAt: '2026-04-03T16:05:00.000Z', - resolvedAt: null, - resolverSurface: null, - }); - - expect(session.dispatchContext?.originSurface).toBe('browser'); - expect(task.status).toBe('blocked'); - expect(approval.status).toBe('requested'); - }); - - it('validates a scheduled FlowMonk run with todos and action logs', () => { - const dispatch = AgentDispatchRequestSchema.parse({ - dispatchId: 'dispatch_flow_1', - targetProductId: 'flowmonk', - targetExecutor: 'flowmonk', - userId: 'saravana', - title: 'Execute weekly planning refresh', - intent: 'Rebuild plan, routines, and habits for next week', - artifactRefs: ['art_plan_template_1'], - memoryRefs: ['mem_goal_1'], - dispatchContext: { - originSurface: 'web', - originProductId: 'flowmonk', - dispatchMode: 'scheduled', - initiatedAt: '2026-04-03T17:00:00.000Z', - }, - }); - - const run = AgentRunSchema.parse({ - runId: 'run_flow_1', - sessionId: 'sess_flow_1', - productId: 'flowmonk', - status: 'waiting-approval', - startedAt: '2026-04-03T17:01:00.000Z', - completedAt: null, - checkpointArtifactId: 'art_plan_1', - correlationId: 'corr_phase2', - }); - - const todo = AgentTodoSchema.parse({ - todoId: 'todo_flow_1', - sessionId: 'sess_flow_1', - text: 'Generate routines from approved plan', - status: 'in-progress', - createdAt: '2026-04-03T17:01:30.000Z', - updatedAt: '2026-04-03T17:02:00.000Z', - }); - - const actionLog = AgentActionLogSchema.parse({ - actionLogId: 'alog_flow_1', - sessionId: 'sess_flow_1', - runId: 'run_flow_1', - eventName: 'agent.run.started', - occurredAt: '2026-04-03T17:01:00.000Z', - actorType: 'agent', - correlationId: 'corr_phase2', - payload: { - dispatchId: dispatch.dispatchId, - title: dispatch.title, - }, - }); - - expect(dispatch.dispatchContext.dispatchMode).toBe('scheduled'); - expect(run.status).toBe('waiting-approval'); - expect(run.checkpointArtifactId).toBe('art_plan_1'); - expect(todo.status).toBe('in-progress'); - expect(actionLog.eventName).toBe('agent.run.started'); - }); - - it('accepts queued runs as a first-class runtime state', () => { - const run = AgentRunSchema.parse({ - runId: 'run_queued_1', - sessionId: 'sess_queued_1', - productId: 'clawcowork', - status: 'queued', - startedAt: '2026-04-04T10:00:00.000Z', - completedAt: null, - checkpointArtifactId: null, - correlationId: 'corr_queued_1', - }); - - expect(run.status).toBe('queued'); - }); - - it('validates checkpoint summaries used for resume review', () => { - const checkpoint = AgentCheckpointSchema.parse({ - checkpointId: 'ckpt_cowork_1', - sessionId: 'sess_cowork_1', - runId: 'run_cowork_1', - productId: 'clawcowork', - userId: 'saravana', - createdAt: '2026-04-04T12:00:00.000Z', - statusAtCapture: 'waiting-approval', - currentTaskId: 'task_cowork_1', - checkpointArtifactId: 'artifact://notelett/note-1', - todoIds: ['todo_cowork_1'], - artifactRefs: ['artifact://notelett/note-1'], - memoryRefs: [], - approvalRefs: ['approval_1'], - dispatchContext: { - originSurface: 'desktop', - originProductId: 'clawcowork', - dispatchMode: 'interactive', - initiatedAt: '2026-04-04T11:58:00.000Z', - }, - resumeToken: 'task_cowork_1', - stateSummary: { - title: 'Investigate imported roadmap risks', - summary: 'Paused for approval after scanning the repository and proposing changes.', - lastActionAt: '2026-04-04T12:00:00.000Z', - }, - }); - - expect(checkpoint.statusAtCapture).toBe('waiting-approval'); - expect(checkpoint.resumeToken).toBe('task_cowork_1'); - expect(checkpoint.checkpointArtifactId).toBe('artifact://notelett/note-1'); - }); -}); diff --git a/vendor/bytelyst/events/src/agent-runtime.ts b/vendor/bytelyst/events/src/agent-runtime.ts deleted file mode 100644 index 5ba0ec9..0000000 --- a/vendor/bytelyst/events/src/agent-runtime.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { z } from 'zod'; - -export const AgentDispatchContextSchema = z.object({ - originSurface: z.enum(['browser', 'mobile', 'desktop', 'web', 'product-api']), - originProductId: z.string().min(1), - dispatchMode: z.enum(['interactive', 'queued', 'scheduled', 'remote']), - initiatedAt: z.string().datetime(), -}); - -export const AgentSessionStatusSchema = z.enum([ - 'active', - 'paused', - 'waiting-approval', - 'completed', - 'failed', - 'cancelled', -]); - -export const AgentSessionSchema = z.object({ - sessionId: z.string().min(1), - productId: z.string().min(1), - userId: z.string().min(1), - status: AgentSessionStatusSchema, - startedAt: z.string().datetime(), - updatedAt: z.string().datetime(), - resumable: z.boolean(), - currentTaskId: z.string().min(1).nullable().optional(), - memoryRefs: z.array(z.string().min(1)), - artifactRefs: z.array(z.string().min(1)), - approvalRefs: z.array(z.string().min(1)), - dispatchContext: AgentDispatchContextSchema.nullable().optional(), -}); - -export const AgentTaskStatusSchema = z.enum([ - 'queued', - 'running', - 'blocked', - 'completed', - 'failed', - 'cancelled', -]); - -export const AgentTaskSchema = z.object({ - taskId: z.string().min(1), - sessionId: z.string().min(1), - title: z.string().min(1), - intent: z.string().min(1), - status: AgentTaskStatusSchema, - priority: z.string().min(1).nullable().optional(), - createdAt: z.string().datetime(), - updatedAt: z.string().datetime(), -}); - -export const AgentTodoStatusSchema = z.enum(['open', 'in-progress', 'done', 'dropped']); - -export const AgentTodoSchema = z.object({ - todoId: z.string().min(1), - sessionId: z.string().min(1), - text: z.string().min(1), - status: AgentTodoStatusSchema, - createdAt: z.string().datetime(), - updatedAt: z.string().datetime(), -}); - -export const AgentCheckpointStatusSchema = z.enum([ - 'queued', - 'running', - 'paused', - 'waiting-approval', - 'completed', - 'failed', - 'cancelled', -]); - -export const AgentCheckpointSchema = z.object({ - checkpointId: z.string().min(1), - sessionId: z.string().min(1), - runId: z.string().min(1).nullable().optional(), - productId: z.string().min(1), - userId: z.string().min(1), - createdAt: z.string().datetime(), - statusAtCapture: AgentCheckpointStatusSchema, - currentTaskId: z.string().min(1).nullable().optional(), - checkpointArtifactId: z.string().min(1).nullable().optional(), - todoIds: z.array(z.string().min(1)), - artifactRefs: z.array(z.string().min(1)), - memoryRefs: z.array(z.string().min(1)), - approvalRefs: z.array(z.string().min(1)), - dispatchContext: AgentDispatchContextSchema.nullable().optional(), - resumeToken: z.string().min(1).nullable().optional(), - stateSummary: z.object({ - title: z.string().min(1), - summary: z.string().min(1), - lastActionAt: z.string().datetime().nullable().optional(), - }), -}); - -export const AgentRunStatusSchema = z.enum([ - 'queued', - 'running', - 'paused', - 'waiting-approval', - 'completed', - 'failed', - 'cancelled', -]); - -export const AgentRunSchema = z.object({ - runId: z.string().min(1), - sessionId: z.string().min(1), - productId: z.string().min(1), - status: AgentRunStatusSchema, - startedAt: z.string().datetime(), - completedAt: z.string().datetime().nullable().optional(), - checkpointArtifactId: z.string().min(1).nullable().optional(), - correlationId: z.string().min(1).nullable().optional(), -}); - -export const AgentApprovalCheckpointSchema = z.object({ - approvalId: z.string().min(1), - sessionId: z.string().min(1), - runId: z.string().min(1), - actionLabel: z.string().min(1), - riskLevel: z.enum(['low', 'medium', 'high', 'critical']), - status: z.enum(['requested', 'approved', 'denied', 'expired']), - requestedAt: z.string().datetime(), - resolvedAt: z.string().datetime().nullable().optional(), - resolverSurface: z.enum(['mobile', 'web', 'desktop']).nullable().optional(), -}); - -export const AgentDispatchRequestSchema = z.object({ - dispatchId: z.string().min(1), - targetProductId: z.string().min(1), - targetExecutor: z.enum(['cowork', 'jarvisjr', 'flowmonk', 'generic-agent']), - userId: z.string().min(1), - title: z.string().min(1), - intent: z.string().min(1), - artifactRefs: z.array(z.string().min(1)).default([]), - memoryRefs: z.array(z.string().min(1)).default([]), - dispatchContext: AgentDispatchContextSchema, -}); - -export const AgentActionLogSchema = z.object({ - actionLogId: z.string().min(1), - sessionId: z.string().min(1), - runId: z.string().min(1), - eventName: z.string().min(1), - occurredAt: z.string().datetime(), - actorType: z.enum(['user', 'agent', 'system', 'device']), - correlationId: z.string().min(1).nullable().optional(), - payload: z.record(z.unknown()), -}); - -export type AgentDispatchContext = z.infer; -export type AgentSession = z.infer; -export type AgentTask = z.infer; -export type AgentTodo = z.infer; -export type AgentCheckpoint = z.infer; -export type AgentRun = z.infer; -export type AgentApprovalCheckpoint = z.infer; -export type AgentDispatchRequest = z.infer; -export type AgentActionLog = z.infer; diff --git a/vendor/bytelyst/events/src/durable.test.ts b/vendor/bytelyst/events/src/durable.test.ts deleted file mode 100644 index b2733a7..0000000 --- a/vendor/bytelyst/events/src/durable.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { mkdtemp, rm } from 'node:fs/promises'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; -import { describe, it, expect, vi } from 'vitest'; -import { FileQueueStore } from '@bytelyst/queue'; -import { MemoryQueueStore } from '@bytelyst/queue'; -import { DurableEventBus } from './durable.js'; - -describe('DurableEventBus', () => { - it('delivers queued events through the worker', async () => { - const store = new MemoryQueueStore(); - const bus = new DurableEventBus({ - store, - autoStart: false, - pollIntervalMs: 10, - }); - - const handler = vi.fn(); - bus.on('user.created', handler); - bus.start(); - - const result = await bus.emit('user.created', { - userId: 'u1', - email: 'test@example.com', - plan: 'free', - productId: 'lysnrai', - }); - - expect(result.handlerCount).toBe(1); - - await waitFor(() => { - expect(handler).toHaveBeenCalledOnce(); - }); - - await bus.stop(); - }); - - it('persists emitted events across bus instances before the worker starts', async () => { - const dir = await mkdtemp(join(tmpdir(), 'events-store-')); - const filePath = join(dir, 'events.json'); - - try { - const first = new DurableEventBus({ - store: new FileQueueStore({ filePath }), - autoStart: false, - pollIntervalMs: 10, - }); - - await first.emit('payment.failed', { - invoiceId: 'inv_1', - userId: 'u1', - amount: 499, - retryCount: 1, - productId: 'lysnrai', - }); - await first.stop(); - - const second = new DurableEventBus({ - store: new FileQueueStore({ filePath }), - autoStart: false, - pollIntervalMs: 10, - }); - - const handler = vi.fn(); - second.on('payment.failed', handler); - second.start(); - - await waitFor(() => { - expect(handler).toHaveBeenCalledOnce(); - }); - - await second.stop(); - } finally { - await rm(dir, { recursive: true, force: true }); - } - }); -}); - -async function waitFor(assertion: () => void, timeoutMs = 1_000): Promise { - const startedAt = Date.now(); - while (Date.now() - startedAt < timeoutMs) { - try { - assertion(); - return; - } catch { - await new Promise(resolve => setTimeout(resolve, 20)); - } - } - - assertion(); -} diff --git a/vendor/bytelyst/events/src/durable.ts b/vendor/bytelyst/events/src/durable.ts deleted file mode 100644 index abb57e9..0000000 --- a/vendor/bytelyst/events/src/durable.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { QueueWorker, type QueueJob, type QueueStore } from '@bytelyst/queue'; -import type { EmitResult } from './memory.js'; -import type { - EventHandler, - EventSubscription, - PlatformEvent, - PlatformEventName, - PlatformEventPayload, -} from './types.js'; - -interface EventEnvelope { - event: PlatformEvent; -} - -export interface DurableEventBusOptions { - store: QueueStore; - queueName?: string; - workerId?: string; - pollIntervalMs?: number; - leaseMs?: number; - backoffMs?: number; - autoStart?: boolean; -} - -export class DurableEventBus { - private readonly handlers = new Map< - string, - Set<{ id: string; fn: EventHandler }> - >(); - private subscriptionCounter = 0; - private readonly queueName: string; - private readonly store: QueueStore; - private readonly worker: QueueWorker; - private running = false; - - constructor(options: DurableEventBusOptions) { - this.queueName = options.queueName ?? 'platform-events'; - this.store = options.store; - this.worker = new QueueWorker({ - queueName: this.queueName, - store: this.store, - workerId: options.workerId, - pollIntervalMs: options.pollIntervalMs, - leaseMs: options.leaseMs, - backoffMs: options.backoffMs, - handler: async job => { - await this.dispatch(job); - }, - }); - - if (options.autoStart !== false) { - this.start(); - } - } - - on(eventType: T, handler: EventHandler): EventSubscription { - const id = `sub_${++this.subscriptionCounter}`; - - if (!this.handlers.has(eventType)) { - this.handlers.set(eventType, new Set()); - } - - const entry = { id, fn: handler as EventHandler }; - this.handlers.get(eventType)!.add(entry); - - return { - id, - eventType, - unsubscribe: () => { - this.handlers.get(eventType)?.delete(entry); - }, - }; - } - - async emit( - eventType: T, - payload: PlatformEventPayload, - options?: { source?: string } - ): Promise { - const event: PlatformEvent = { - id: crypto.randomUUID(), - type: eventType, - payload, - timestamp: new Date().toISOString(), - source: options?.source, - }; - - await this.store.enqueue(this.queueName, { - idempotencyKey: event.id, - type: eventType, - payload: { event }, - productId: extractProductId(payload), - metadata: { - source: options?.source, - }, - }); - - return { - eventId: event.id, - handlerCount: this.listenerCount(eventType), - errors: [], - }; - } - - start(): void { - if (this.running) return; - this.running = true; - this.worker.start(); - } - - async stop(): Promise { - if (!this.running) return; - this.running = false; - await this.worker.stop(); - } - - clear(eventType?: PlatformEventName): void { - if (eventType) { - this.handlers.delete(eventType); - } else { - this.handlers.clear(); - } - } - - listenerCount(eventType: PlatformEventName): number { - return this.handlers.get(eventType)?.size ?? 0; - } - - eventTypes(): PlatformEventName[] { - return Array.from(this.handlers.entries()) - .filter(([, set]) => set.size > 0) - .map(([type]) => type as PlatformEventName); - } - - private async dispatch(job: QueueJob): Promise { - const event = job.payload.event; - const handlers = this.handlers.get(event.type); - if (!handlers || handlers.size === 0) { - return; - } - - await Promise.allSettled( - Array.from(handlers).map(async ({ fn }) => fn(event as PlatformEvent)) - ); - } -} - -function extractProductId(payload: unknown): string | undefined { - if (!payload || typeof payload !== 'object') return undefined; - const productId = (payload as { productId?: unknown }).productId; - return typeof productId === 'string' ? productId : undefined; -} diff --git a/vendor/bytelyst/events/src/ecosystem.test.ts b/vendor/bytelyst/events/src/ecosystem.test.ts deleted file mode 100644 index 6f83cc0..0000000 --- a/vendor/bytelyst/events/src/ecosystem.test.ts +++ /dev/null @@ -1,401 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import transcriptArtifact from '../fixtures/ecosystem/phase1/transcript-artifact.json' with { type: 'json' }; -import noteArtifact from '../fixtures/ecosystem/phase1/note-artifact.json' with { type: 'json' }; -import memoryArtifact from '../fixtures/ecosystem/phase1/memory-artifact.json' with { type: 'json' }; -import captureTranscriptCreatedEvent from '../fixtures/ecosystem/phase1/capture-transcript-created.event.json' with { type: 'json' }; -import artifactCreatedEvent from '../fixtures/ecosystem/phase1/artifact-created.event.json' with { type: 'json' }; -import artifactLinkedEvent from '../fixtures/ecosystem/phase1/artifact-linked.event.json' with { type: 'json' }; -import memoryEntryCreatedEvent from '../fixtures/ecosystem/phase1/memory-entry-created.event.json' with { type: 'json' }; -import { - ArtifactCreatedEventSchema, - ArtifactLinkedEventSchema, - HabitArtifactEnvelopeSchema, - PlanArtifactEnvelopeSchema, - Phase1ArtifactEnvelopeSchema, - Phase1EcosystemEventSchema, - Phase1EcosystemEventSchemas, - RoutineArtifactEnvelopeSchema, - TrailReportArtifactEnvelopeSchema, -} from './ecosystem.js'; - -describe('phase1 ecosystem contracts', () => { - it('validates canonical transcript, note, and memory artifacts', () => { - const transcript = Phase1ArtifactEnvelopeSchema.parse(transcriptArtifact); - const note = Phase1ArtifactEnvelopeSchema.parse(noteArtifact); - const memory = Phase1ArtifactEnvelopeSchema.parse(memoryArtifact); - - expect(transcript.artifactType).toBe('transcript'); - expect(note.links).toContainEqual({ - relation: 'summarizes', - targetArtifactId: transcript.id, - }); - expect(memory.links).toEqual( - expect.arrayContaining([ - { - relation: 'generated-memory', - targetArtifactId: note.id, - }, - ]) - ); - }); - - it('validates canonical phase1 events', () => { - const events = [ - captureTranscriptCreatedEvent, - artifactCreatedEvent, - artifactLinkedEvent, - memoryEntryCreatedEvent, - ].map(event => Phase1EcosystemEventSchema.parse(event)); - - expect(events.map(event => event.eventName)).toEqual([ - 'capture.transcript.created', - 'artifact.created', - 'artifact.linked', - 'memory.entry.created', - ]); - }); - - it('exposes event-specific schemas keyed by canonical event name', () => { - const created = Phase1EcosystemEventSchemas['artifact.created'].parse(artifactCreatedEvent); - const linked = Phase1EcosystemEventSchemas['artifact.linked'].parse(artifactLinkedEvent); - - expect(created.payload.artifactType).toBe('note'); - expect(linked.payload.relation).toBe('summarizes'); - }); -}); - -describe('phase2 ecosystem contract extensions', () => { - it('validates canonical plan, routine, and habit artifacts', () => { - const plan = PlanArtifactEnvelopeSchema.parse({ - id: 'art_plan_demo', - artifactType: 'plan', - schemaVersion: 1, - productId: 'flowmonk', - sourceSurface: 'backend', - title: 'FlowMonk weekly plan', - summary: 'Three focused execution blocks', - createdAt: '2026-04-03T12:00:00.000Z', - updatedAt: '2026-04-03T12:00:00.000Z', - createdBy: { actorType: 'agent', actorId: 'phase2-plan-exporter' }, - ownership: { userId: 'saravana', orgId: null }, - visibility: { - scope: 'private', - allowedProducts: ['learning_ai_clock', 'learning_ai_efforise'], - }, - status: 'draft', - tags: ['ecosystem', 'phase2', 'plan'], - links: [], - provenance: { - originProductId: 'flowmonk', - originActionId: 'plan_export_1', - sessionId: 'sess_phase2', - runId: 'run_phase2_plan', - approvalId: null, - correlationId: 'corr_phase2', - lineage: [ - { - stepType: 'plan-exported', - productId: 'flowmonk', - actorType: 'agent', - timestamp: '2026-04-03T12:00:00.000Z', - }, - ], - }, - payload: { - weekOf: '2026-04-07', - taskCount: 3, - scheduledEntryCount: 3, - totalScheduledMinutes: 165, - entries: [ - { - taskTitle: 'Architecture review', - scheduledDate: '2026-04-07', - startTime: '08:00', - endTime: '09:00', - durationMinutes: 60, - flowName: 'Deep Work', - zoneName: 'Studio', - priority: 'high', - }, - ], - }, - }); - - const routine = RoutineArtifactEnvelopeSchema.parse({ - id: 'art_routine_demo', - artifactType: 'routine', - schemaVersion: 1, - productId: 'chronomind', - sourceSurface: 'backend', - title: 'Routine from FlowMonk weekly plan', - summary: 'Three-step execution routine', - createdAt: '2026-04-03T12:05:00.000Z', - updatedAt: '2026-04-03T12:05:00.000Z', - createdBy: { actorType: 'agent', actorId: 'phase2-routine-importer' }, - ownership: { userId: 'saravana', orgId: null }, - visibility: { scope: 'private' }, - status: 'ready', - tags: ['ecosystem', 'phase2', 'routine'], - links: [{ relation: 'generated-routine', targetArtifactId: plan.id }], - provenance: { - originProductId: 'flowmonk', - originActionId: 'plan_export_1', - sessionId: 'sess_phase2', - runId: 'run_phase2_routine', - approvalId: null, - correlationId: 'corr_phase2', - lineage: [ - { - stepType: 'plan-exported', - productId: 'flowmonk', - actorType: 'agent', - timestamp: '2026-04-03T12:00:00.000Z', - }, - { - stepType: 'routine-created', - productId: 'chronomind', - actorType: 'agent', - timestamp: '2026-04-03T12:05:00.000Z', - }, - ], - }, - payload: { - routineId: 'routine_demo', - stepCount: 3, - totalDurationMinutes: 165, - status: 'ready', - isTemplate: true, - category: 'phase2-import', - steps: [ - { - label: 'Architecture review', - durationMinutes: 60, - transition: '5m_break', - status: 'pending', - }, - ], - }, - }); - - const habit = HabitArtifactEnvelopeSchema.parse({ - id: 'art_habit_demo', - artifactType: 'habit', - schemaVersion: 1, - productId: 'efforise', - sourceSurface: 'backend', - title: 'Habit from FlowMonk weekly plan', - summary: 'Practice the imported routine daily', - createdAt: '2026-04-03T12:10:00.000Z', - updatedAt: '2026-04-03T12:10:00.000Z', - createdBy: { actorType: 'agent', actorId: 'phase2-habit-importer' }, - ownership: { userId: 'saravana', orgId: null }, - visibility: { scope: 'private' }, - status: 'active', - tags: ['ecosystem', 'phase2', 'habit'], - links: [{ relation: 'generated-habit', targetArtifactId: routine.id }], - provenance: { - originProductId: 'flowmonk', - originActionId: 'plan_export_1', - sessionId: 'sess_phase2', - runId: 'run_phase2_habit', - approvalId: null, - correlationId: 'corr_phase2', - lineage: [ - { - stepType: 'plan-exported', - productId: 'flowmonk', - actorType: 'agent', - timestamp: '2026-04-03T12:00:00.000Z', - }, - { - stepType: 'routine-created', - productId: 'chronomind', - actorType: 'agent', - timestamp: '2026-04-03T12:05:00.000Z', - }, - { - stepType: 'habit-created', - productId: 'efforise', - actorType: 'agent', - timestamp: '2026-04-03T12:10:00.000Z', - }, - ], - }, - payload: { - habitId: 'habit_demo', - identityId: 'identity_phase2', - frequency: 'daily', - targetCount: 1, - reminderTime: '08:00', - isActive: true, - sourceRoutineId: 'routine_demo', - }, - }); - - expect(routine.links[0]?.targetArtifactId).toBe(plan.id); - expect(habit.links[0]?.targetArtifactId).toBe(routine.id); - }); - - it('accepts generic artifact.created and artifact.linked events for phase2 artifact types', () => { - const created = ArtifactCreatedEventSchema.parse({ - eventId: 'evt_phase2_created', - eventName: 'artifact.created', - eventVersion: 1, - occurredAt: '2026-04-03T12:05:00.000Z', - productId: 'chronomind', - sourceSurface: 'backend', - userId: 'saravana', - orgId: null, - sessionId: 'sess_phase2', - runId: 'run_phase2_routine', - artifactId: 'art_routine_demo', - actor: { actorType: 'agent', actorId: 'phase2-routine-importer' }, - trace: { - correlationId: 'corr_phase2', - causationId: 'evt_phase2_plan', - parentEventId: 'evt_phase2_plan', - }, - payload: { - artifactType: 'routine', - title: 'Routine from FlowMonk weekly plan', - status: 'ready', - }, - }); - - const linked = ArtifactLinkedEventSchema.parse({ - eventId: 'evt_phase2_linked', - eventName: 'artifact.linked', - eventVersion: 1, - occurredAt: '2026-04-03T12:10:00.000Z', - productId: 'efforise', - sourceSurface: 'backend', - userId: 'saravana', - orgId: null, - sessionId: 'sess_phase2', - runId: 'run_phase2_habit', - artifactId: 'art_habit_demo', - actor: { actorType: 'agent', actorId: 'phase2-habit-importer' }, - trace: { - correlationId: 'corr_phase2', - causationId: 'evt_phase2_created', - parentEventId: 'evt_phase2_created', - }, - payload: { - sourceArtifactId: 'art_habit_demo', - targetArtifactId: 'art_routine_demo', - relation: 'generated-habit', - }, - }); - - expect(created.payload.artifactType).toBe('routine'); - expect(linked.payload.relation).toBe('generated-habit'); - }); -}); - -describe('phase3 ecosystem contract extensions', () => { - it('validates canonical trail-report artifacts', () => { - const trailReport = TrailReportArtifactEnvelopeSchema.parse({ - id: 'art_trail_demo', - artifactType: 'trail-report', - schemaVersion: 1, - productId: 'actiontrail', - sourceSurface: 'backend', - title: 'Cowork audit report for task task-123', - summary: '4 audited actions with 1 safety signal', - createdAt: '2026-04-03T14:00:00.000Z', - updatedAt: '2026-04-03T14:00:00.000Z', - createdBy: { actorType: 'agent', actorId: 'phase3-audit-importer' }, - ownership: { userId: 'saravana', orgId: null }, - visibility: { - scope: 'private', - allowedProducts: ['learning_ai_notes', 'learning_multimodal_memory_agents'], - }, - status: 'recorded', - tags: ['ecosystem', 'phase3', 'audit'], - links: [], - provenance: { - originProductId: 'claw-cowork', - originActionId: 'task-123', - sessionId: 'sess_phase3', - runId: 'run_phase3_trail', - approvalId: null, - correlationId: 'corr_phase3', - lineage: [ - { - stepType: 'audit-exported', - productId: 'claw-cowork', - actorType: 'system', - timestamp: '2026-04-03T13:55:00.000Z', - }, - { - stepType: 'trail-report-created', - productId: 'actiontrail', - actorType: 'agent', - timestamp: '2026-04-03T14:00:00.000Z', - }, - ], - }, - payload: { - sourceProduct: 'claw-cowork', - sourceTaskId: 'task-123', - generatedFrom: 'audit-export-json', - reportGeneratedAt: '2026-04-03T14:00:00.000Z', - actionCount: 4, - toolCallCount: 2, - approvalCount: 1, - failureCount: 0, - safetySignalCount: 1, - tasks: ['task-123'], - actionBreakdown: [ - { action: 'TaskStarted', count: 1 }, - { action: 'ToolCall', count: 2 }, - { action: 'InjectionDetected', count: 1 }, - ], - entries: [ - { - timestamp: '2026-04-03T13:55:00.000Z', - taskId: 'task-123', - action: 'TaskStarted', - tool: null, - result: 'Success', - approval: null, - inputSummary: 'Audit seed task', - metadata: { surface: 'desktop' }, - }, - ], - }, - }); - - expect(trailReport.payload.sourceProduct).toBe('claw-cowork'); - expect(trailReport.payload.safetySignalCount).toBe(1); - }); - - it('accepts generic artifact.created events for trail-report artifacts', () => { - const created = ArtifactCreatedEventSchema.parse({ - eventId: 'evt_phase3_created', - eventName: 'artifact.created', - eventVersion: 1, - occurredAt: '2026-04-03T14:00:00.000Z', - productId: 'actiontrail', - sourceSurface: 'backend', - userId: 'saravana', - orgId: null, - sessionId: 'sess_phase3', - runId: 'run_phase3_trail', - artifactId: 'art_trail_demo', - actor: { actorType: 'agent', actorId: 'phase3-audit-importer' }, - trace: { - correlationId: 'corr_phase3', - causationId: 'evt_cowork_export', - parentEventId: 'evt_cowork_export', - }, - payload: { - artifactType: 'trail-report', - title: 'Cowork audit report for task task-123', - status: 'recorded', - }, - }); - - expect(created.payload.artifactType).toBe('trail-report'); - }); -}); diff --git a/vendor/bytelyst/events/src/ecosystem.ts b/vendor/bytelyst/events/src/ecosystem.ts deleted file mode 100644 index 178ec87..0000000 --- a/vendor/bytelyst/events/src/ecosystem.ts +++ /dev/null @@ -1,349 +0,0 @@ -import { z } from 'zod'; - -export const EcosystemArtifactTypeSchema = z.enum([ - 'transcript', - 'note', - 'memory', - 'plan', - 'routine', - 'habit', - 'habit-checkin', - 'trail-report', - 'route-session', - 'agent-output', - 'document', - 'digest', -]); - -export const ArtifactLinkRelationSchema = z.enum([ - 'derived-from', - 'summarizes', - 'generated-task', - 'generated-routine', - 'generated-habit', - 'generated-memory', - 'evidence-for', - 'review-of', - 'attached-to', -]); - -export const ArtifactLinkSchema = z.object({ - relation: ArtifactLinkRelationSchema, - targetArtifactId: z.string().min(1), -}); - -export const ArtifactCreatedBySchema = z.object({ - actorType: z.enum(['user', 'agent', 'system', 'mixed']), - actorId: z.string().min(1).nullable(), -}); - -export const ArtifactOwnershipSchema = z.object({ - userId: z.string().min(1), - orgId: z.string().min(1).nullable().optional(), -}); - -export const ArtifactVisibilitySchema = z.object({ - scope: z.enum(['private', 'org', 'shared', 'local-only']), - allowedProducts: z.array(z.string().min(1)).optional(), -}); - -export const ArtifactLineageStepSchema = z.object({ - stepType: z.string().min(1), - productId: z.string().min(1), - actorType: z.enum(['user', 'agent', 'system']), - timestamp: z.string().datetime(), -}); - -export const ArtifactProvenanceSchema = z.object({ - originProductId: z.string().min(1), - originActionId: z.string().min(1).nullable().optional(), - sessionId: z.string().min(1).nullable().optional(), - runId: z.string().min(1).nullable().optional(), - approvalId: z.string().min(1).nullable().optional(), - correlationId: z.string().min(1).nullable().optional(), - lineage: z.array(ArtifactLineageStepSchema).min(1), -}); - -export const BaseArtifactEnvelopeSchema = z.object({ - id: z.string().min(1), - artifactType: EcosystemArtifactTypeSchema, - schemaVersion: z.literal(1), - productId: z.string().min(1), - sourceSurface: z.string().min(1), - title: z.string().min(1).nullable(), - summary: z.string().min(1).nullable(), - createdAt: z.string().datetime(), - updatedAt: z.string().datetime(), - createdBy: ArtifactCreatedBySchema, - ownership: ArtifactOwnershipSchema, - visibility: ArtifactVisibilitySchema, - status: z.string().min(1), - tags: z.array(z.string().min(1)), - links: z.array(ArtifactLinkSchema), - provenance: ArtifactProvenanceSchema, - payload: z.record(z.unknown()), -}); - -export const TranscriptPayloadSchema = z.object({ - transcriptText: z.string().min(1), - transcriptSource: z.enum(['microphone', 'upload', 'call', 'browser', 'other']), - language: z.string().min(1), - durationMs: z.number().int().nonnegative(), - segments: z - .array( - z.object({ - speaker: z.string().min(1).nullable().optional(), - startedAtMs: z.number().int().nonnegative(), - endedAtMs: z.number().int().nonnegative(), - text: z.string().min(1), - }) - ) - .default([]), -}); - -export const NotePayloadSchema = z.object({ - noteFormat: z.enum(['markdown', 'plain-text', 'rich-text']), - body: z.string().min(1), - excerpt: z.string().min(1).nullable().optional(), -}); - -export const MemoryPayloadSchema = z.object({ - memoryKind: z.enum(['fact', 'preference', 'person', 'project', 'insight', 'todo']), - text: z.string().min(1), - confidence: z.number().min(0).max(1), - sourceArtifactIds: z.array(z.string().min(1)).min(1), - reviewState: z.enum(['proposed', 'accepted', 'rejected']), -}); - -export const PlanPayloadSchema = z.object({ - weekOf: z.string().min(1), - taskCount: z.number().int().nonnegative(), - scheduledEntryCount: z.number().int().nonnegative(), - totalScheduledMinutes: z.number().int().nonnegative(), - entries: z.array( - z.object({ - taskTitle: z.string().min(1), - scheduledDate: z.string().min(1), - startTime: z.string().min(1), - endTime: z.string().min(1), - durationMinutes: z.number().int().nonnegative(), - flowName: z.string().min(1).nullable(), - zoneName: z.string().min(1).nullable(), - priority: z.string().min(1), - }) - ), -}); - -export const RoutinePayloadSchema = z.object({ - routineId: z.string().min(1), - stepCount: z.number().int().nonnegative(), - totalDurationMinutes: z.number().nonnegative(), - status: z.string().min(1), - isTemplate: z.boolean(), - category: z.string().min(1).nullable().optional(), - steps: z.array( - z.object({ - label: z.string().min(1), - durationMinutes: z.number().nonnegative(), - transition: z.string().min(1), - status: z.string().min(1), - }) - ), -}); - -export const HabitPayloadSchema = z.object({ - habitId: z.string().min(1), - identityId: z.string().min(1), - frequency: z.enum(['daily', 'weekly', 'custom']), - customDays: z.array(z.number().int().min(0).max(6)).optional(), - targetCount: z.number().int().positive(), - reminderTime: z.string().min(1).nullable().optional(), - isActive: z.boolean(), - sourceRoutineId: z.string().min(1), -}); - -export const TrailReportPayloadSchema = z.object({ - sourceProduct: z.literal('claw-cowork'), - sourceTaskId: z.string().min(1).nullable().optional(), - generatedFrom: z.enum(['audit-export-json', 'audit-query-json']), - reportGeneratedAt: z.string().datetime(), - actionCount: z.number().int().nonnegative(), - toolCallCount: z.number().int().nonnegative(), - approvalCount: z.number().int().nonnegative(), - failureCount: z.number().int().nonnegative(), - safetySignalCount: z.number().int().nonnegative(), - tasks: z.array(z.string().min(1)), - actionBreakdown: z.array( - z.object({ - action: z.string().min(1), - count: z.number().int().positive(), - }) - ), - entries: z.array( - z.object({ - timestamp: z.string().datetime(), - taskId: z.string().min(1).nullable(), - action: z.string().min(1), - tool: z.string().min(1).nullable().optional(), - result: z.string().min(1).nullable().optional(), - approval: z.string().min(1).nullable().optional(), - inputSummary: z.string().min(1).nullable().optional(), - metadata: z.record(z.unknown()).nullable().optional(), - }) - ), -}); - -export const TranscriptArtifactEnvelopeSchema = BaseArtifactEnvelopeSchema.extend({ - artifactType: z.literal('transcript'), - payload: TranscriptPayloadSchema, -}); - -export const NoteArtifactEnvelopeSchema = BaseArtifactEnvelopeSchema.extend({ - artifactType: z.literal('note'), - payload: NotePayloadSchema, -}); - -export const MemoryArtifactEnvelopeSchema = BaseArtifactEnvelopeSchema.extend({ - artifactType: z.literal('memory'), - payload: MemoryPayloadSchema, -}); - -export const PlanArtifactEnvelopeSchema = BaseArtifactEnvelopeSchema.extend({ - artifactType: z.literal('plan'), - payload: PlanPayloadSchema, -}); - -export const RoutineArtifactEnvelopeSchema = BaseArtifactEnvelopeSchema.extend({ - artifactType: z.literal('routine'), - payload: RoutinePayloadSchema, -}); - -export const HabitArtifactEnvelopeSchema = BaseArtifactEnvelopeSchema.extend({ - artifactType: z.literal('habit'), - payload: HabitPayloadSchema, -}); - -export const TrailReportArtifactEnvelopeSchema = BaseArtifactEnvelopeSchema.extend({ - artifactType: z.literal('trail-report'), - payload: TrailReportPayloadSchema, -}); - -export const Phase1ArtifactEnvelopeSchema = z.discriminatedUnion('artifactType', [ - TranscriptArtifactEnvelopeSchema, - NoteArtifactEnvelopeSchema, - MemoryArtifactEnvelopeSchema, -]); - -export const EcosystemEventActorSchema = z.object({ - actorType: z.enum(['user', 'agent', 'system', 'device']), - actorId: z.string().min(1).nullable().optional(), -}); - -export const EcosystemEventTraceSchema = z.object({ - correlationId: z.string().min(1).nullable(), - causationId: z.string().min(1).nullable(), - parentEventId: z.string().min(1).nullable(), -}); - -export const BaseEcosystemEventSchema = z.object({ - eventId: z.string().min(1), - eventName: z.string().min(1), - eventVersion: z.literal(1), - occurredAt: z.string().datetime(), - productId: z.string().min(1), - sourceSurface: z.string().min(1), - userId: z.string().min(1).nullable().optional(), - orgId: z.string().min(1).nullable().optional(), - sessionId: z.string().min(1).nullable().optional(), - runId: z.string().min(1).nullable().optional(), - artifactId: z.string().min(1).nullable().optional(), - actor: EcosystemEventActorSchema, - trace: EcosystemEventTraceSchema, - payload: z.record(z.unknown()), -}); - -export const CaptureTranscriptCreatedPayloadSchema = z.object({ - artifactId: z.string().min(1), - durationMs: z.number().int().nonnegative(), - language: z.string().min(1), - transcriptSource: z.enum(['microphone', 'upload', 'call', 'browser', 'other']), -}); - -export const ArtifactCreatedPayloadSchema = z.object({ - artifactType: z.enum([ - 'transcript', - 'note', - 'memory', - 'plan', - 'routine', - 'habit', - 'trail-report', - ]), - title: z.string().min(1).nullable(), - status: z.string().min(1), -}); - -export const ArtifactLinkedPayloadSchema = z.object({ - sourceArtifactId: z.string().min(1), - targetArtifactId: z.string().min(1), - relation: z.enum([ - 'summarizes', - 'generated-memory', - 'generated-routine', - 'generated-habit', - 'derived-from', - ]), -}); - -export const MemoryEntryCreatedPayloadSchema = z.object({ - artifactId: z.string().min(1), - memoryKind: MemoryPayloadSchema.shape.memoryKind, - reviewState: MemoryPayloadSchema.shape.reviewState, - confidence: MemoryPayloadSchema.shape.confidence, - sourceArtifactIds: z.array(z.string().min(1)).min(1), -}); - -export const CaptureTranscriptCreatedEventSchema = BaseEcosystemEventSchema.extend({ - eventName: z.literal('capture.transcript.created'), - payload: CaptureTranscriptCreatedPayloadSchema, -}); - -export const ArtifactCreatedEventSchema = BaseEcosystemEventSchema.extend({ - eventName: z.literal('artifact.created'), - payload: ArtifactCreatedPayloadSchema, -}); - -export const ArtifactLinkedEventSchema = BaseEcosystemEventSchema.extend({ - eventName: z.literal('artifact.linked'), - payload: ArtifactLinkedPayloadSchema, -}); - -export const MemoryEntryCreatedEventSchema = BaseEcosystemEventSchema.extend({ - eventName: z.literal('memory.entry.created'), - payload: MemoryEntryCreatedPayloadSchema, -}); - -export const Phase1EcosystemEventSchema = z.discriminatedUnion('eventName', [ - CaptureTranscriptCreatedEventSchema, - ArtifactCreatedEventSchema, - ArtifactLinkedEventSchema, - MemoryEntryCreatedEventSchema, -]); - -export const Phase1EcosystemEventSchemas = { - 'capture.transcript.created': CaptureTranscriptCreatedEventSchema, - 'artifact.created': ArtifactCreatedEventSchema, - 'artifact.linked': ArtifactLinkedEventSchema, - 'memory.entry.created': MemoryEntryCreatedEventSchema, -} as const; - -export type ArtifactEnvelope = z.infer; -export type TranscriptArtifactEnvelope = z.infer; -export type NoteArtifactEnvelope = z.infer; -export type MemoryArtifactEnvelope = z.infer; -export type PlanArtifactEnvelope = z.infer; -export type RoutineArtifactEnvelope = z.infer; -export type HabitArtifactEnvelope = z.infer; -export type Phase1ArtifactEnvelope = z.infer; -export type EcosystemEvent = z.infer; -export type Phase1EcosystemEvent = z.infer; diff --git a/vendor/bytelyst/events/src/index.ts b/vendor/bytelyst/events/src/index.ts deleted file mode 100644 index 2c22a07..0000000 --- a/vendor/bytelyst/events/src/index.ts +++ /dev/null @@ -1,80 +0,0 @@ -export { EventBus } from './memory.js'; -export type { EmitResult, EmitError } from './memory.js'; -export { DurableEventBus } from './durable.js'; -export type { DurableEventBusOptions } from './durable.js'; -export { PlatformEventSchemas } from './types.js'; -export { - AgentActionLogSchema, - AgentCheckpointSchema, - AgentApprovalCheckpointSchema, - AgentDispatchContextSchema, - AgentDispatchRequestSchema, - AgentRunSchema, - AgentSessionSchema, - AgentTaskSchema, - AgentTodoSchema, -} from './agent-runtime.js'; -export { - ArtifactCreatedBySchema, - ArtifactLineageStepSchema, - ArtifactLinkRelationSchema, - ArtifactLinkSchema, - ArtifactOwnershipSchema, - ArtifactProvenanceSchema, - ArtifactVisibilitySchema, - BaseArtifactEnvelopeSchema, - BaseEcosystemEventSchema, - CaptureTranscriptCreatedEventSchema, - CaptureTranscriptCreatedPayloadSchema, - EcosystemArtifactTypeSchema, - EcosystemEventActorSchema, - EcosystemEventTraceSchema, - MemoryArtifactEnvelopeSchema, - MemoryEntryCreatedEventSchema, - MemoryEntryCreatedPayloadSchema, - MemoryPayloadSchema, - NoteArtifactEnvelopeSchema, - NotePayloadSchema, - Phase1ArtifactEnvelopeSchema, - Phase1EcosystemEventSchema, - Phase1EcosystemEventSchemas, - TranscriptArtifactEnvelopeSchema, - TranscriptPayloadSchema, - ArtifactCreatedEventSchema, - ArtifactCreatedPayloadSchema, - ArtifactLinkedEventSchema, - ArtifactLinkedPayloadSchema, -} from './ecosystem.js'; -export { - buildTimelineItem, - buildTimelineItems, - TimelineItemSchema, - TimelineVisibilitySchema, -} from './timeline.js'; -export type { - AgentActionLog, - AgentCheckpoint, - AgentApprovalCheckpoint, - AgentDispatchContext, - AgentDispatchRequest, - AgentRun, - AgentSession, - AgentTask, - AgentTodo, -} from './agent-runtime.js'; -export type { - PlatformEventName, - PlatformEventPayload, - PlatformEvent, - EventHandler, - EventSubscription, -} from './types.js'; -export type { - EcosystemEvent, - MemoryArtifactEnvelope, - NoteArtifactEnvelope, - Phase1ArtifactEnvelope, - Phase1EcosystemEvent, - TranscriptArtifactEnvelope, -} from './ecosystem.js'; -export type { TimelineItem } from './timeline.js'; diff --git a/vendor/bytelyst/events/src/memory.test.ts b/vendor/bytelyst/events/src/memory.test.ts deleted file mode 100644 index 87567d0..0000000 --- a/vendor/bytelyst/events/src/memory.test.ts +++ /dev/null @@ -1,250 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { EventBus } from './memory.js'; -import type { PlatformEvent } from './types.js'; - -describe('EventBus', () => { - let bus: EventBus; - - beforeEach(() => { - bus = new EventBus(); - }); - - describe('on / emit', () => { - it('should deliver events to registered handlers', async () => { - const handler = vi.fn(); - bus.on('user.created', handler); - - await bus.emit('user.created', { - userId: 'u1', - email: 'test@example.com', - plan: 'free', - productId: 'lysnrai', - }); - - expect(handler).toHaveBeenCalledOnce(); - expect(handler.mock.calls[0][0]).toMatchObject({ - type: 'user.created', - payload: { userId: 'u1', email: 'test@example.com' }, - }); - }); - - it('should include event id and timestamp', async () => { - let received: PlatformEvent<'user.created'> | undefined; - bus.on('user.created', e => { - received = e; - }); - - await bus.emit('user.created', { - userId: 'u1', - email: 'a@b.com', - plan: 'free', - productId: 'test', - }); - - expect(received).toBeDefined(); - expect(received!.id).toBeTruthy(); - expect(received!.timestamp).toBeTruthy(); - }); - - it('should deliver to multiple handlers', async () => { - const h1 = vi.fn(); - const h2 = vi.fn(); - const h3 = vi.fn(); - bus.on('user.created', h1); - bus.on('user.created', h2); - bus.on('user.created', h3); - - await bus.emit('user.created', { - userId: 'u1', - email: 'a@b.com', - plan: 'free', - productId: 'test', - }); - - expect(h1).toHaveBeenCalledOnce(); - expect(h2).toHaveBeenCalledOnce(); - expect(h3).toHaveBeenCalledOnce(); - }); - - it('should not deliver to handlers of different event types', async () => { - const userHandler = vi.fn(); - const paymentHandler = vi.fn(); - bus.on('user.created', userHandler); - bus.on('payment.succeeded', paymentHandler); - - await bus.emit('user.created', { - userId: 'u1', - email: 'a@b.com', - plan: 'free', - productId: 'test', - }); - - expect(userHandler).toHaveBeenCalledOnce(); - expect(paymentHandler).not.toHaveBeenCalled(); - }); - - it('should return result with zero handlers when no subscribers', async () => { - const result = await bus.emit('user.deleted', { - userId: 'u1', - productId: 'test', - }); - - expect(result.handlerCount).toBe(0); - expect(result.errors).toHaveLength(0); - expect(result.eventId).toBeTruthy(); - }); - }); - - describe('error isolation', () => { - it('should not block other handlers when one throws', async () => { - const h1 = vi.fn(); - const h2 = vi.fn(() => { - throw new Error('handler crash'); - }); - const h3 = vi.fn(); - - bus.on('user.created', h1); - bus.on('user.created', h2); - bus.on('user.created', h3); - - const result = await bus.emit('user.created', { - userId: 'u1', - email: 'a@b.com', - plan: 'free', - productId: 'test', - }); - - expect(h1).toHaveBeenCalledOnce(); - expect(h2).toHaveBeenCalledOnce(); - expect(h3).toHaveBeenCalledOnce(); - expect(result.errors).toHaveLength(1); - expect(result.errors[0].error).toBe('handler crash'); - }); - - it('should handle async handler rejection', async () => { - bus.on('payment.failed', async () => { - throw new Error('async fail'); - }); - - const result = await bus.emit('payment.failed', { - invoiceId: 'inv_1', - userId: 'u1', - amount: 999, - retryCount: 1, - productId: 'test', - }); - - expect(result.errors).toHaveLength(1); - expect(result.errors[0].error).toBe('async fail'); - }); - }); - - describe('unsubscribe', () => { - it('should stop delivering events after unsubscribe', async () => { - const handler = vi.fn(); - const sub = bus.on('user.created', handler); - - await bus.emit('user.created', { - userId: 'u1', - email: 'a@b.com', - plan: 'free', - productId: 'test', - }); - expect(handler).toHaveBeenCalledOnce(); - - sub.unsubscribe(); - - await bus.emit('user.created', { - userId: 'u2', - email: 'b@c.com', - plan: 'pro', - productId: 'test', - }); - expect(handler).toHaveBeenCalledOnce(); // still 1, not 2 - }); - - it('should return subscription metadata', () => { - const sub = bus.on('flag.toggled', () => {}); - expect(sub.id).toBeTruthy(); - expect(sub.eventType).toBe('flag.toggled'); - expect(typeof sub.unsubscribe).toBe('function'); - }); - }); - - describe('clear', () => { - it('should remove all handlers for a specific event type', async () => { - const h1 = vi.fn(); - const h2 = vi.fn(); - bus.on('user.created', h1); - bus.on('payment.succeeded', h2); - - bus.clear('user.created'); - - await bus.emit('user.created', { - userId: 'u1', - email: 'a@b.com', - plan: 'free', - productId: 'test', - }); - await bus.emit('payment.succeeded', { - invoiceId: 'inv_1', - userId: 'u1', - amount: 100, - currency: 'usd', - productId: 'test', - }); - - expect(h1).not.toHaveBeenCalled(); - expect(h2).toHaveBeenCalledOnce(); - }); - - it('should remove all handlers when called without args', () => { - bus.on('user.created', () => {}); - bus.on('payment.failed', () => {}); - bus.on('flag.toggled', () => {}); - - expect(bus.eventTypes().length).toBe(3); - bus.clear(); - expect(bus.eventTypes().length).toBe(0); - }); - }); - - describe('listenerCount / eventTypes', () => { - it('should count handlers per event type', () => { - bus.on('user.created', () => {}); - bus.on('user.created', () => {}); - bus.on('payment.failed', () => {}); - - expect(bus.listenerCount('user.created')).toBe(2); - expect(bus.listenerCount('payment.failed')).toBe(1); - expect(bus.listenerCount('user.deleted')).toBe(0); - }); - - it('should list event types with handlers', () => { - bus.on('user.created', () => {}); - bus.on('payment.failed', () => {}); - - const types = bus.eventTypes(); - expect(types).toContain('user.created'); - expect(types).toContain('payment.failed'); - expect(types).not.toContain('user.deleted'); - }); - }); - - describe('source option', () => { - it('should pass source through to handlers', async () => { - let received: PlatformEvent<'user.created'> | undefined; - bus.on('user.created', e => { - received = e; - }); - - await bus.emit( - 'user.created', - { userId: 'u1', email: 'a@b.com', plan: 'free', productId: 'test' }, - { source: 'auth-module' } - ); - - expect(received?.source).toBe('auth-module'); - }); - }); -}); diff --git a/vendor/bytelyst/events/src/memory.ts b/vendor/bytelyst/events/src/memory.ts deleted file mode 100644 index 0c419b6..0000000 --- a/vendor/bytelyst/events/src/memory.ts +++ /dev/null @@ -1,122 +0,0 @@ -import type { - PlatformEventName, - PlatformEventPayload, - PlatformEvent, - EventHandler, - EventSubscription, -} from './types.js'; - -// ── In-Memory Event Bus ────────────────────────────────────── -// Phase 1 implementation: typed in-process pub/sub with error isolation. -// Handlers run concurrently via Promise.allSettled — a failing handler -// never blocks other handlers or the emitter. - -export class EventBus { - private handlers = new Map }>>(); - private subscriptionCounter = 0; - - /** - * Subscribe to a specific event type. - * Returns an EventSubscription with an `unsubscribe()` method. - */ - on(eventType: T, handler: EventHandler): EventSubscription { - const id = `sub_${++this.subscriptionCounter}`; - - if (!this.handlers.has(eventType)) { - this.handlers.set(eventType, new Set()); - } - - const entry = { id, fn: handler as EventHandler }; - this.handlers.get(eventType)!.add(entry); - - return { - id, - eventType, - unsubscribe: () => { - this.handlers.get(eventType)?.delete(entry); - }, - }; - } - - /** - * Emit an event to all registered handlers for that event type. - * Handlers run concurrently. Errors are collected and returned, - * never thrown — the emitter is never blocked. - */ - async emit( - eventType: T, - payload: PlatformEventPayload, - options?: { source?: string } - ): Promise { - const event: PlatformEvent = { - id: crypto.randomUUID(), - type: eventType, - payload, - timestamp: new Date().toISOString(), - source: options?.source, - }; - - const handlers = this.handlers.get(eventType); - if (!handlers || handlers.size === 0) { - return { eventId: event.id, handlerCount: 0, errors: [] }; - } - - const results = await Promise.allSettled( - Array.from(handlers).map(async ({ fn }) => fn(event as PlatformEvent)) - ); - - const errors: EmitError[] = []; - for (const result of results) { - if (result.status === 'rejected') { - errors.push({ - eventType, - eventId: event.id, - error: result.reason instanceof Error ? result.reason.message : String(result.reason), - }); - } - } - - return { eventId: event.id, handlerCount: handlers.size, errors }; - } - - /** - * Remove all handlers for a specific event type, or all handlers if no type given. - */ - clear(eventType?: PlatformEventName): void { - if (eventType) { - this.handlers.delete(eventType); - } else { - this.handlers.clear(); - } - } - - /** - * Get count of registered handlers for a specific event type. - */ - listenerCount(eventType: PlatformEventName): number { - return this.handlers.get(eventType)?.size ?? 0; - } - - /** - * Get all event types that have at least one handler registered. - */ - eventTypes(): PlatformEventName[] { - return Array.from(this.handlers.entries()) - .filter(([, set]) => set.size > 0) - .map(([type]) => type as PlatformEventName); - } -} - -// ── Result Types ───────────────────────────────────────────── - -export interface EmitResult { - eventId: string; - handlerCount: number; - errors: EmitError[]; -} - -export interface EmitError { - eventType: string; - eventId: string; - error: string; -} diff --git a/vendor/bytelyst/events/src/timeline.test.ts b/vendor/bytelyst/events/src/timeline.test.ts deleted file mode 100644 index 4f444c7..0000000 --- a/vendor/bytelyst/events/src/timeline.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { buildTimelineItems, TimelineItemSchema } from './timeline.js'; - -describe('timeline contract baseline', () => { - it('builds unified timeline items across phases 1 to 3', () => { - const items = buildTimelineItems([ - { - eventId: 'evt_capture_1', - eventName: 'capture.transcript.created', - occurredAt: '2026-04-03T12:00:00.000Z', - productId: 'lysnrai', - artifactId: 'art_transcript_1', - actor: { actorType: 'user', actorId: 'saravana' }, - trace: { correlationId: 'corr_phase1', causationId: null, parentEventId: null }, - payload: { durationMs: 42150, transcriptSource: 'microphone' }, - }, - { - eventId: 'evt_plan_1', - eventName: 'artifact.created', - occurredAt: '2026-04-03T13:00:00.000Z', - productId: 'flowmonk', - artifactId: 'art_plan_1', - actor: { actorType: 'agent', actorId: 'phase2-plan-exporter' }, - trace: { correlationId: 'corr_phase2', causationId: null, parentEventId: null }, - payload: { artifactType: 'plan', title: 'FlowMonk weekly plan', status: 'draft' }, - }, - { - eventId: 'evt_trail_1', - eventName: 'artifact.created', - occurredAt: '2026-04-03T14:00:00.000Z', - productId: 'actiontrail', - artifactId: 'art_trail_1', - actor: { actorType: 'agent', actorId: 'phase3-audit-importer' }, - trace: { - correlationId: 'corr_phase3', - causationId: 'task_phase3', - parentEventId: 'task_phase3', - }, - payload: { - artifactType: 'trail-report', - title: 'Cowork audit report for task task_phase3', - status: 'recorded', - }, - }, - { - eventId: 'evt_memory_1', - eventName: 'memory.entry.created', - occurredAt: '2026-04-03T14:10:00.000Z', - productId: 'mindlyst', - artifactId: 'art_memory_1', - actor: { actorType: 'agent', actorId: 'phase3-audit-note-importer' }, - trace: { - correlationId: 'corr_phase3', - causationId: 'evt_note_linked_1', - parentEventId: 'evt_note_linked_1', - }, - payload: { - memoryKind: 'insight', - sourceArtifactIds: ['art_note_1', 'art_trail_1'], - }, - }, - ]); - - expect(items.map(item => item.eventName)).toEqual([ - 'memory.entry.created', - 'artifact.created', - 'artifact.created', - 'capture.transcript.created', - ]); - expect(items[0]?.title).toBe('Insight memory proposed'); - expect(items[1]?.title).toBe('Cowork audit report for task task_phase3'); - expect(items[2]?.summary).toBe('plan status: draft'); - expect(items[3]?.summary).toContain('microphone transcript captured'); - expect(items[0]?.relatedEventIds).toEqual(['evt_note_linked_1']); - }); - - it('exposes a stable timeline item schema', () => { - const item = TimelineItemSchema.parse({ - itemId: 'timeline_evt_1', - occurredAt: '2026-04-03T14:10:00.000Z', - eventName: 'memory.entry.created', - productId: 'mindlyst', - title: 'Insight memory proposed', - summary: '2 source artifacts linked', - artifactRefs: ['art_memory_1'], - relatedEventIds: ['evt_note_linked_1'], - actorType: 'agent', - visibility: 'private', - correlationId: 'corr_phase3', - }); - - expect(item.visibility).toBe('private'); - expect(item.relatedEventIds).toHaveLength(1); - }); -}); diff --git a/vendor/bytelyst/events/src/timeline.ts b/vendor/bytelyst/events/src/timeline.ts deleted file mode 100644 index ad9568f..0000000 --- a/vendor/bytelyst/events/src/timeline.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { z } from 'zod'; -import type { EcosystemEvent } from './ecosystem.js'; - -export const TimelineVisibilitySchema = z.enum(['private', 'org', 'shared', 'local-only']); - -export const TimelineItemSchema = z.object({ - itemId: z.string().min(1), - occurredAt: z.string().datetime(), - eventName: z.string().min(1), - productId: z.string().min(1), - title: z.string().min(1), - summary: z.string().nullable().optional(), - artifactRefs: z.array(z.string().min(1)), - relatedEventIds: z.array(z.string().min(1)), - actorType: z.enum(['user', 'agent', 'system', 'device']), - visibility: TimelineVisibilitySchema, - correlationId: z.string().min(1).nullable().optional(), -}); - -export type TimelineItem = z.infer; - -type EventLike = Pick< - EcosystemEvent, - | 'eventId' - | 'eventName' - | 'occurredAt' - | 'productId' - | 'artifactId' - | 'actor' - | 'trace' - | 'payload' ->; - -function titleForEvent(event: EventLike): string { - if (event.eventName === 'capture.transcript.created') { - return 'Transcript captured'; - } - if (event.eventName === 'memory.entry.created') { - const kind = typeof event.payload.memoryKind === 'string' ? event.payload.memoryKind : 'memory'; - return `${capitalize(kind)} memory proposed`; - } - if (event.eventName === 'artifact.created') { - const type = - typeof event.payload.artifactType === 'string' ? event.payload.artifactType : 'artifact'; - const title = - typeof event.payload.title === 'string' && event.payload.title ? event.payload.title : null; - return title ? title : `${capitalize(type)} created`; - } - if (event.eventName === 'artifact.linked') { - const relation = typeof event.payload.relation === 'string' ? event.payload.relation : 'linked'; - return `Artifact ${relation}`; - } - return event.eventName; -} - -function summaryForEvent(event: EventLike): string | null { - if (event.eventName === 'capture.transcript.created') { - const duration = - typeof event.payload.durationMs === 'number' - ? `${event.payload.durationMs}ms` - : 'unknown duration'; - const source = - typeof event.payload.transcriptSource === 'string' - ? event.payload.transcriptSource - : 'unknown source'; - return `${source} transcript captured (${duration})`; - } - if (event.eventName === 'memory.entry.created') { - const sources = Array.isArray(event.payload.sourceArtifactIds) - ? event.payload.sourceArtifactIds.length - : 0; - return `${sources} source artifact${sources === 1 ? '' : 's'} linked`; - } - if (event.eventName === 'artifact.created') { - const type = - typeof event.payload.artifactType === 'string' ? event.payload.artifactType : 'artifact'; - const status = typeof event.payload.status === 'string' ? event.payload.status : 'created'; - return `${type} status: ${status}`; - } - if (event.eventName === 'artifact.linked') { - const target = - typeof event.payload.targetArtifactId === 'string' - ? event.payload.targetArtifactId - : 'unknown target'; - return `Linked to ${target}`; - } - return null; -} - -function relatedEventIdsForEvent(event: EventLike): string[] { - return Array.from( - new Set( - [event.trace.parentEventId, event.trace.causationId].filter( - (value): value is string => typeof value === 'string' && value.length > 0 - ) - ) - ); -} - -export function buildTimelineItem(event: EventLike): TimelineItem { - return TimelineItemSchema.parse({ - itemId: `timeline_${event.eventId}`, - occurredAt: event.occurredAt, - eventName: event.eventName, - productId: event.productId, - title: titleForEvent(event), - summary: summaryForEvent(event), - artifactRefs: [event.artifactId].filter((value): value is string => Boolean(value)), - relatedEventIds: relatedEventIdsForEvent(event), - actorType: event.actor.actorType, - visibility: 'private', - correlationId: event.trace.correlationId ?? null, - }); -} - -export function buildTimelineItems(events: EventLike[]): TimelineItem[] { - return events - .map(buildTimelineItem) - .sort((left, right) => right.occurredAt.localeCompare(left.occurredAt)); -} - -function capitalize(value: string): string { - return value.length === 0 ? value : `${value[0]!.toUpperCase()}${value.slice(1)}`; -} diff --git a/vendor/bytelyst/events/src/types.ts b/vendor/bytelyst/events/src/types.ts deleted file mode 100644 index f59677b..0000000 --- a/vendor/bytelyst/events/src/types.ts +++ /dev/null @@ -1,322 +0,0 @@ -import { z } from 'zod'; - -// ── Platform Event Schemas ─────────────────────────────────── -// Each event type has a Zod schema for payload validation. -// Handlers receive typed payloads matching these schemas. - -export const PlatformEventSchemas = { - // Auth events - 'user.created': z.object({ - userId: z.string(), - email: z.string(), - plan: z.string(), - productId: z.string(), - }), - 'user.deleted': z.object({ - userId: z.string(), - email: z.string(), - productId: z.string(), - }), - 'user.email_verified': z.object({ - userId: z.string(), - email: z.string(), - productId: z.string(), - }), - 'user.password_reset': z.object({ - userId: z.string(), - email: z.string(), - resetToken: z.string(), - displayName: z.string().optional(), - productId: z.string(), - }), - 'user.email_verification_requested': z.object({ - userId: z.string(), - email: z.string(), - verificationToken: z.string(), - displayName: z.string().optional(), - productId: z.string(), - }), - - // SmartAuth events - 'auth.account_locked': z.object({ - userId: z.string(), - email: z.string(), - productId: z.string(), - lockedUntil: z.string(), - failedAttempts: z.number(), - }), - 'auth.oauth_linked': z.object({ - userId: z.string(), - provider: z.string(), - providerEmail: z.string(), - productId: z.string(), - }), - 'auth.oauth_unlinked': z.object({ - userId: z.string(), - provider: z.string(), - productId: z.string(), - }), - 'auth.membership_provisioned': z.object({ - userId: z.string(), - productId: z.string(), - plan: z.string(), - role: z.string(), - }), - 'auth.account_merged': z.object({ - primaryUserId: z.string(), - secondaryUserId: z.string(), - productId: z.string(), - }), - 'auth.magic_link_requested': z.object({ - userId: z.string(), - email: z.string(), - token: z.string(), - productId: z.string(), - expiresAt: z.string(), - }), - - // Subscription events - 'subscription.created': z.object({ - subscriptionId: z.string(), - userId: z.string(), - plan: z.string(), - status: z.string(), - productId: z.string(), - }), - 'subscription.changed': z.object({ - subscriptionId: z.string(), - userId: z.string(), - oldPlan: z.string(), - newPlan: z.string(), - productId: z.string(), - }), - 'subscription.canceled': z.object({ - subscriptionId: z.string(), - userId: z.string(), - reason: z.string().optional(), - productId: z.string(), - }), - 'subscription.trial_expiring': z.object({ - subscriptionId: z.string(), - userId: z.string(), - expiresAt: z.string(), - productId: z.string(), - }), - 'subscription.trial_expired': z.object({ - subscriptionId: z.string(), - userId: z.string(), - productId: z.string(), - }), - - // Payment events - 'payment.succeeded': z.object({ - invoiceId: z.string(), - userId: z.string(), - amount: z.number(), - currency: z.string(), - productId: z.string(), - }), - 'payment.failed': z.object({ - invoiceId: z.string(), - userId: z.string(), - amount: z.number(), - currency: z.string().default('usd'), - retryCount: z.number(), - productId: z.string(), - }), - - // Growth events - 'invitation.redeemed': z.object({ - invitationId: z.string(), - userId: z.string(), - productId: z.string(), - }), - 'referral.completed': z.object({ - referralId: z.string(), - referrerId: z.string(), - referredId: z.string(), - productId: z.string(), - }), - 'waitlist.joined': z.object({ - email: z.string(), - position: z.number(), - productId: z.string(), - }), - - // Feature flags - 'flag.toggled': z.object({ - flagId: z.string(), - enabled: z.boolean(), - percentage: z.number().optional(), - productId: z.string(), - flagKey: z.string().optional(), - actor: z.string().optional(), - }), - 'flag.created': z.object({ - productId: z.string(), - flagKey: z.string(), - actor: z.string().optional(), - }), - 'flag.updated': z.object({ - productId: z.string(), - flagKey: z.string(), - actor: z.string().optional(), - changes: z.array(z.string()).optional(), - }), - 'flag.deleted': z.object({ - productId: z.string(), - flagKey: z.string(), - actor: z.string().optional(), - }), - 'flag.kill_switch': z.object({ - productId: z.string(), - disabled: z.array(z.string()), - actor: z.string().optional(), - }), - - // License events - 'license.activated': z.object({ - licenseId: z.string(), - userId: z.string(), - deviceId: z.string().optional(), - productId: z.string(), - }), - 'license.expired': z.object({ - licenseId: z.string(), - userId: z.string(), - productId: z.string(), - }), - - // Job events - 'job.completed': z.object({ - jobName: z.string(), - runId: z.string(), - durationMs: z.number(), - productId: z.string(), - }), - 'job.failed': z.object({ - jobName: z.string(), - runId: z.string(), - error: z.string(), - productId: z.string(), - }), - - // Diagnostics events - 'diagnostics.session.created': z.object({ - sessionId: z.string(), - productId: z.string(), - targetUserId: z.string().optional(), - targetAnonymousId: z.string().optional(), - targetDeviceId: z.string().optional(), - createdBy: z.string(), - }), - 'diagnostics.session.started': z.object({ - sessionId: z.string(), - productId: z.string(), - startedAt: z.string(), - }), - 'diagnostics.session.updated': z.object({ - sessionId: z.string(), - productId: z.string(), - changes: z.record(z.unknown()), - updatedBy: z.string(), - }), - 'diagnostics.session.cancelled': z.object({ - sessionId: z.string(), - productId: z.string(), - reason: z.string().optional(), - cancelledBy: z.string(), - }), - 'diagnostics.session.completed': z.object({ - sessionId: z.string(), - productId: z.string(), - stats: z.object({ - logCount: z.number(), - traceCount: z.number(), - screenshotCount: z.number(), - }), - endedAt: z.string(), - }), - 'diagnostics.session.expired': z.object({ - sessionId: z.string(), - productId: z.string(), - expiredAt: z.string(), - }), - 'diagnostics.ingest.fatal': z.object({ - sessionId: z.string(), - productId: z.string(), - logEntry: z.record(z.unknown()).optional(), - timestamp: z.string(), - }), - 'diagnostics.screenshot.captured': z.object({ - sessionId: z.string(), - productId: z.string(), - screenshotId: z.string(), - trigger: z.enum(['manual', 'error', 'interval', 'user_request']), - }), - - // Delivery events - 'delivery.email.requested': z.object({ - userId: z.string(), - productId: z.string(), - templateId: z.string(), - context: z.record(z.unknown()).optional(), - }), - - // Notifications events - 'notifications.push.requested': z.object({ - userId: z.string(), - productId: z.string(), - title: z.string(), - body: z.string(), - data: z.record(z.unknown()).optional(), - }), - 'notifications.inapp.create': z.object({ - userId: z.string(), - productId: z.string(), - title: z.string(), - content: z.string(), - priority: z.enum(['high', 'normal', 'low']).optional(), - }), - - // Integration events - 'integrations.slack.notify': z.object({ - channel: z.string(), - message: z.record(z.unknown()), - }), - - // Predictive analytics events - 'predictive.campaign.triggered': z.object({ - campaignId: z.string(), - userId: z.string(), - productId: z.string(), - riskSegment: z.string(), - channels: z.array(z.string()), - }), -} as const; - -// ── Derived Types ──────────────────────────────────────────── - -export type PlatformEventName = keyof typeof PlatformEventSchemas; - -export type PlatformEventPayload = z.infer< - (typeof PlatformEventSchemas)[T] ->; - -export interface PlatformEvent { - id: string; - type: T; - payload: PlatformEventPayload; - timestamp: string; - source?: string; -} - -export type EventHandler = ( - event: PlatformEvent -) => void | Promise; - -export interface EventSubscription { - id: string; - eventType: PlatformEventName; - unsubscribe: () => void; -} diff --git a/vendor/bytelyst/events/tsconfig.json b/vendor/bytelyst/events/tsconfig.json deleted file mode 100644 index 5edad81..0000000 --- a/vendor/bytelyst/events/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/vendor/bytelyst/extraction/package.json b/vendor/bytelyst/extraction/package.json deleted file mode 100644 index f83be5c..0000000 --- a/vendor/bytelyst/extraction/package.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "name": "@bytelyst/extraction", - "version": "0.1.5", - "type": "module", - "description": "Shared types and client for the extraction service", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks" - }, - "peerDependencies": { - "@bytelyst/api-client": "workspace:*" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/extraction/src/__tests__/extraction.test.ts b/vendor/bytelyst/extraction/src/__tests__/extraction.test.ts deleted file mode 100644 index 5c15c98..0000000 --- a/vendor/bytelyst/extraction/src/__tests__/extraction.test.ts +++ /dev/null @@ -1,323 +0,0 @@ -/** - * Tests for @bytelyst/extraction package — client factory + types. - */ - -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { createExtractionClient } from '../client.js'; -import type { - ExtractRequest, - ExtractResponse, - BatchExtractRequest, - BatchExtractResponse, - ExtractionTask, - ExtractionClientConfig, - ExtractionEntity, - ExtractionExample, -} from '../types.js'; - -// ── Mock @bytelyst/api-client ────────────────────────────────── - -const mockApiFetch = vi.fn(); - -vi.mock('@bytelyst/api-client', () => ({ - createApiClient: vi.fn(() => ({ - fetch: mockApiFetch, - })), -})); - -describe('createExtractionClient', () => { - beforeEach(() => { - mockApiFetch.mockReset(); - }); - - it('returns an object with extract, extractBatch, listTasks, getTask', () => { - const client = createExtractionClient({ baseUrl: 'http://localhost:4005' }); - expect(typeof client.extract).toBe('function'); - expect(typeof client.extractBatch).toBe('function'); - expect(typeof client.listTasks).toBe('function'); - expect(typeof client.getTask).toBe('function'); - }); - - describe('extract', () => { - it('calls POST /api/extract with correct body', async () => { - const mockResponse: ExtractResponse = { - extractions: [{ extraction_class: 'person', extraction_text: 'John' }], - metadata: { modelId: 'gemini-1.5', durationMs: 150, charCount: 35 }, - }; - mockApiFetch.mockResolvedValue(mockResponse); - - const client = createExtractionClient({ baseUrl: 'http://localhost:4005' }); - const req: ExtractRequest = { - text: 'John said we should ship by Friday.', - taskId: 'transcript-extraction', - }; - - const result = await client.extract(req); - - expect(mockApiFetch).toHaveBeenCalledWith('/api/extract', { - method: 'POST', - body: JSON.stringify(req), - }); - expect(result).toEqual(mockResponse); - }); - - it('passes optional fields in request', async () => { - mockApiFetch.mockResolvedValue({ extractions: [], metadata: {} }); - const client = createExtractionClient({ baseUrl: 'http://localhost:4005' }); - - const req: ExtractRequest = { - text: 'Hello world', - taskPrompt: 'Extract entities', - modelId: 'gpt-4', - productId: 'lysnrai', - options: { extractionPasses: 2, maxWorkers: 4, maxCharBuffer: 1000 }, - examples: [ - { text: 'Hi Bob', extractions: [{ extraction_class: 'person', extraction_text: 'Bob' }] }, - ], - }; - - await client.extract(req); - expect(mockApiFetch).toHaveBeenCalledWith('/api/extract', { - method: 'POST', - body: JSON.stringify(req), - }); - }); - - it('propagates errors from api client', async () => { - mockApiFetch.mockRejectedValue(new Error('Forbidden')); - const client = createExtractionClient({ baseUrl: 'http://localhost:4005' }); - - await expect(client.extract({ text: 'test' })).rejects.toThrow('Forbidden'); - }); - }); - - describe('extractBatch', () => { - it('calls POST /api/extract/batch with correct body', async () => { - const mockResponse: BatchExtractResponse = { - results: [{ extractions: [], metadata: { modelId: 'test', durationMs: 10, charCount: 5 } }], - requestId: 'req-123', - }; - mockApiFetch.mockResolvedValue(mockResponse); - - const client = createExtractionClient({ baseUrl: 'http://localhost:4005' }); - const req: BatchExtractRequest = { - inputs: [ - { text: 'First document', taskId: 'triage' }, - { text: 'Second document', taskPrompt: 'Extract names' }, - ], - modelId: 'gemini-1.5', - productId: 'mindlyst', - }; - - const result = await client.extractBatch(req); - - expect(mockApiFetch).toHaveBeenCalledWith('/api/extract/batch', { - method: 'POST', - body: JSON.stringify(req), - }); - expect(result).toEqual(mockResponse); - }); - }); - - describe('listTasks', () => { - it('calls GET /api/tasks without productId', async () => { - const tasks: ExtractionTask[] = [ - { - id: 'triage', - name: 'Triage Extraction', - prompt: 'Extract entities', - classes: ['person', 'date'], - builtIn: true, - productId: 'lysnrai', - }, - ]; - mockApiFetch.mockResolvedValue(tasks); - - const client = createExtractionClient({ baseUrl: 'http://localhost:4005' }); - const result = await client.listTasks(); - - expect(mockApiFetch).toHaveBeenCalledWith('/api/tasks'); - expect(result).toEqual(tasks); - }); - - it('appends productId query param when provided', async () => { - mockApiFetch.mockResolvedValue([]); - const client = createExtractionClient({ baseUrl: 'http://localhost:4005' }); - - await client.listTasks('mindlyst'); - - expect(mockApiFetch).toHaveBeenCalledWith('/api/tasks?productId=mindlyst'); - }); - - it('encodes special characters in productId', async () => { - mockApiFetch.mockResolvedValue([]); - const client = createExtractionClient({ baseUrl: 'http://localhost:4005' }); - - await client.listTasks('my product'); - - expect(mockApiFetch).toHaveBeenCalledWith('/api/tasks?productId=my%20product'); - }); - }); - - describe('getTask', () => { - it('calls GET /api/tasks/:id without productId', async () => { - const task: ExtractionTask = { - id: 'triage', - name: 'Triage', - prompt: 'Extract', - classes: ['person'], - builtIn: true, - productId: 'lysnrai', - }; - mockApiFetch.mockResolvedValue(task); - - const client = createExtractionClient({ baseUrl: 'http://localhost:4005' }); - const result = await client.getTask('triage'); - - expect(mockApiFetch).toHaveBeenCalledWith('/api/tasks/triage'); - expect(result).toEqual(task); - }); - - it('appends productId query param when provided', async () => { - mockApiFetch.mockResolvedValue({}); - const client = createExtractionClient({ baseUrl: 'http://localhost:4005' }); - - await client.getTask('my-task', 'mindlyst'); - - expect(mockApiFetch).toHaveBeenCalledWith('/api/tasks/my-task?productId=mindlyst'); - }); - - it('encodes special characters in task id', async () => { - mockApiFetch.mockResolvedValue({}); - const client = createExtractionClient({ baseUrl: 'http://localhost:4005' }); - - await client.getTask('task with spaces'); - - expect(mockApiFetch).toHaveBeenCalledWith('/api/tasks/task%20with%20spaces'); - }); - }); - - describe('transcribe', () => { - it('calls POST /api/transcribe with correct body', async () => { - const mockResponse = { - text: 'Hello, this is a test recording.', - language: 'en', - durationSeconds: 5.2, - model: 'whisper-1', - durationMs: 1200, - }; - mockApiFetch.mockResolvedValue(mockResponse); - - const client = createExtractionClient({ baseUrl: 'http://localhost:4005' }); - const req = { - audioUrl: 'https://blob.example.com/audio.mp3', - language: 'en', - productId: 'notelett', - }; - - const result = await client.transcribe(req); - - expect(mockApiFetch).toHaveBeenCalledWith('/api/transcribe', { - method: 'POST', - body: JSON.stringify(req), - }); - expect(result.text).toBe('Hello, this is a test recording.'); - expect(result.language).toBe('en'); - expect(result.durationSeconds).toBe(5.2); - }); - - it('passes optional model and prompt fields', async () => { - mockApiFetch.mockResolvedValue({ - text: 'test', - language: null, - durationSeconds: null, - model: 'whisper-1', - durationMs: 100, - }); - const client = createExtractionClient({ baseUrl: 'http://localhost:4005' }); - - const req = { - audioUrl: 'https://blob.example.com/meeting.wav', - model: 'whisper-1', - prompt: 'Technical meeting about software architecture.', - responseFormat: 'verbose_json' as const, - }; - - await client.transcribe(req); - expect(mockApiFetch).toHaveBeenCalledWith('/api/transcribe', { - method: 'POST', - body: JSON.stringify(req), - }); - }); - - it('propagates errors from api client', async () => { - mockApiFetch.mockRejectedValue(new Error('Service unavailable')); - const client = createExtractionClient({ baseUrl: 'http://localhost:4005' }); - - await expect( - client.transcribe({ audioUrl: 'https://blob.example.com/audio.mp3' }) - ).rejects.toThrow('Service unavailable'); - }); - }); - - describe('config options', () => { - it('passes getToken to createApiClient', async () => { - const { createApiClient } = await import('@bytelyst/api-client'); - const getToken = () => 'my-token'; - - createExtractionClient({ baseUrl: 'http://localhost:4005', getToken }); - - expect(createApiClient).toHaveBeenCalledWith({ - baseUrl: 'http://localhost:4005', - getToken, - }); - }); - - it('works without getToken', async () => { - const { createApiClient } = await import('@bytelyst/api-client'); - - createExtractionClient({ baseUrl: 'http://localhost:4005' }); - - expect(createApiClient).toHaveBeenCalledWith({ - baseUrl: 'http://localhost:4005', - getToken: undefined, - }); - }); - }); -}); - -describe('Extraction types', () => { - it('ExtractionEntity shape is correct', () => { - const entity: ExtractionEntity = { - extraction_class: 'person', - extraction_text: 'John Doe', - attributes: { role: 'engineer' }, - start_offset: 0, - end_offset: 8, - }; - expect(entity.extraction_class).toBe('person'); - expect(entity.attributes?.role).toBe('engineer'); - }); - - it('ExtractionExample shape is correct', () => { - const example: ExtractionExample = { - text: 'Meet Bob at 3pm', - extractions: [ - { extraction_class: 'person', extraction_text: 'Bob' }, - { extraction_class: 'time', extraction_text: '3pm' }, - ], - }; - expect(example.extractions).toHaveLength(2); - }); - - it('ExtractionClientConfig with optional getToken', () => { - const config1: ExtractionClientConfig = { baseUrl: 'http://localhost:4005' }; - expect(config1.getToken).toBeUndefined(); - - const config2: ExtractionClientConfig = { - baseUrl: 'http://localhost:4005', - getToken: () => 'tok', - }; - expect(config2.getToken?.()).toBe('tok'); - }); -}); diff --git a/vendor/bytelyst/extraction/src/client.ts b/vendor/bytelyst/extraction/src/client.ts deleted file mode 100644 index 7c105ad..0000000 --- a/vendor/bytelyst/extraction/src/client.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** - * Extraction service client factory. - * Uses @bytelyst/api-client under the hood for consistent auth token injection. - */ - -import { createApiClient } from '@bytelyst/api-client'; - -import type { - ExtractionClientConfig, - ExtractRequest, - ExtractResponse, - BatchExtractRequest, - BatchExtractResponse, - ExtractionTask, - TranscribeRequest, - TranscribeResponse, -} from './types.js'; - -export interface ExtractionClient { - /** Single document extraction. */ - extract(req: ExtractRequest): Promise; - - /** Batch extraction (multiple inputs, shared config). */ - extractBatch(req: BatchExtractRequest): Promise; - - /** List available extraction tasks. */ - listTasks(productId?: string): Promise; - - /** Get a single task by ID. */ - getTask(id: string, productId?: string): Promise; - - /** Transcribe audio from a URL via the configured STT provider. */ - transcribe(req: TranscribeRequest): Promise; -} - -/** - * Create a typed extraction service client. - * - * @example - * ```ts - * const client = createExtractionClient({ - * baseUrl: "http://localhost:4005", - * getToken: () => localStorage.getItem("access_token"), - * }); - * - * const result = await client.extract({ - * text: "John said we should ship by Friday.", - * taskId: "transcript-extraction", - * }); - * ``` - */ -export function createExtractionClient(config: ExtractionClientConfig): ExtractionClient { - const api = createApiClient({ - baseUrl: config.baseUrl, - getToken: config.getToken, - }); - - return { - async extract(req: ExtractRequest): Promise { - return api.fetch('/api/extract', { - method: 'POST', - body: JSON.stringify(req), - }); - }, - - async extractBatch(req: BatchExtractRequest): Promise { - return api.fetch('/api/extract/batch', { - method: 'POST', - body: JSON.stringify(req), - }); - }, - - async listTasks(productId?: string): Promise { - const qs = productId ? `?productId=${encodeURIComponent(productId)}` : ''; - return api.fetch(`/api/tasks${qs}`); - }, - - async getTask(id: string, productId?: string): Promise { - const qs = productId ? `?productId=${encodeURIComponent(productId)}` : ''; - return api.fetch(`/api/tasks/${encodeURIComponent(id)}${qs}`); - }, - - async transcribe(req: TranscribeRequest): Promise { - return api.fetch('/api/transcribe', { - method: 'POST', - body: JSON.stringify(req), - }); - }, - }; -} diff --git a/vendor/bytelyst/extraction/src/index.ts b/vendor/bytelyst/extraction/src/index.ts deleted file mode 100644 index e8dbffb..0000000 --- a/vendor/bytelyst/extraction/src/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -export { createExtractionClient } from './client.js'; -export type { ExtractionClient } from './client.js'; -export type { - ExtractionEntity, - ExtractionExample, - ExtractionTask, - ExtractRequest, - ExtractResponse, - BatchExtractRequest, - BatchExtractResponse, - TranscribeRequest, - TranscribeResponse, - ExtractionClientConfig, -} from './types.js'; diff --git a/vendor/bytelyst/extraction/src/types.ts b/vendor/bytelyst/extraction/src/types.ts deleted file mode 100644 index a3fbab3..0000000 --- a/vendor/bytelyst/extraction/src/types.ts +++ /dev/null @@ -1,110 +0,0 @@ -// ── Extraction types (shared across consumers) ───────────────── - -export interface ExtractionEntity { - extraction_class: string; - extraction_text: string; - attributes?: Record; - start_offset?: number; - end_offset?: number; -} - -export interface ExtractionExample { - text: string; - extractions: ExtractionEntity[]; -} - -export interface ExtractionTask { - id: string; - name: string; - description?: string; - prompt: string; - classes: string[]; - examples?: ExtractionExample[]; - defaultModelId?: string; - builtIn: boolean; - productId: string; - createdAt?: string; - updatedAt?: string; -} - -// ── Request / Response types ──────────────────────────────────── - -export interface ExtractRequest { - text: string; - taskId?: string; - taskPrompt?: string; - examples?: ExtractionExample[]; - modelId?: string; - options?: { - extractionPasses?: number; - maxWorkers?: number; - maxCharBuffer?: number; - }; - productId?: string; -} - -export interface ExtractResponse { - extractions: ExtractionEntity[]; - metadata: { - modelId: string; - durationMs: number; - tokenCount?: number; - charCount: number; - }; - requestId?: string; -} - -export interface BatchExtractRequest { - inputs: Array<{ - text: string; - taskId?: string; - taskPrompt?: string; - }>; - examples?: ExtractionExample[]; - modelId?: string; - productId?: string; -} - -export interface BatchExtractResponse { - results: ExtractResponse[]; - requestId?: string; -} - -// ── Transcription types ───────────────────────────────────────── - -export interface TranscribeRequest { - /** URL of the audio file (e.g. Azure Blob SAS URL). */ - audioUrl: string; - /** Override the Whisper model (default: whisper-1). */ - model?: string; - /** ISO 639-1 language hint (e.g. 'en', 'es'). Improves accuracy. */ - language?: string; - /** Optional prompt to guide the transcription style. */ - prompt?: string; - /** Response format: 'text' | 'json' | 'verbose_json'. */ - responseFormat?: 'text' | 'json' | 'verbose_json'; - /** Product ID for scoping / rate limiting. */ - productId?: string; -} - -export interface TranscribeResponse { - /** The transcribed text. */ - text: string; - /** Detected or specified language code. */ - language: string | null; - /** Duration of the audio in seconds (when available). */ - durationSeconds: number | null; - /** Whisper model used. */ - model: string; - /** Processing time in milliseconds. */ - durationMs: number; - /** Request ID for tracing. */ - requestId?: string; -} - -// ── Client config ─────────────────────────────────────────────── - -export interface ExtractionClientConfig { - baseUrl: string; - getToken?: () => string | null; -} diff --git a/vendor/bytelyst/extraction/tsconfig.json b/vendor/bytelyst/extraction/tsconfig.json deleted file mode 100644 index 318c075..0000000 --- a/vendor/bytelyst/extraction/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src", - "lib": ["ES2022", "DOM"] - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/vendor/bytelyst/fastify-auth/package.json b/vendor/bytelyst/fastify-auth/package.json deleted file mode 100644 index 59edb9b..0000000 --- a/vendor/bytelyst/fastify-auth/package.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "name": "@bytelyst/fastify-auth", - "version": "0.1.5", - "description": "JWT auth middleware + request context for Fastify product backends", - "type": "module", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" - } - }, - "scripts": { - "build": "tsc", - "typecheck": "tsc --noEmit", - "test": "vitest run --pool forks", - "clean": "rm -rf dist" - }, - "peerDependencies": { - "fastify": ">=5.0.0", - "jose": ">=5.0.0" - }, - "dependencies": { - "@bytelyst/errors": "workspace:*" - }, - "devDependencies": { - "fastify": "^5.2.1", - "jose": "^6.0.8", - "typescript": "^5.7.3", - "vitest": "^3.0.5" - }, - "files": [ - "dist" - ], - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/fastify-auth/src/auth.ts b/vendor/bytelyst/fastify-auth/src/auth.ts deleted file mode 100644 index 9828931..0000000 --- a/vendor/bytelyst/fastify-auth/src/auth.ts +++ /dev/null @@ -1,88 +0,0 @@ -/** - * Configurable JWT auth middleware — RS256 JWKS verification with HS256 fallback. - * - * Factory function creates extractAuth() and requireRole() bound to the - * provided config, eliminating the need for each product backend to maintain - * its own copy. - */ -import { jwtVerify, createRemoteJWKSet } from 'jose'; -import { UnauthorizedError, ForbiddenError } from '@bytelyst/errors'; -import type { AuthPayload, FastifyAuthOptions } from './types.js'; - -export function createAuthMiddleware(opts: FastifyAuthOptions) { - // Lazy-init JWKS client (cached, auto-refreshed by jose) - let jwks: ReturnType | null = null; - let cachedJwksUrl: string | undefined; - - function resolveJwksUrl(): string | undefined { - return typeof opts.jwksUrl === 'function' ? opts.jwksUrl() : opts.jwksUrl; - } - - function resolveJwtSecret(): string { - return typeof opts.jwtSecret === 'function' ? opts.jwtSecret() : opts.jwtSecret; - } - - function getJWKS(): ReturnType | null { - const url = resolveJwksUrl(); - if (!url) return null; - if (jwks && cachedJwksUrl === url) return jwks; - jwks = createRemoteJWKSet(new URL(url)); - cachedJwksUrl = url; - return jwks; - } - - function getHmacSecret(): Uint8Array { - return new TextEncoder().encode(resolveJwtSecret()); - } - - /** - * Extract and verify auth payload from an Authorization header. - * Tries RS256 via JWKS first, falls back to HS256. - */ - async function extractAuth(req: { headers: { authorization?: string } }): Promise { - const auth = req.headers.authorization; - if (!auth?.startsWith('Bearer ')) { - throw new UnauthorizedError(); - } - const token = auth.slice(7); - - // Try RS256 via JWKS first - const remoteJWKS = getJWKS(); - if (remoteJWKS) { - try { - const { payload } = await jwtVerify(token, remoteJWKS); - const p = payload as unknown as AuthPayload; - if (p.type !== 'access') throw new Error('Not an access token'); - return p; - } catch { - // Fall through to HS256 - } - } - - // Fall back to HS256 (existing behavior) - try { - const { payload } = await jwtVerify(token, getHmacSecret()); - const p = payload as unknown as AuthPayload; - if (p.type !== 'access') throw new Error('Not an access token'); - return p; - } catch { - throw new UnauthorizedError('Invalid or expired token'); - } - } - - /** - * Require specific roles. Extracts auth first, then checks role. - */ - async function requireRole( - req: { headers: { authorization?: string } }, - ...roles: string[] - ): Promise { - const payload = await extractAuth(req); - if (roles.length > 0 && (!payload.role || !roles.includes(payload.role))) { - throw new ForbiddenError('Insufficient permissions'); - } - return payload; - } - - return { extractAuth, requireRole }; -} diff --git a/vendor/bytelyst/fastify-auth/src/index.test.ts b/vendor/bytelyst/fastify-auth/src/index.test.ts deleted file mode 100644 index 23a0f11..0000000 --- a/vendor/bytelyst/fastify-auth/src/index.test.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { SignJWT } from 'jose'; -import { createAuthMiddleware, createRequestContext } from './index.js'; -import type { JwtPayload } from './types.js'; - -const TEST_SECRET = 'test-jwt-secret-for-fastify-auth-package'; - -interface MockReq { - headers: Record; - jwtPayload?: JwtPayload; -} - -function makeReq(token?: string): MockReq { - return { - headers: { - authorization: token ? `Bearer ${token}` : undefined, - }, - jwtPayload: undefined, - }; -} - -async function signToken(payload: Record, secret = TEST_SECRET) { - return new SignJWT(payload) - .setProtectedHeader({ alg: 'HS256' }) - .setIssuedAt() - .setExpirationTime('5m') - .sign(new TextEncoder().encode(secret)); -} - -describe('createAuthMiddleware', () => { - const { extractAuth, requireRole } = createAuthMiddleware({ - jwtSecret: TEST_SECRET, - }); - - it('extracts auth payload from valid access token', async () => { - const token = await signToken({ - sub: 'user-1', - email: 'test@test.com', - role: 'admin', - type: 'access', - }); - const payload = await extractAuth(makeReq(token)); - expect(payload.sub).toBe('user-1'); - expect(payload.email).toBe('test@test.com'); - expect(payload.role).toBe('admin'); - }); - - it('throws UnauthorizedError when no Authorization header', async () => { - await expect(extractAuth(makeReq())).rejects.toThrow('Unauthorized'); - }); - - it('throws UnauthorizedError for invalid token', async () => { - await expect(extractAuth(makeReq('bad-token'))).rejects.toThrow('Invalid or expired token'); - }); - - it('throws UnauthorizedError for non-access token type', async () => { - const token = await signToken({ sub: 'u1', type: 'refresh' }); - await expect(extractAuth(makeReq(token))).rejects.toThrow('Invalid or expired token'); - }); - - it('throws UnauthorizedError for wrong secret', async () => { - const token = await signToken({ sub: 'u1', type: 'access' }, 'wrong'); - await expect(extractAuth(makeReq(token))).rejects.toThrow('Invalid or expired token'); - }); - - it('requireRole passes when role matches', async () => { - const token = await signToken({ - sub: 'u1', - role: 'admin', - type: 'access', - }); - const payload = await requireRole(makeReq(token), 'admin', 'superadmin'); - expect(payload.sub).toBe('u1'); - }); - - it('requireRole throws ForbiddenError when role does not match', async () => { - const token = await signToken({ - sub: 'u1', - role: 'viewer', - type: 'access', - }); - await expect(requireRole(makeReq(token), 'admin')).rejects.toThrow('Insufficient permissions'); - }); - - it('requireRole passes with no required roles (any authenticated user)', async () => { - const token = await signToken({ - sub: 'u1', - type: 'access', - }); - const payload = await requireRole(makeReq(token)); - expect(payload.sub).toBe('u1'); - }); -}); - -describe('createRequestContext', () => { - const { getRequestProductId, getUserId } = createRequestContext({ - productId: 'testproduct', - }); - - function makeFastifyReq(overrides?: { - jwtPayload?: JwtPayload; - headers?: Record; - }) { - const req = makeReq(); - if (overrides?.jwtPayload !== undefined) req.jwtPayload = overrides.jwtPayload; - if (overrides?.headers) Object.assign(req.headers, overrides.headers); - return req as unknown as import('fastify').FastifyRequest; - } - - it('returns product ID for valid request', () => { - expect(getRequestProductId(makeFastifyReq())).toBe('testproduct'); - }); - - it('returns product ID when JWT productId matches', () => { - expect( - getRequestProductId(makeFastifyReq({ jwtPayload: { sub: 'u1', productId: 'testproduct' } })) - ).toBe('testproduct'); - }); - - it('throws BadRequestError when JWT productId does not match', () => { - expect(() => - getRequestProductId(makeFastifyReq({ jwtPayload: { sub: 'u1', productId: 'wrong' } })) - ).toThrow('Invalid productId'); - }); - - it('throws BadRequestError when X-Product-Id header does not match', () => { - expect(() => - getRequestProductId(makeFastifyReq({ headers: { 'x-product-id': 'wrong' } })) - ).toThrow('Invalid productId'); - }); - - it('getUserId returns sub from JWT payload', () => { - expect(getUserId(makeFastifyReq({ jwtPayload: { sub: 'user-42' } }))).toBe('user-42'); - }); - - it('getUserId throws when no JWT payload', () => { - expect(() => getUserId(makeFastifyReq())).toThrow('Missing userId'); - }); - - it('getUserId throws when JWT has no sub', () => { - expect(() => getUserId(makeFastifyReq({ jwtPayload: {} as JwtPayload }))).toThrow( - 'Missing userId' - ); - }); -}); diff --git a/vendor/bytelyst/fastify-auth/src/index.ts b/vendor/bytelyst/fastify-auth/src/index.ts deleted file mode 100644 index 903ec4c..0000000 --- a/vendor/bytelyst/fastify-auth/src/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { createAuthMiddleware } from './auth.js'; -export { createRequestContext } from './request-context.js'; -export type { - AuthPayload, - JwtPayload, - FastifyAuthOptions, - RequestContextOptions, -} from './types.js'; diff --git a/vendor/bytelyst/fastify-auth/src/request-context.ts b/vendor/bytelyst/fastify-auth/src/request-context.ts deleted file mode 100644 index 27eda5e..0000000 --- a/vendor/bytelyst/fastify-auth/src/request-context.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * Configurable request context helpers for Fastify product backends. - * - * Factory function creates getRequestProductId() and getUserId() bound to - * the provided product ID, eliminating hardcoded product IDs in each repo. - */ -import type { FastifyRequest } from 'fastify'; -import { BadRequestError } from '@bytelyst/errors'; -import type { RequestContextOptions } from './types.js'; - -export function createRequestContext(opts: RequestContextOptions) { - const { productId } = opts; - - /** - * Extract productId from request. Validates against this backend's product ID. - * Falls back to the configured productId since this is a product-specific backend. - */ - function getRequestProductId(req: FastifyRequest): string { - // 1. From JWT - const jwtPid = req.jwtPayload?.productId; - if (jwtPid && jwtPid !== productId) { - throw new BadRequestError(`Invalid productId: expected ${productId}, got ${jwtPid}`); - } - - // 2. From header - const header = req.headers['x-product-id']; - if (typeof header === 'string' && header.length > 0 && header !== productId) { - throw new BadRequestError(`Invalid productId: expected ${productId}, got ${header}`); - } - - return productId; - } - - /** - * Extract userId from the JWT payload on the request. - * Throws BadRequestError if no authenticated user is found. - */ - function getUserId(req: FastifyRequest): string { - const sub = req.jwtPayload?.sub; - if (!sub) { - throw new BadRequestError('Missing userId — request must be authenticated'); - } - return sub; - } - - return { getRequestProductId, getUserId }; -} diff --git a/vendor/bytelyst/fastify-auth/src/types.ts b/vendor/bytelyst/fastify-auth/src/types.ts deleted file mode 100644 index 32dd973..0000000 --- a/vendor/bytelyst/fastify-auth/src/types.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * JWT payload shape expected from platform-service tokens. - * Re-exported from @bytelyst/auth for convenience. - */ -export interface AuthPayload { - sub: string; - email?: string; - role?: string; - productId?: string; - type?: string; - iat?: number; - exp?: number; - iss?: string; -} - -/** JWT payload shape attached to req by the onRequest hook. */ -export interface JwtPayload { - sub: string; - email?: string; - role?: string; - productId?: string; - type?: string; -} - -/** Options for creating the auth middleware. */ -export interface FastifyAuthOptions { - /** HS256 symmetric secret for JWT verification. May be a getter for dynamic config. */ - jwtSecret: string | (() => string); - /** Optional RS256 JWKS endpoint URL (tried first, falls back to HS256). May be a getter. */ - jwksUrl?: string | (() => string | undefined); -} - -/** Options for creating the request context helpers. */ -export interface RequestContextOptions { - /** The product ID this backend serves (e.g. 'peakpulse', 'nomgap'). */ - productId: string; -} - -// Augment Fastify request to include parsed JWT payload -declare module 'fastify' { - interface FastifyRequest { - jwtPayload?: JwtPayload; - } -} diff --git a/vendor/bytelyst/fastify-auth/tsconfig.json b/vendor/bytelyst/fastify-auth/tsconfig.json deleted file mode 100644 index 01c4d9a..0000000 --- a/vendor/bytelyst/fastify-auth/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src", - "declaration": true - }, - "include": ["src"] -} diff --git a/vendor/bytelyst/fastify-core/package.json b/vendor/bytelyst/fastify-core/package.json deleted file mode 100644 index 20a6dfc..0000000 --- a/vendor/bytelyst/fastify-core/package.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "name": "@bytelyst/fastify-core", - "version": "0.1.5", - "type": "module", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks" - }, - "dependencies": { - "@bytelyst/errors": "workspace:*" - }, - "peerDependencies": { - "@fastify/cors": ">=10.0.0", - "@fastify/swagger": ">=9.0.0", - "fastify": ">=5.0.0", - "fastify-metrics": ">=10.0.0" - }, - "peerDependenciesMeta": { - "@fastify/swagger": { - "optional": true - }, - "fastify-metrics": { - "optional": true - } - }, - "devDependencies": { - "@fastify/swagger": "^9.7.0", - "@fastify/swagger-ui": "^5.2.5", - "fastify-metrics": "^10.6.0" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/fastify-core/src/__tests__/fastify-core.test.ts b/vendor/bytelyst/fastify-core/src/__tests__/fastify-core.test.ts deleted file mode 100644 index 77e6233..0000000 --- a/vendor/bytelyst/fastify-core/src/__tests__/fastify-core.test.ts +++ /dev/null @@ -1,374 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { createServiceApp, registerOptionalJwtContext, startService } from '../index.js'; - -type JwtRequest = { jwtPayload?: unknown }; - -describe('createServiceApp', () => { - it('returns a Fastify instance', async () => { - const app = await createServiceApp({ - name: 'test-service', - version: '0.0.1', - logger: false, - }); - expect(app).toBeDefined(); - expect(typeof app.listen).toBe('function'); - expect(typeof app.get).toBe('function'); - await app.close(); - }); - - it('has a /health endpoint returning correct shape', async () => { - const app = await createServiceApp({ - name: 'my-service', - version: '1.2.3', - description: 'A test service', - logger: false, - }); - - const res = await app.inject({ method: 'GET', url: '/health' }); - expect(res.statusCode).toBe(200); - - const body = JSON.parse(res.payload); - expect(body.status).toBe('ok'); - expect(body.service).toBe('my-service'); - expect(body.version).toBe('1.2.3'); - expect(body.description).toBe('A test service'); - expect(body.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/); - expect(body.requestId).toBe(res.headers['x-request-id']); - - await app.close(); - }); - - it('propagates x-request-id header', async () => { - const app = await createServiceApp({ - name: 'test', - version: '0.1.0', - logger: false, - }); - - const customId = 'req-12345'; - const res = await app.inject({ - method: 'GET', - url: '/health', - headers: { 'x-request-id': customId }, - }); - - expect(res.headers['x-request-id']).toBe(customId); - const body = JSON.parse(res.payload); - expect(body.requestId).toBe(customId); - - await app.close(); - }); - - it('generates x-request-id when not provided', async () => { - const app = await createServiceApp({ - name: 'test', - version: '0.1.0', - logger: false, - }); - - const res = await app.inject({ method: 'GET', url: '/health' }); - expect(res.headers['x-request-id']).toBeTruthy(); - // Should be UUID format - expect(res.headers['x-request-id']).toMatch( - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/ - ); - const body = JSON.parse(res.payload); - expect(body.requestId).toBe(res.headers['x-request-id']); - - await app.close(); - }); - - it('handles ServiceError with correct status code', async () => { - const { NotFoundError } = await import('@bytelyst/errors'); - const app = await createServiceApp({ - name: 'test', - version: '0.1.0', - logger: false, - }); - - app.get('/fail', async () => { - throw new NotFoundError('User not found'); - }); - - const res = await app.inject({ method: 'GET', url: '/fail' }); - expect(res.statusCode).toBe(404); - const body = JSON.parse(res.payload); - expect(body.error).toBe('User not found'); - expect(body.requestId).toBe(res.headers['x-request-id']); - - await app.close(); - }); - - it('handles ServiceError with details', async () => { - const { BadRequestError } = await import('@bytelyst/errors'); - const app = await createServiceApp({ - name: 'test', - version: '0.1.0', - logger: false, - }); - - app.get('/bad', async () => { - throw new BadRequestError('Validation failed', { field: 'email' }); - }); - - const res = await app.inject({ method: 'GET', url: '/bad' }); - expect(res.statusCode).toBe(400); - const body = JSON.parse(res.payload); - expect(body.error).toBe('Validation failed'); - expect(body.details).toEqual({ field: 'email' }); - expect(body.requestId).toBe(res.headers['x-request-id']); - - await app.close(); - }); - - it('handles unknown errors as 500', async () => { - const app = await createServiceApp({ - name: 'test', - version: '0.1.0', - logger: false, - }); - - app.get('/crash', async () => { - throw new Error('unexpected'); - }); - - const res = await app.inject({ method: 'GET', url: '/crash' }); - expect(res.statusCode).toBe(500); - const body = JSON.parse(res.payload); - expect(body.error).toBe('Internal server error'); - expect(body.requestId).toBe(res.headers['x-request-id']); - - await app.close(); - }); - - it('omits description from health when not provided', async () => { - const app = await createServiceApp({ - name: 'minimal', - version: '0.1.0', - logger: false, - }); - - const res = await app.inject({ method: 'GET', url: '/health' }); - const body = JSON.parse(res.payload); - expect(body.description).toBeUndefined(); - - await app.close(); - }); - - it('supports readiness endpoint and returns not_ready until enabled', async () => { - const app = await createServiceApp({ - name: 'ready-test', - version: '1.0.0', - readiness: true, - logger: false, - }); - - const before = await app.inject({ method: 'GET', url: '/ready' }); - expect(before.statusCode).toBe(503); - expect(JSON.parse(before.payload)).toMatchObject({ - status: 'not_ready', - service: 'ready-test', - version: '1.0.0', - requestId: before.headers['x-request-id'], - }); - - app.setReadyState(true); - - const after = await app.inject({ method: 'GET', url: '/ready' }); - expect(after.statusCode).toBe(200); - expect(JSON.parse(after.payload)).toMatchObject({ - status: 'ready', - service: 'ready-test', - version: '1.0.0', - requestId: after.headers['x-request-id'], - }); - - await app.close(); - }); - - it('supports custom readiness path and initial readiness state', async () => { - const app = await createServiceApp({ - name: 'custom-ready', - version: '1.0.0', - readiness: { path: '/status/ready', initialReady: true }, - logger: false, - }); - - const res = await app.inject({ method: 'GET', url: '/status/ready' }); - expect(res.statusCode).toBe(200); - expect(JSON.parse(res.payload)).toMatchObject({ - status: 'ready', - service: 'custom-ready', - }); - - await app.close(); - }); - - it('can omit requestId from error responses when configured', async () => { - const { BadRequestError } = await import('@bytelyst/errors'); - const app = await createServiceApp({ - name: 'error-config', - version: '1.0.0', - logger: false, - errorResponses: { includeRequestId: false }, - }); - - app.get('/bad', async () => { - throw new BadRequestError('No request id please'); - }); - - const res = await app.inject({ method: 'GET', url: '/bad' }); - expect(res.statusCode).toBe(400); - const body = JSON.parse(res.payload); - expect(body.error).toBe('No request id please'); - expect(body.requestId).toBeUndefined(); - - await app.close(); - }); -}); - -describe('registerOptionalJwtContext', () => { - it('attaches jwtPayload when bearer token verification succeeds', async () => { - const app = await createServiceApp({ - name: 'jwt-context', - version: '1.0.0', - logger: false, - }); - - await registerOptionalJwtContext(app, { - verifyToken: async token => ({ sub: `user:${token}`, role: 'admin' }), - }); - - app.get('/secure', async req => ({ jwtPayload: (req as typeof req & JwtRequest).jwtPayload })); - - const res = await app.inject({ - method: 'GET', - url: '/secure', - headers: { authorization: 'Bearer abc123' }, - }); - - expect(res.statusCode).toBe(200); - expect(JSON.parse(res.payload)).toEqual({ - jwtPayload: { sub: 'user:abc123', role: 'admin' }, - }); - - await app.close(); - }); - - it('swallows verification errors by default for optional auth', async () => { - const app = await createServiceApp({ - name: 'jwt-optional', - version: '1.0.0', - logger: false, - }); - - await registerOptionalJwtContext(app, { - verifyToken: async () => { - throw new Error('invalid token'); - }, - }); - - app.get('/secure', async req => ({ - jwtPayload: (req as typeof req & JwtRequest).jwtPayload ?? null, - })); - - const res = await app.inject({ - method: 'GET', - url: '/secure', - headers: { authorization: 'Bearer broken' }, - }); - - expect(res.statusCode).toBe(200); - expect(JSON.parse(res.payload)).toEqual({ jwtPayload: null }); - - await app.close(); - }); - - it('invokes onError callback when token verification fails', async () => { - const app = await createServiceApp({ - name: 'jwt-onerror', - version: '1.0.0', - logger: false, - }); - - const onError = vi.fn(); - - await registerOptionalJwtContext(app, { - verifyToken: async () => { - throw new Error('bad token'); - }, - onError, - }); - - app.get('/secure', async req => ({ - jwtPayload: (req as typeof req & JwtRequest).jwtPayload ?? null, - })); - - const res = await app.inject({ - method: 'GET', - url: '/secure', - headers: { authorization: 'Bearer broken' }, - }); - - expect(res.statusCode).toBe(200); - expect(onError).toHaveBeenCalledOnce(); - - await app.close(); - }); -}); - -describe('startService', () => { - it('sets readiness state after successful listen', async () => { - const app = await createServiceApp({ - name: 'start-ready', - version: '1.0.0', - readiness: true, - logger: false, - }); - - const listenMock = vi.fn(async () => 'http://127.0.0.1:9999'); - const closeMock = vi.fn(async () => undefined); - const infoMock = vi.fn(); - const errorMock = vi.fn(); - - app.listen = listenMock as typeof app.listen; - app.close = closeMock as unknown as typeof app.close; - app.log.info = infoMock as typeof app.log.info; - app.log.error = errorMock as typeof app.log.error; - - expect(app.isReadyState()).toBe(false); - - await startService(app, { port: 9999, registerSignalHandlers: false, exitOnFatal: false }); - - expect(listenMock).toHaveBeenCalledWith({ port: 9999, host: '0.0.0.0' }); - expect(app.isReadyState()).toBe(true); - expect(errorMock).not.toHaveBeenCalled(); - - await app.close(); - }); - - it('throws startup error instead of exiting when exitOnFatal is false', async () => { - const app = await createServiceApp({ - name: 'start-fail', - version: '1.0.0', - logger: false, - }); - - const startupError = new Error('listen failed'); - const listenMock = vi.fn(async () => { - throw startupError; - }); - const errorMock = vi.fn(); - - app.listen = listenMock as typeof app.listen; - app.log.error = errorMock as typeof app.log.error; - - await expect( - startService(app, { port: 9999, registerSignalHandlers: false, exitOnFatal: false }) - ).rejects.toThrow('listen failed'); - - expect(errorMock).toHaveBeenCalledWith(startupError); - - await app.close(); - }); -}); diff --git a/vendor/bytelyst/fastify-core/src/auth.ts b/vendor/bytelyst/fastify-core/src/auth.ts deleted file mode 100644 index a443989..0000000 --- a/vendor/bytelyst/fastify-core/src/auth.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { FastifyRequest } from 'fastify'; -import type { FastifyApp } from './types.js'; - -interface JwtCarrier { - jwtPayload?: unknown; -} - -export interface OptionalJwtContextOptions { - onError?: (error: unknown, request: FastifyRequest) => Promise | void; - verifyToken: (token: string, request: FastifyRequest) => Promise | TPayload; -} - -export async function registerOptionalJwtContext( - app: FastifyApp, - options: OptionalJwtContextOptions -): Promise { - const { verifyToken, onError } = options; - - app.addHook('onRequest', async req => { - const auth = req.headers.authorization; - if (!auth?.startsWith('Bearer ')) return; - - try { - const payload = await verifyToken(auth.slice(7), req); - (req as FastifyRequest & JwtCarrier).jwtPayload = payload; - } catch (error) { - if (onError) { - await onError(error, req); - } - } - }); -} diff --git a/vendor/bytelyst/fastify-core/src/create-app.ts b/vendor/bytelyst/fastify-core/src/create-app.ts deleted file mode 100644 index 4c4ad6d..0000000 --- a/vendor/bytelyst/fastify-core/src/create-app.ts +++ /dev/null @@ -1,171 +0,0 @@ -/** - * Factory for creating a Fastify service app with standard middleware. - * - * Includes: CORS, x-request-id propagation, health endpoint, ServiceError handler. - */ - -import { randomUUID } from 'node:crypto'; -import Fastify from 'fastify'; -import cors from '@fastify/cors'; -import { ServiceError } from '@bytelyst/errors'; -import type { ServiceAppOptions, FastifyApp } from './types.js'; - -/** - * Create a Fastify app preconfigured with common middleware. - * - * @example - * ```ts - * const app = await createServiceApp({ - * name: "platform-service", - * version: "0.1.0", - * description: "Auth, audit, flags, notifications", - * corsOrigin: process.env.CORS_ORIGIN, - * }); - * await app.register(authRoutes, { prefix: "/api" }); - * await startService(app, { port: 4003 }); - * ``` - */ -export async function createServiceApp(options: ServiceAppOptions): Promise { - const { - name, - version, - description, - corsOrigin, - logger = true, - logLevel, - swagger, - metrics, - readiness, - errorResponses, - optionalPluginFailure = 'warn', - } = options; - - const app = Fastify({ - logger: logger ? (logLevel ? { level: logLevel } : true) : false, - }) as unknown as FastifyApp; - - let readyState = typeof readiness === 'object' ? (readiness.initialReady ?? false) : false; - const readinessPath = typeof readiness === 'object' ? (readiness.path ?? '/ready') : '/ready'; - const includeRequestIdInErrors = errorResponses?.includeRequestId ?? true; - - app.setReadyState = (ready: boolean) => { - readyState = ready; - }; - - app.isReadyState = () => readyState; - - // CORS — deny all origins when CORS_ORIGIN is not explicitly set - const origin = corsOrigin ? corsOrigin.split(',').map(o => o.trim()) : false; - await app.register(cors, { origin }); - - // OpenAPI spec (optional — consumer must have @fastify/swagger installed) - if (swagger) { - try { - const swaggerPlugin = (await import('@fastify/swagger')).default; - await app.register(swaggerPlugin, { - openapi: { - info: { - title: swagger.title, - version, - ...(swagger.description && { description: swagger.description }), - }, - ...(swagger.port && { servers: [{ url: `http://localhost:${swagger.port}` }] }), - }, - }); - } catch (error) { - if (optionalPluginFailure === 'throw') { - throw error; - } - app.log.warn( - { err: error }, - 'Optional plugin @fastify/swagger not available; skipping OpenAPI registration' - ); - } - - // Swagger UI — serves interactive API docs at /documentation - try { - const swaggerUiPlugin = (await import('@fastify/swagger-ui')).default; - await app.register(swaggerUiPlugin, { - routePrefix: '/documentation', - uiConfig: { docExpansion: 'list', deepLinking: true }, - }); - } catch (error) { - if (optionalPluginFailure === 'throw') { - throw error; - } - app.log.warn( - { err: error }, - 'Optional plugin @fastify/swagger-ui not available; skipping Swagger UI registration' - ); - } - } - - // Prometheus metrics (optional — consumer must have fastify-metrics installed) - if (metrics) { - try { - const metricsMod = await import('fastify-metrics'); - const plugin = metricsMod.default as unknown as Parameters[0]; - await app.register(plugin, { endpoint: '/metrics' }); - } catch (error) { - if (optionalPluginFailure === 'throw') { - throw error; - } - app.log.warn( - { err: error }, - 'Optional plugin fastify-metrics not available; skipping metrics registration' - ); - } - } - - // x-request-id propagation - app.addHook('onRequest', async (req, reply) => { - const requestId = (req.headers['x-request-id'] as string) || randomUUID(); - req.headers['x-request-id'] = requestId; - reply.header('x-request-id', requestId); - req.log = req.log.child({ requestId }); - }); - - // Health check - app.get('/health', async req => ({ - status: 'ok', - service: name, - version, - ...(description && { description }), - timestamp: new Date().toISOString(), - requestId: req.headers['x-request-id'], - })); - - if (readiness) { - app.get(readinessPath, async (req, reply) => { - if (!app.isReadyState()) { - reply.code(503); - } - - return { - status: app.isReadyState() ? 'ready' : 'not_ready', - service: name, - version, - ...(description && { description }), - timestamp: new Date().toISOString(), - requestId: req.headers['x-request-id'], - }; - }); - } - - // ServiceError-aware error handler - app.setErrorHandler((error, req, reply) => { - if (error instanceof ServiceError) { - const body: Record = { error: error.message }; - if (error.details) body.details = error.details; - if (includeRequestIdInErrors) body.requestId = req.headers['x-request-id']; - reply.code(error.statusCode).send(body); - return; - } - app.log.error(error); - const body: Record = { error: 'Internal server error' }; - if (includeRequestIdInErrors) body.requestId = req.headers['x-request-id']; - reply.code(500).send(body); - }); - - return app; -} diff --git a/vendor/bytelyst/fastify-core/src/index.ts b/vendor/bytelyst/fastify-core/src/index.ts deleted file mode 100644 index e198e58..0000000 --- a/vendor/bytelyst/fastify-core/src/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { createServiceApp } from './create-app.js'; -export { registerOptionalJwtContext } from './auth.js'; -export { startService } from './start.js'; -export type { ServiceAppOptions, StartOptions, FastifyApp } from './types.js'; diff --git a/vendor/bytelyst/fastify-core/src/start.ts b/vendor/bytelyst/fastify-core/src/start.ts deleted file mode 100644 index a4054d7..0000000 --- a/vendor/bytelyst/fastify-core/src/start.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** - * Service startup helper — starts the app and logs the address. - */ - -import type { FastifyApp, StartOptions } from './types.js'; - -const registeredSignalHandlers = new Map>(); - -export async function startService(app: FastifyApp, options: StartOptions): Promise { - const { port, host = '0.0.0.0', exitOnFatal = true, registerSignalHandlers = true } = options; - - // Graceful shutdown on SIGTERM/SIGINT (Docker, K8s, Ctrl-C) - if (registerSignalHandlers) { - for (const signal of ['SIGTERM', 'SIGINT'] as const) { - const appsForSignal = registeredSignalHandlers.get(signal) ?? new WeakSet(); - - if (!appsForSignal.has(app)) { - appsForSignal.add(app); - registeredSignalHandlers.set(signal, appsForSignal); - - process.once(signal, async () => { - app.log.info(`Received ${signal}, shutting down gracefully…`); - await app.close(); - if (exitOnFatal) { - process.exit(0); - } - }); - } - } - } - - try { - await app.listen({ port, host }); - if (typeof app.setReadyState === 'function') { - app.setReadyState(true); - } - app.log.info(`Service listening on ${host}:${port}`); - } catch (err) { - app.log.error(err); - if (exitOnFatal) { - process.exit(1); - } - throw err; - } -} diff --git a/vendor/bytelyst/fastify-core/src/types.ts b/vendor/bytelyst/fastify-core/src/types.ts deleted file mode 100644 index 0b2d5cc..0000000 --- a/vendor/bytelyst/fastify-core/src/types.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { FastifyInstance } from 'fastify'; - -export interface SwaggerOptions { - title: string; - description?: string; - port?: number; -} - -export interface ReadinessOptions { - path?: string; - initialReady?: boolean; -} - -export interface ErrorResponseOptions { - includeRequestId?: boolean; -} - -export interface ServiceAppOptions { - name: string; - version: string; - description?: string; - corsOrigin?: string; - logger?: boolean; - logLevel?: 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal'; - swagger?: SwaggerOptions; - metrics?: boolean; - readiness?: boolean | ReadinessOptions; - errorResponses?: ErrorResponseOptions; - optionalPluginFailure?: 'warn' | 'throw'; -} - -export interface StartOptions { - port: number; - host?: string; - exitOnFatal?: boolean; - registerSignalHandlers?: boolean; -} - -export type FastifyApp = FastifyInstance & { - setReadyState: (ready: boolean) => void; - isReadyState: () => boolean; -}; diff --git a/vendor/bytelyst/fastify-core/tsconfig.json b/vendor/bytelyst/fastify-core/tsconfig.json deleted file mode 100644 index 5edad81..0000000 --- a/vendor/bytelyst/fastify-core/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/vendor/bytelyst/fastify-sse/package.json b/vendor/bytelyst/fastify-sse/package.json deleted file mode 100644 index 279e2e8..0000000 --- a/vendor/bytelyst/fastify-sse/package.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "@bytelyst/fastify-sse", - "version": "0.3.5", - "description": "Fastify plugin for Server-Sent Events (SSE) — real-time push for ByteLyst product backends", - "type": "module", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks" - }, - "peerDependencies": { - "fastify": "^5.0.0" - }, - "devDependencies": { - "vitest": "^3.0.0" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/fastify-sse/src/hub.test.ts b/vendor/bytelyst/fastify-sse/src/hub.test.ts deleted file mode 100644 index fbfccb7..0000000 --- a/vendor/bytelyst/fastify-sse/src/hub.test.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { SSEHub } from './hub.js'; -import { EventEmitter } from 'node:events'; -import type { ServerResponse } from 'node:http'; - -function mockResponse(): ServerResponse { - const emitter = new EventEmitter(); - const chunks: string[] = []; - const res = Object.assign(emitter, { - writeHead: vi.fn(), - write: vi.fn((chunk: string) => { - chunks.push(chunk); - return true; - }), - end: vi.fn(), - _chunks: chunks, - }); - return res as unknown as ServerResponse; -} - -describe('SSEHub', () => { - let hub: SSEHub; - - beforeEach(() => { - hub = new SSEHub(); - }); - - describe('addClient', () => { - it('returns a client ID and sets SSE headers', () => { - const res = mockResponse(); - const id = hub.addClient(res); - expect(id).toMatch(/^sse_/); - expect(res.writeHead).toHaveBeenCalledWith( - 200, - expect.objectContaining({ - 'Content-Type': 'text/event-stream', - }) - ); - expect(hub.clientCount).toBe(1); - }); - - it('sends initial connected event', () => { - const res = mockResponse(); - hub.addClient(res); - expect(res.write).toHaveBeenCalledWith(expect.stringContaining('event: connected')); - }); - - it('removes client on close', () => { - const res = mockResponse(); - hub.addClient(res); - expect(hub.clientCount).toBe(1); - res.emit('close'); - expect(hub.clientCount).toBe(0); - }); - }); - - describe('broadcast', () => { - it('sends to all connected clients', () => { - const res1 = mockResponse(); - const res2 = mockResponse(); - hub.addClient(res1); - hub.addClient(res2); - - const sent = hub.broadcast({ event: 'test', data: '{"hello":true}' }); - expect(sent).toBe(2); - // Each res gets: initial connected write + broadcast write = 2 calls - expect(res1.write).toHaveBeenCalledTimes(2); - expect(res2.write).toHaveBeenCalledTimes(2); - }); - - it('returns 0 when no clients', () => { - const sent = hub.broadcast({ data: 'test' }); - expect(sent).toBe(0); - }); - - it('removes clients that throw on write', () => { - const res1 = mockResponse(); - const res2 = mockResponse(); - hub.addClient(res1); - hub.addClient(res2); - - // Make res1 throw on next write - (res1.write as ReturnType).mockImplementationOnce(() => { - throw new Error('broken'); - }); - - hub.broadcast({ data: 'test' }); - // res1 should be removed after the error - expect(hub.clientCount).toBe(1); - }); - }); - - describe('sendToUser', () => { - it('sends only to matching userId', () => { - const res1 = mockResponse(); - const res2 = mockResponse(); - hub.addClient(res1, 'user-a'); - hub.addClient(res2, 'user-b'); - - const sent = hub.sendToUser('user-a', { data: 'targeted' }); - expect(sent).toBe(1); - // res1: connected + targeted = 2 writes - expect(res1.write).toHaveBeenCalledTimes(2); - // res2: only connected = 1 write - expect(res2.write).toHaveBeenCalledTimes(1); - }); - - it('returns 0 when no matching users', () => { - const res = mockResponse(); - hub.addClient(res, 'user-a'); - const sent = hub.sendToUser('user-z', { data: 'nothing' }); - expect(sent).toBe(0); - }); - }); - - describe('heartbeat', () => { - it('sends comment to all clients', () => { - const res = mockResponse(); - hub.addClient(res); - hub.heartbeat(); - expect(res.write).toHaveBeenCalledWith(': heartbeat\n\n'); - }); - }); - - describe('disconnectAll', () => { - it('ends all client responses and clears', () => { - const res1 = mockResponse(); - const res2 = mockResponse(); - hub.addClient(res1); - hub.addClient(res2); - expect(hub.clientCount).toBe(2); - - hub.disconnectAll(); - expect(hub.clientCount).toBe(0); - expect(res1.end).toHaveBeenCalled(); - expect(res2.end).toHaveBeenCalled(); - }); - }); - - describe('formatSSE', () => { - it('formats with event, id, and data', () => { - const res = mockResponse(); - hub.addClient(res); - hub.broadcast({ event: 'task.created', data: '{}', id: 'evt_1' }); - - const lastWrite = (res.write as ReturnType).mock.calls.at(-1)?.[0] as string; - expect(lastWrite).toContain('id: evt_1'); - expect(lastWrite).toContain('event: task.created'); - expect(lastWrite).toContain('data: {}'); - expect(lastWrite).toMatch(/\n\n$/); - }); - - it('formats with retry field', () => { - const res = mockResponse(); - hub.addClient(res); - hub.broadcast({ data: 'test', retry: 5000 }); - - const lastWrite = (res.write as ReturnType).mock.calls.at(-1)?.[0] as string; - expect(lastWrite).toContain('retry: 5000'); - }); - }); -}); diff --git a/vendor/bytelyst/fastify-sse/src/hub.ts b/vendor/bytelyst/fastify-sse/src/hub.ts deleted file mode 100644 index 7436946..0000000 --- a/vendor/bytelyst/fastify-sse/src/hub.ts +++ /dev/null @@ -1,143 +0,0 @@ -/** - * SSE Hub — manages connected clients and broadcasts events. - * Product backends create an SSEHub instance and push events to it; - * the hub fans out to all connected SSE clients. - */ - -import type { ServerResponse } from 'node:http'; - -export interface SSEMessage { - event?: string; - data: string; - id?: string; - retry?: number; -} - -interface ConnectedClient { - id: string; - userId?: string; - res: ServerResponse; - connectedAt: string; -} - -export class SSEHub { - private clients = new Map(); - private clientCounter = 0; - - /** - * Add a new SSE client connection. - * Sets up the SSE headers and returns a client ID. - */ - addClient(res: ServerResponse, userId?: string): string { - const id = `sse_${++this.clientCounter}_${Date.now()}`; - - res.writeHead(200, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', - 'X-Accel-Buffering': 'no', - }); - - // Send initial connection event - res.write(`event: connected\ndata: ${JSON.stringify({ clientId: id })}\n\n`); - - const client: ConnectedClient = { - id, - userId, - res, - connectedAt: new Date().toISOString(), - }; - - this.clients.set(id, client); - - // Clean up on close - res.on('close', () => { - this.clients.delete(id); - }); - - return id; - } - - /** - * Broadcast an SSE message to all connected clients. - */ - broadcast(message: SSEMessage): number { - let sent = 0; - const formatted = formatSSE(message); - - for (const [id, client] of this.clients) { - try { - client.res.write(formatted); - sent++; - } catch { - this.clients.delete(id); - } - } - - return sent; - } - - /** - * Send an SSE message to a specific user's connections. - */ - sendToUser(userId: string, message: SSEMessage): number { - let sent = 0; - const formatted = formatSSE(message); - - for (const [id, client] of this.clients) { - if (client.userId === userId) { - try { - client.res.write(formatted); - sent++; - } catch { - this.clients.delete(id); - } - } - } - - return sent; - } - - /** - * Send a heartbeat (comment) to all clients to keep connections alive. - */ - heartbeat(): void { - for (const [id, client] of this.clients) { - try { - client.res.write(': heartbeat\n\n'); - } catch { - this.clients.delete(id); - } - } - } - - /** - * Get count of connected clients. - */ - get clientCount(): number { - return this.clients.size; - } - - /** - * Disconnect all clients. - */ - disconnectAll(): void { - for (const [, client] of this.clients) { - try { - client.res.end(); - } catch { - /* already closed */ - } - } - this.clients.clear(); - } -} - -function formatSSE(message: SSEMessage): string { - let output = ''; - if (message.id) output += `id: ${message.id}\n`; - if (message.event) output += `event: ${message.event}\n`; - if (message.retry) output += `retry: ${message.retry}\n`; - output += `data: ${message.data}\n\n`; - return output; -} diff --git a/vendor/bytelyst/fastify-sse/src/index.ts b/vendor/bytelyst/fastify-sse/src/index.ts deleted file mode 100644 index a5f3728..0000000 --- a/vendor/bytelyst/fastify-sse/src/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { SSEHub } from './hub.js'; -export { ssePlugin } from './plugin.js'; -export type { SSEPluginOptions, SSEClient } from './plugin.js'; -export { startSSE, sendSSEData, sendSSEEvent, endSSE } from './per-request.js'; diff --git a/vendor/bytelyst/fastify-sse/src/per-request.test.ts b/vendor/bytelyst/fastify-sse/src/per-request.test.ts deleted file mode 100644 index ca21f88..0000000 --- a/vendor/bytelyst/fastify-sse/src/per-request.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import { startSSE, sendSSEData, sendSSEEvent, endSSE } from './per-request.js'; -import type { FastifyReply } from 'fastify'; - -function mockReply(): FastifyReply { - const chunks: string[] = []; - return { - raw: { - writeHead: vi.fn(), - write: vi.fn((chunk: string) => { - chunks.push(chunk); - return true; - }), - end: vi.fn(), - _chunks: chunks, - }, - hijack: vi.fn(), - } as unknown as FastifyReply; -} - -describe('per-request SSE helpers', () => { - describe('startSSE', () => { - it('sets correct SSE headers', () => { - const reply = mockReply(); - startSSE(reply); - expect(reply.raw.writeHead).toHaveBeenCalledWith(200, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', - 'X-Accel-Buffering': 'no', - }); - }); - - it('hijacks the reply', () => { - const reply = mockReply(); - startSSE(reply); - expect(reply.hijack).toHaveBeenCalled(); - }); - }); - - describe('sendSSEData', () => { - it('formats object data as JSON', () => { - const reply = mockReply(); - sendSSEData(reply, { hello: true }); - expect(reply.raw.write).toHaveBeenCalledWith('data: {"hello":true}\n\n'); - }); - - it('formats string data without double-encoding', () => { - const reply = mockReply(); - sendSSEData(reply, 'plain text'); - expect(reply.raw.write).toHaveBeenCalledWith('data: plain text\n\n'); - }); - - it('formats number data as JSON', () => { - const reply = mockReply(); - sendSSEData(reply, 42); - expect(reply.raw.write).toHaveBeenCalledWith('data: 42\n\n'); - }); - }); - - describe('sendSSEEvent', () => { - it('formats named event with object data', () => { - const reply = mockReply(); - sendSSEEvent(reply, 'token', { text: 'hi' }); - expect(reply.raw.write).toHaveBeenCalledWith('event: token\ndata: {"text":"hi"}\n\n'); - }); - - it('formats named event with string data', () => { - const reply = mockReply(); - sendSSEEvent(reply, 'status', 'done'); - expect(reply.raw.write).toHaveBeenCalledWith('event: status\ndata: done\n\n'); - }); - }); - - describe('endSSE', () => { - it('sends the [DONE] sentinel', () => { - const reply = mockReply(); - endSSE(reply); - expect(reply.raw.write).toHaveBeenCalledWith('data: [DONE]\n\n'); - }); - - it('ends the stream', () => { - const reply = mockReply(); - endSSE(reply); - expect(reply.raw.end).toHaveBeenCalled(); - }); - }); -}); diff --git a/vendor/bytelyst/fastify-sse/src/per-request.ts b/vendor/bytelyst/fastify-sse/src/per-request.ts deleted file mode 100644 index d1c8dc7..0000000 --- a/vendor/bytelyst/fastify-sse/src/per-request.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Per-request SSE helpers — single-request streaming pattern. - * Used by chat streaming and model comparison endpoints where - * one route handler streams SSE to one client. - */ - -import type { FastifyReply } from 'fastify'; - -export function startSSE(reply: FastifyReply): void { - reply.raw.writeHead(200, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', - 'X-Accel-Buffering': 'no', - }); - reply.hijack(); -} - -export function sendSSEEvent(reply: FastifyReply, event: string, data: unknown): void { - const payload = typeof data === 'string' ? data : JSON.stringify(data); - reply.raw.write(`event: ${event}\ndata: ${payload}\n\n`); -} - -export function sendSSEData(reply: FastifyReply, data: unknown): void { - const payload = typeof data === 'string' ? data : JSON.stringify(data); - reply.raw.write(`data: ${payload}\n\n`); -} - -export function endSSE(reply: FastifyReply): void { - reply.raw.write('data: [DONE]\n\n'); - reply.raw.end(); -} diff --git a/vendor/bytelyst/fastify-sse/src/plugin.ts b/vendor/bytelyst/fastify-sse/src/plugin.ts deleted file mode 100644 index 1c12d64..0000000 --- a/vendor/bytelyst/fastify-sse/src/plugin.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * Fastify plugin that registers an SSE endpoint. - * Product backends configure the path and optional auth check. - */ - -import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; -import { SSEHub } from './hub.js'; - -export interface SSEClient { - id: string; - userId?: string; -} - -export interface SSEPluginOptions { - /** Route path for the SSE endpoint (default: /events/stream) */ - path?: string; - /** Extract userId from request (optional, enables per-user targeting) */ - getUserId?: (req: FastifyRequest) => string | undefined; - /** Heartbeat interval in ms (default: 30000, set 0 to disable) */ - heartbeatIntervalMs?: number; - /** The SSE hub instance to use (creates one if not provided) */ - hub?: SSEHub; -} - -export async function ssePlugin( - app: FastifyInstance, - options: SSEPluginOptions = {} -): Promise { - const path = options.path ?? '/events/stream'; - const heartbeatMs = options.heartbeatIntervalMs ?? 30_000; - const hub = options.hub ?? new SSEHub(); - - // Decorate app with the hub so routes can push events - if (!app.hasDecorator('sseHub')) { - app.decorate('sseHub', hub); - } - - // Register SSE endpoint - app.get(path, async (req: FastifyRequest, reply: FastifyReply) => { - const userId = options.getUserId?.(req); - - // Hijack the raw response for SSE streaming - const raw = reply.raw; - hub.addClient(raw, userId); - - // Prevent Fastify from sending its own response - reply.hijack(); - }); - - // Heartbeat timer - let heartbeatTimer: ReturnType | undefined; - if (heartbeatMs > 0) { - heartbeatTimer = setInterval(() => hub.heartbeat(), heartbeatMs); - } - - // Cleanup on close - app.addHook('onClose', async () => { - if (heartbeatTimer) clearInterval(heartbeatTimer); - hub.disconnectAll(); - }); -} diff --git a/vendor/bytelyst/fastify-sse/tsconfig.json b/vendor/bytelyst/fastify-sse/tsconfig.json deleted file mode 100644 index 5edad81..0000000 --- a/vendor/bytelyst/fastify-sse/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/vendor/bytelyst/feature-flag-client/package.json b/vendor/bytelyst/feature-flag-client/package.json deleted file mode 100644 index c9b7097..0000000 --- a/vendor/bytelyst/feature-flag-client/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "@bytelyst/feature-flag-client", - "version": "0.1.5", - "type": "module", - "description": "Browser/React Native-safe feature flag client for platform-service", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/feature-flag-client/src/client.test.ts b/vendor/bytelyst/feature-flag-client/src/client.test.ts deleted file mode 100644 index 8a1788e..0000000 --- a/vendor/bytelyst/feature-flag-client/src/client.test.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { createFeatureFlagClient } from './client.js'; - -describe('createFeatureFlagClient', () => { - const baseConfig = { - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - platform: 'web', - }; - - beforeEach(() => { - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); - vi.restoreAllMocks(); - }); - - it('should create a client with isEnabled returning false before init', () => { - const client = createFeatureFlagClient(baseConfig); - expect(client.isEnabled('any_flag')).toBe(false); - }); - - it('should fetch flags on init', async () => { - const mockFlags = { premium: true, beta_feature: false }; - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ flags: mockFlags }), - }) - ); - - const client = createFeatureFlagClient(baseConfig); - await client.init(); - - expect(client.isEnabled('premium')).toBe(true); - expect(client.isEnabled('beta_feature')).toBe(false); - expect(client.isEnabled('nonexistent')).toBe(false); - client.stop(); - }); - - it('should return all flags via getAllFlags', async () => { - const mockFlags = { a: true, b: false }; - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ flags: mockFlags }), - }) - ); - - const client = createFeatureFlagClient(baseConfig); - await client.init(); - - expect(client.getAllFlags()).toEqual({ a: true, b: false }); - client.stop(); - }); - - it('should send correct headers', async () => { - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ flags: {} }), - }); - vi.stubGlobal('fetch', fetchMock); - - const client = createFeatureFlagClient(baseConfig); - await client.init({ userId: 'user-123' }); - - expect(fetchMock).toHaveBeenCalledWith( - expect.stringContaining('/flags/poll?'), - expect.objectContaining({ - headers: expect.objectContaining({ 'x-product-id': 'testapp' }), - }) - ); - - const url = fetchMock.mock.calls[0][0] as string; - expect(url).toContain('platform=web'); - expect(url).toContain('userId=user-123'); - client.stop(); - }); - - it('should keep existing flags on network error', async () => { - let callCount = 0; - vi.stubGlobal( - 'fetch', - vi.fn().mockImplementation(() => { - callCount++; - if (callCount === 1) { - return Promise.resolve({ - ok: true, - json: () => Promise.resolve({ flags: { initial: true } }), - }); - } - return Promise.reject(new Error('network')); - }) - ); - - const client = createFeatureFlagClient(baseConfig); - await client.init(); - expect(client.isEnabled('initial')).toBe(true); - - await client.refresh(); - expect(client.isEnabled('initial')).toBe(true); - client.stop(); - }); - - it('should persist to storage when provided', async () => { - const store: Record = {}; - const storage = { - getItem: (k: string) => store[k] ?? null, - setItem: (k: string, v: string) => { - store[k] = v; - }, - }; - - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ flags: { cached: true } }), - }) - ); - - const client = createFeatureFlagClient({ ...baseConfig, storage }); - await client.init(); - - expect(store['testapp-feature-flags']).toBeDefined(); - expect(JSON.parse(store['testapp-feature-flags'])).toEqual({ cached: true }); - client.stop(); - }); - - it('should restore flags from storage on creation', () => { - const store: Record = { - 'testapp-feature-flags': JSON.stringify({ restored: true }), - }; - const storage = { - getItem: (k: string) => store[k] ?? null, - setItem: (k: string, v: string) => { - store[k] = v; - }, - }; - - const client = createFeatureFlagClient({ ...baseConfig, storage }); - expect(client.isEnabled('restored')).toBe(true); - }); - - it('should stop polling on stop()', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ flags: {} }), - }) - ); - - const client = createFeatureFlagClient({ ...baseConfig, pollIntervalMs: 1000 }); - await client.init(); - client.stop(); - - expect(client.isEnabled('anything')).toBe(false); - expect(client.getAllFlags()).toEqual({}); - }); -}); diff --git a/vendor/bytelyst/feature-flag-client/src/client.ts b/vendor/bytelyst/feature-flag-client/src/client.ts deleted file mode 100644 index 98c2fe1..0000000 --- a/vendor/bytelyst/feature-flag-client/src/client.ts +++ /dev/null @@ -1,300 +0,0 @@ -/** - * Browser/React Native-safe feature flag client for platform-service. - * - * Supports two modes: - * 1. **Polling** (default) — GET /flags/poll on a configurable interval - * 2. **Streaming** — SSE via GET /flags/stream for real-time updates - * - * Both modes support multi-variate evaluation via POST /flags/evaluate. - * No Node.js dependencies — uses globalThis.fetch and EventSource. - * - * @example - * ```ts - * import { createFeatureFlagClient } from '@bytelyst/feature-flag-client'; - * - * const flags = createFeatureFlagClient({ - * baseUrl: 'http://localhost:4003/api', - * productId: 'nomgap', - * platform: 'mobile', - * }); - * - * await flags.init({ userId: 'user-123' }); - * - * // Boolean check (legacy) - * if (flags.isEnabled('premium_body_viz')) { ... } - * - * // Multi-variate - * const color = flags.getValue('cta_color', '#000000'); - * const config = flags.getValue('rate_limits', { maxReqs: 100 }); - * - * // Listen for changes (SSE or polling refresh) - * const unsub = flags.onChange((key) => console.log(`${key} changed`)); - * ``` - */ - -import type { FeatureFlagClient, FeatureFlagClientConfig, EvaluationResult } from './types.js'; - -export function createFeatureFlagClient(config: FeatureFlagClientConfig): FeatureFlagClient { - const { - baseUrl, - productId, - platform, - pollIntervalMs = 5 * 60 * 1000, - storage, - storagePrefix, - useStreaming = false, - getAccessToken, - } = config; - - const prefix = storagePrefix ?? productId; - const BOOL_KEY = `${prefix}-feature-flags`; - const EVAL_KEY = `${prefix}-feature-evals`; - - let boolFlags: Record = {}; - let evaluations: Record = {}; - let initialized = false; - let intervalId: ReturnType | null = null; - // eslint-disable-next-line no-undef - let eventSource: InstanceType | null = null; - let userId: string | undefined; - const listeners = new Set<(flagKey: string) => void>(); - - // Restore from storage on creation - if (storage) { - try { - const cached = storage.getItem(BOOL_KEY); - if (cached) boolFlags = JSON.parse(cached); - } catch { - /* Ignore parse errors */ - } - try { - const cached = storage.getItem(EVAL_KEY); - if (cached) evaluations = JSON.parse(cached); - } catch { - /* Ignore parse errors */ - } - } - - function buildHeaders(): Record { - const requestId = - typeof globalThis.crypto?.randomUUID === 'function' - ? globalThis.crypto.randomUUID() - : `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; - - const headers: Record = { - 'x-product-id': productId, - 'x-request-id': requestId, - }; - if (getAccessToken) { - const token = getAccessToken(); - if (token) headers['authorization'] = `Bearer ${token}`; - } - return headers; - } - - function notifyListeners(flagKey: string): void { - for (const listener of listeners) { - try { - listener(flagKey); - } catch { - /* best-effort */ - } - } - } - - function persistToStorage(): void { - if (!storage) return; - try { - storage.setItem(BOOL_KEY, JSON.stringify(boolFlags)); - } catch { - /* non-fatal */ - } - try { - storage.setItem(EVAL_KEY, JSON.stringify(evaluations)); - } catch { - /* non-fatal */ - } - } - - async function fetchBoolFlags(): Promise { - try { - const parts = [`platform=${encodeURIComponent(platform)}`]; - if (userId) parts.push(`userId=${encodeURIComponent(userId)}`); - - const res = await globalThis.fetch(`${baseUrl}/flags/poll?${parts.join('&')}`, { - headers: buildHeaders(), - }); - - if (!res.ok) return; - - const data = (await res.json()) as { flags?: Record }; - const prev = boolFlags; - boolFlags = data.flags ?? {}; - - // Detect changes and notify - const allKeys = new Set([...Object.keys(prev), ...Object.keys(boolFlags)]); - for (const key of allKeys) { - if (prev[key] !== boolFlags[key]) notifyListeners(key); - } - - persistToStorage(); - } catch { - // Keep existing flags on network error - } - } - - async function fetchEvaluations(): Promise { - try { - const body: Record = { platform }; - if (userId) body.userId = userId; - - const res = await globalThis.fetch(`${baseUrl}/flags/evaluate`, { - method: 'POST', - headers: { ...buildHeaders(), 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - - if (!res.ok) return; - - const data = (await res.json()) as { evaluations?: Record }; - const prev = evaluations; - evaluations = data.evaluations ?? {}; - - // Also update bool flags for backward compatibility - for (const [key, result] of Object.entries(evaluations)) { - const newBool = - result.reason !== 'off' && - result.reason !== 'prerequisite_failed' && - result.reason !== 'schedule_inactive' && - result.reason !== 'error'; - if (boolFlags[key] !== newBool) { - boolFlags[key] = newBool; - notifyListeners(key); - } else if (JSON.stringify(prev[key]?.value) !== JSON.stringify(result.value)) { - notifyListeners(key); - } - } - - persistToStorage(); - } catch { - // Keep existing evaluations on network error - } - } - - async function fetchAll(): Promise { - await Promise.all([fetchBoolFlags(), fetchEvaluations()]); - } - - function startStreaming(): void { - if (typeof globalThis.EventSource === 'undefined') { - // SSE not available (e.g. React Native) — fall back to polling - intervalId = setInterval(() => { - void fetchAll(); - }, pollIntervalMs); - return; - } - - const parts = [`productId=${encodeURIComponent(productId)}`]; - if (getAccessToken) { - const token = getAccessToken(); - if (token) parts.push(`token=${encodeURIComponent(token)}`); - } - const url = `${baseUrl}/flags/stream?${parts.join('&')}`; - eventSource = new globalThis.EventSource(url); - - eventSource.onmessage = event => { - try { - const data = JSON.parse(event.data) as { type?: string; flagKey?: string }; - if (data.type === 'flag_change' && data.flagKey) { - // Re-fetch all evaluations on any flag change - void fetchAll().then(() => notifyListeners(data.flagKey!)); - } - } catch { - /* Ignore parse errors */ - } - }; - - eventSource.onerror = () => { - // Reconnect handled automatically by EventSource spec - }; - } - - async function init(params?: { userId?: string }): Promise { - if (initialized) return; - initialized = true; - userId = params?.userId; - - await fetchAll(); - - if (useStreaming) { - startStreaming(); - } else { - intervalId = setInterval(() => { - void fetchAll(); - }, pollIntervalMs); - } - } - - function isEnabled(key: string): boolean { - return boolFlags[key] === true; - } - - function getValue>( - key: string, - defaultValue: T - ): T { - const result = evaluations[key]; - if (!result) return defaultValue; - if (result.reason === 'off' || result.reason === 'error') return defaultValue; - return result.value as T; - } - - function getEvaluation(key: string): EvaluationResult | undefined { - return evaluations[key]; - } - - function getAllFlags(): Readonly> { - return boolFlags; - } - - function getAllEvaluations(): Readonly> { - return evaluations; - } - - async function refresh(): Promise { - await fetchAll(); - } - - function onChange(listener: (flagKey: string) => void): () => void { - listeners.add(listener); - return () => { - listeners.delete(listener); - }; - } - - function stop(): void { - if (intervalId) clearInterval(intervalId); - intervalId = null; - if (eventSource) { - eventSource.close(); - eventSource = null; - } - boolFlags = {}; - evaluations = {}; - initialized = false; - userId = undefined; - listeners.clear(); - } - - return { - init, - isEnabled, - getValue, - getEvaluation, - getAllFlags, - getAllEvaluations, - refresh, - onChange, - stop, - }; -} diff --git a/vendor/bytelyst/feature-flag-client/src/index.ts b/vendor/bytelyst/feature-flag-client/src/index.ts deleted file mode 100644 index cd426b3..0000000 --- a/vendor/bytelyst/feature-flag-client/src/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export { createFeatureFlagClient } from './client.js'; -export type { - FeatureFlagClient, - FeatureFlagClientConfig, - EvaluationContext, - EvaluationResult, -} from './types.js'; diff --git a/vendor/bytelyst/feature-flag-client/src/types.ts b/vendor/bytelyst/feature-flag-client/src/types.ts deleted file mode 100644 index cda332a..0000000 --- a/vendor/bytelyst/feature-flag-client/src/types.ts +++ /dev/null @@ -1,88 +0,0 @@ -/** - * Types for @bytelyst/feature-flag-client. - * Browser/React Native-safe — no Node.js dependencies. - */ - -// ── Evaluation types ──────────────────────────────────────────────────────── - -export interface EvaluationContext { - userId?: string; - platform?: string; - region?: string; - osVersion?: string; - appVersion?: string; - email?: string; - custom?: Record; -} - -export interface EvaluationResult { - key: string; - value: boolean | string | number | Record; - variationKey: string; - reason: string; -} - -// ── Config ────────────────────────────────────────────────────────────────── - -export interface FeatureFlagClientConfig { - /** Platform-service base URL (e.g. "http://localhost:4003/api"). */ - baseUrl: string; - - /** Product identifier sent as x-product-id header. */ - productId: string; - - /** Platform string for the poll query (e.g. "mobile", "web"). */ - platform: string; - - /** Poll interval in milliseconds. Default: 5 minutes. */ - pollIntervalMs?: number; - - /** Optional persistent storage adapter for flag cache. */ - storage?: { - getItem(key: string): string | null; - setItem(key: string, value: string): void; - }; - - /** Optional storage key prefix. Default: productId. */ - storagePrefix?: string; - - /** Use SSE for real-time updates instead of polling. Default: false. */ - useStreaming?: boolean; - - /** Auth token getter for authenticated requests. */ - getAccessToken?: () => string | null; -} - -// ── Client interface ──────────────────────────────────────────────────────── - -export interface FeatureFlagClient { - /** Initialize the client: fetch flags immediately and start polling/streaming. */ - init(params?: { userId?: string }): Promise; - - /** Check if a boolean feature flag is enabled. Returns false if not found. */ - isEnabled(key: string): boolean; - - /** Get the resolved value of a multi-variate flag. Returns defaultValue if not found. */ - getValue>( - key: string, - defaultValue: T - ): T; - - /** Get the full evaluation result for a flag. Returns undefined if not found. */ - getEvaluation(key: string): EvaluationResult | undefined; - - /** Get all currently cached boolean flags (legacy format). */ - getAllFlags(): Readonly>; - - /** Get all evaluation results (multi-variate format). */ - getAllEvaluations(): Readonly>; - - /** Force a refresh of feature flags. */ - refresh(): Promise; - - /** Register a listener for flag changes. Returns unsubscribe function. */ - onChange(listener: (flagKey: string) => void): () => void; - - /** Stop polling/streaming and reset state. */ - stop(): void; -} diff --git a/vendor/bytelyst/feature-flag-client/tsconfig.json b/vendor/bytelyst/feature-flag-client/tsconfig.json deleted file mode 100644 index 318c075..0000000 --- a/vendor/bytelyst/feature-flag-client/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src", - "lib": ["ES2022", "DOM"] - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/vendor/bytelyst/feedback-client/package.json b/vendor/bytelyst/feedback-client/package.json deleted file mode 100644 index 3dafade..0000000 --- a/vendor/bytelyst/feedback-client/package.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "name": "@bytelyst/feedback-client", - "version": "0.1.5", - "description": "TypeScript client for submitting user feedback with screenshots", - "type": "module", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks" - }, - "dependencies": { - "@bytelyst/api-client": "workspace:*" - }, - "peerDependencies": { - "zod": "^3.22.0" - }, - "devDependencies": { - "typescript": "^5.7.0", - "vitest": "^3.0.0" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/feedback-client/src/gdpr.test.ts b/vendor/bytelyst/feedback-client/src/gdpr.test.ts deleted file mode 100644 index c5a1077..0000000 --- a/vendor/bytelyst/feedback-client/src/gdpr.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -/** - * GDPR deletion test for feedback screenshots - * - * Tests the Right to be Forgotten compliance: - * 1. User submits feedback with screenshot - * 2. Admin deletes feedback and screenshot - * 3. Blob storage reference removed (actual deletion by lifecycle policy) - * - * TODO-5: GDPR deletion compliance test - */ - -import { describe, it, expect, beforeAll } from 'vitest'; -import { createFeedbackClient, type FeedbackClient } from './index.js'; - -// Check if blob storage is available -const blobStorageAvailable = !!( - process.env.AZURE_BLOB_CONNECTION_STRING || - (process.env.AZURE_BLOB_ACCOUNT_NAME && process.env.AZURE_BLOB_ACCOUNT_KEY) -); - -const describeIntegration = blobStorageAvailable ? describe : describe.skip; - -describeIntegration('GDPR Deletion Compliance (TODO-5)', () => { - let client: FeedbackClient; - const testBaseUrl = process.env.TEST_API_URL || 'http://localhost:4003'; - const testAuthToken = process.env.TEST_AUTH_TOKEN || 'test-token'; - const adminToken = process.env.TEST_ADMIN_TOKEN || 'admin-token'; - - beforeAll(() => { - client = createFeedbackClient({ - baseUrl: testBaseUrl, - getAuthToken: () => testAuthToken, - }); - }); - - it('should delete feedback and screenshot on user request (GDPR)', async () => { - // Step 1: Submit feedback with screenshot - const testPngData = new Uint8Array([ - 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, - 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, - 0x77, 0x53, 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, 0x54, 0x08, 0xd7, 0x63, 0xf8, - 0xcf, 0xc0, 0x00, 0x00, 0x03, 0x01, 0x01, 0x00, 0x18, 0xdd, 0x8d, 0xb4, 0x00, 0x00, 0x00, - 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, - ]); - const testBlob = new Blob([testPngData], { type: 'image/png' }); - - const submitResult = await client.submitWithScreenshot({ - type: 'bug', - title: 'GDPR test feedback', - body: 'This feedback will be deleted per user request', - screenshot: { - blob: testBlob, - contentType: 'image/png', - }, - }); - - expect(submitResult.screenshotBlobPath).toBeDefined(); - const feedbackId = submitResult.id; - - // Step 2: Delete feedback (admin action) - const deleteRes = await fetch(`${testBaseUrl}/api/feedback/${feedbackId}`, { - method: 'DELETE', - headers: { Authorization: `Bearer ${adminToken}` }, - }); - expect(deleteRes.status).toBe(204); - - // Step 3: Verify feedback no longer exists - const getRes = await fetch(`${testBaseUrl}/api/feedback/${feedbackId}`, { - headers: { Authorization: `Bearer ${adminToken}` }, - }); - expect(getRes.status).toBe(404); - - // Step 4: Verify screenshot reference is gone - // Note: Actual blob deletion is handled by Azure lifecycle policy - // This test verifies the database reference is removed - const screenshotRes = await fetch(`${testBaseUrl}/api/feedback/${feedbackId}/screenshot`, { - headers: { Authorization: `Bearer ${adminToken}` }, - }); - expect(screenshotRes.status).toBe(404); - - // GDPR deletion verified — blob will be purged by Azure lifecycle policy within 90 days - }, 30000); - - it('should delete only screenshot while keeping feedback (partial deletion)', async () => { - // Submit feedback with screenshot - const testBlob = new Blob(['test'], { type: 'image/png' }); - const submitResult = await client.submitWithScreenshot({ - type: 'feature', - title: 'Partial deletion test', - screenshot: { - blob: testBlob, - contentType: 'image/png', - }, - }); - - const feedbackId = submitResult.id; - - // Delete just the screenshot - const deleteScreenshotRes = await fetch( - `${testBaseUrl}/api/feedback/${feedbackId}/screenshot`, - { - method: 'DELETE', - headers: { Authorization: `Bearer ${adminToken}` }, - } - ); - expect(deleteScreenshotRes.status).toBe(204); - - // Verify feedback still exists but screenshot is gone - const getRes = await fetch(`${testBaseUrl}/api/feedback/${feedbackId}`, { - headers: { Authorization: `Bearer ${adminToken}` }, - }); - expect(getRes.status).toBe(200); - - const feedback = await getRes.json(); - expect(feedback.screenshotBlobPath).toBeNull(); - - // Cleanup - await fetch(`${testBaseUrl}/api/feedback/${feedbackId}`, { - method: 'DELETE', - headers: { Authorization: `Bearer ${adminToken}` }, - }); - }, 30000); -}); - -describe('GDPR Compliance Checklist', () => { - it('documents GDPR requirements', () => { - const gdprRequirements = [ - '✅ User can request deletion of their feedback', - '✅ Admin can delete feedback and screenshots', - '✅ Screenshot blob reference is removed from database', - '✅ Feedback data removed from Cosmos DB', - '✅ Azure lifecycle policy purges blob within 90 days', - '✅ Deletion is irreversible (no soft-delete)', - ]; - - // All GDPR requirements satisfied — see gdprRequirements array above - - expect(gdprRequirements.length).toBeGreaterThan(0); - }); -}); diff --git a/vendor/bytelyst/feedback-client/src/index.test.ts b/vendor/bytelyst/feedback-client/src/index.test.ts deleted file mode 100644 index dbbe693..0000000 --- a/vendor/bytelyst/feedback-client/src/index.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import { FeedbackClient } from './index.js'; -import type { ApiClient } from '@bytelyst/api-client'; - -describe('FeedbackClient', () => { - const mockApi: Partial = { - fetch: vi.fn(), - }; - - const createClient = () => new FeedbackClient(mockApi as ApiClient); - - it('should submit feedback without screenshot', async () => { - const client = createClient(); - const mockResponse = { - id: 'fb_123', - productId: 'test', - userId: 'user_123', - type: 'bug', - title: 'Test bug', - status: 'new', - createdAt: new Date().toISOString(), - }; - - mockApi.fetch = vi.fn().mockResolvedValue(mockResponse); - - const result = await client.submitWithScreenshot({ - type: 'bug', - title: 'Test bug', - body: 'Description', - }); - - expect(result).toEqual(mockResponse); - expect(mockApi.fetch).toHaveBeenCalledWith( - '/api/feedback', - expect.objectContaining({ - method: 'POST', - }) - ); - }); - - it('should throw if captureAndSubmit called without screenshot', async () => { - const client = createClient(); - - await expect(client.captureAndSubmit({ type: 'bug', title: 'Test' })).rejects.toThrow( - 'Screenshot capture only available in browser environment' - ); - }); -}); diff --git a/vendor/bytelyst/feedback-client/src/index.ts b/vendor/bytelyst/feedback-client/src/index.ts deleted file mode 100644 index 52f1876..0000000 --- a/vendor/bytelyst/feedback-client/src/index.ts +++ /dev/null @@ -1,384 +0,0 @@ -/** - * Feedback Client — TypeScript SDK for user feedback with screenshots - * - * @module @bytelyst/feedback-client - */ - -import { createApiClient, type ApiClient } from '@bytelyst/api-client'; - -export interface FeedbackClientConfig { - baseUrl: string; - getAuthToken: () => string | null; -} - -export interface DeviceContext { - osVersion: string; - appVersion: string; - deviceModel: string; - screenResolution: string; - locale: string; -} - -export interface SubmitFeedbackParams { - type: 'bug' | 'feature' | 'praise' | 'other'; - title: string; - body?: string; - screen?: string; - rating?: number; - appVersion?: string; - platform?: 'web' | 'ios' | 'android'; - screenshot?: { - blob: Blob; - contentType: 'image/png' | 'image/jpeg' | 'image/webp'; - }; - deviceContext?: DeviceContext; -} - -export interface SasResponse { - blobPath: string; - uploadUrl: string; - expiresIn: number; - maxSizeBytes: number; -} - -export interface FeedbackResponse { - id: string; - productId: string; - userId: string; - type: string; - title: string; - status: string; - createdAt: string; - screenshotBlobPath?: string; -} - -export type UploadProgressCallback = (loaded: number, total: number) => void; - -export interface ScreenshotOptions { - /** For web: CSS selector of element to capture. If omitted, captures viewport */ - selector?: string; - /** Image format */ - format?: 'png' | 'jpeg' | 'webp'; - /** JPEG quality (0-1), only used for jpeg format */ - quality?: number; -} - -export interface CaptureResult { - blob: Blob; - contentType: 'image/png' | 'image/jpeg' | 'image/webp'; - width: number; - height: number; -} - -/** - * Create a feedback client for submitting user feedback with optional screenshots - */ -export function createFeedbackClient(config: FeedbackClientConfig) { - const api = createApiClient({ - baseUrl: config.baseUrl, - getToken: config.getAuthToken, - }); - - return new FeedbackClient(api); -} - -/** - * Feedback client class for submitting feedback with screenshots - */ -export class FeedbackClient { - constructor(private api: ApiClient) {} - - /** - * Submit feedback with optional screenshot - * - * Flow: - * 1. If screenshot provided, get SAS URL - * 2. Upload screenshot to blob storage - * 3. Submit feedback with screenshot metadata - */ - async submitWithScreenshot( - params: SubmitFeedbackParams, - onProgress?: UploadProgressCallback - ): Promise { - let screenshotMeta: { blobPath: string; contentType: string; sizeBytes: number } | undefined; - - // Step 1 & 2: Handle screenshot upload if provided - if (params.screenshot) { - const sas = await this.generateSasUrl(params.screenshot.contentType); - - await this.uploadScreenshot(sas.uploadUrl, params.screenshot.blob, onProgress); - - screenshotMeta = { - blobPath: sas.blobPath, - contentType: params.screenshot.contentType, - sizeBytes: params.screenshot.blob.size, - }; - } - - // Step 3: Submit feedback - const response = await this.api.fetch('/api/feedback', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - type: params.type, - title: params.title, - body: params.body, - screen: params.screen, - rating: params.rating, - appVersion: params.appVersion, - platform: params.platform, - screenshotBlobPath: screenshotMeta?.blobPath, - screenshotContentType: screenshotMeta?.contentType as - | 'image/png' - | 'image/jpeg' - | 'image/webp', - screenshotSizeBytes: screenshotMeta?.sizeBytes, - deviceContext: params.deviceContext, - }), - }); - - return response; - } - - /** - * Generate SAS URL for screenshot upload - */ - private async generateSasUrl( - contentType: 'image/png' | 'image/jpeg' | 'image/webp' - ): Promise { - const response = await this.api.fetch('/api/feedback/sas', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ contentType }), - }); - return response; - } - - /** - * Upload screenshot directly to Azure Blob - */ - private async uploadScreenshot( - uploadUrl: string, - blob: Blob, - onProgress?: UploadProgressCallback - ): Promise { - // Use XMLHttpRequest for progress tracking if callback provided - if (onProgress && typeof window !== 'undefined') { - return this.uploadWithProgress(uploadUrl, blob, onProgress); - } - - // Simple fetch upload - const response = await fetch(uploadUrl, { - method: 'PUT', - headers: { - 'Content-Type': blob.type, - 'x-ms-blob-type': 'BlockBlob', - }, - body: blob, - }); - - if (!response.ok) { - throw new Error(`Upload failed: ${response.status} ${response.statusText}`); - } - } - - /** - * Upload with progress tracking using XMLHttpRequest - */ - private uploadWithProgress( - uploadUrl: string, - blob: Blob, - onProgress: UploadProgressCallback - ): Promise { - return new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest(); - - xhr.upload.addEventListener('progress', (event: ProgressEvent) => { - if (event.lengthComputable) { - onProgress(event.loaded, event.total); - } - }); - - xhr.addEventListener('load', () => { - if (xhr.status >= 200 && xhr.status < 300) { - resolve(); - } else { - reject(new Error(`Upload failed: ${xhr.status} ${xhr.statusText}`)); - } - }); - - xhr.addEventListener('error', () => { - reject(new Error('Upload failed: Network error')); - }); - - xhr.open('PUT', uploadUrl); - xhr.setRequestHeader('Content-Type', blob.type); - xhr.setRequestHeader('x-ms-blob-type', 'BlockBlob'); - xhr.send(blob); - }); - } - - /** - * Capture screenshot of current page or element (Web only) - * - * Uses native getDisplayMedia for screen capture or html2canvas-style - * DOM serialization for element capture. - */ - async captureScreenshot(options: ScreenshotOptions = {}): Promise { - // Check if running in browser - if (typeof window === 'undefined' || typeof document === 'undefined') { - throw new Error('Screenshot capture only available in browser environment'); - } - - const format = options.format || 'png'; - const mimeType = - format === 'png' ? 'image/png' : format === 'jpeg' ? 'image/jpeg' : 'image/webp'; - - // If selector provided, capture specific element - if (options.selector) { - return this.captureElement(options.selector, mimeType, options.quality); - } - - // Otherwise capture full screen using getDisplayMedia - return this.captureScreen(mimeType); - } - - /** - * Capture entire screen using getDisplayMedia - */ - private async captureScreen(mimeType: string): Promise { - try { - // Request screen capture permission - const stream = await navigator.mediaDevices.getDisplayMedia({ - video: true, - audio: false, - }); - - // Create video element to capture frame - const video = document.createElement('video'); - video.srcObject = stream; - - // Wait for video to load - await new Promise((resolve, reject) => { - video.onloadedmetadata = () => { - video.play(); - resolve(); - }; - video.onerror = () => reject(new Error('Failed to load video stream')); - // Timeout after 5 seconds - setTimeout(() => reject(new Error('Video load timeout')), 5000); - }); - - // Draw to canvas - const canvas = document.createElement('canvas'); - canvas.width = video.videoWidth; - canvas.height = video.videoHeight; - const ctx = canvas.getContext('2d'); - if (!ctx) throw new Error('Failed to get canvas context'); - - ctx.drawImage(video, 0, 0); - - // Stop all tracks - stream.getTracks().forEach(track => track.stop()); - - // Convert to blob - const quality = mimeType === 'image/jpeg' ? 0.9 : undefined; - const blob = await new Promise((resolve, reject) => { - canvas.toBlob( - b => (b ? resolve(b) : reject(new Error('Canvas toBlob failed'))), - mimeType, - quality - ); - }); - - return { - blob, - contentType: mimeType as 'image/png' | 'image/jpeg' | 'image/webp', - width: canvas.width, - height: canvas.height, - }; - } catch (err) { - throw new Error( - `Screen capture failed: ${err instanceof Error ? err.message : String(err)}`, - { cause: err } - ); - } - } - - /** - * Capture specific DOM element using html-to-image approach. - * `mimeType` and `quality` are accepted for API parity with captureScreen() - * but are not yet honoured here (this path always returns the fallback PNG). - */ - private async captureElement( - selector: string, - _mimeType: string, - _quality?: number - ): Promise { - const element = document.querySelector(selector); - if (!element) { - throw new Error(`Element not found: ${selector}`); - } - - // Use html-to-image or similar approach - // For now, we'll use a simple canvas-based approach for visible elements - const rect = element.getBoundingClientRect(); - - // Create canvas - const canvas = document.createElement('canvas'); - canvas.width = rect.width; - canvas.height = rect.height; - const ctx = canvas.getContext('2d'); - if (!ctx) throw new Error('Failed to get canvas context'); - - // Try to use html2canvas-style approach if available, otherwise warn - // This is a simplified implementation - throw new Error( - 'Element capture requires html2canvas library. ' + - 'Please install: npm install html2canvas ' + - 'Then use: html2canvas(element).then(canvas => canvas.toBlob(...))' - ); - } - - /** - * Capture and submit feedback in one operation - * - * @example - * // Capture full screen and submit - * const result = await client.captureAndSubmit({ - * type: 'bug', - * title: 'Something is broken', - * body: 'Description of the issue' - * }); - * - * @example - * // Capture specific element - * const result = await client.captureAndSubmit({ - * type: 'bug', - * title: 'Button not working', - * body: 'The submit button is unresponsive' - * }, { - * selector: '#submit-button' - * }); - */ - async captureAndSubmit( - params: Omit, - screenshotOptions?: ScreenshotOptions, - onProgress?: UploadProgressCallback - ): Promise { - // Capture screenshot - const capture = await this.captureScreenshot(screenshotOptions); - - // Submit with captured screenshot - return this.submitWithScreenshot( - { - ...params, - screenshot: { - blob: capture.blob, - contentType: capture.contentType, - }, - }, - onProgress - ); - } -} diff --git a/vendor/bytelyst/feedback-client/src/integration.test.ts b/vendor/bytelyst/feedback-client/src/integration.test.ts deleted file mode 100644 index 3451254..0000000 --- a/vendor/bytelyst/feedback-client/src/integration.test.ts +++ /dev/null @@ -1,210 +0,0 @@ -/** - * Integration tests for feedback screenshot flow - * - * These tests verify the complete flow: - * 1. Generate SAS URL for upload - * 2. Upload screenshot to blob storage - * 3. Submit feedback with screenshot metadata - * 4. Retrieve feedback with screenshot URL - * - * NOTE: Requires blob storage to be available in test environment. - * Tests are auto-skipped when AZURE_BLOB_CONNECTION_STRING is not set. - * In CI, set AZURE_BLOB_CONNECTION_STRING or AZURE_BLOB_ACCOUNT_NAME+KEY. - */ - -import { describe, it, expect, beforeAll } from 'vitest'; -import { createFeedbackClient, type FeedbackClient } from './index.js'; - -// Check if blob storage is available -const blobStorageAvailable = !!( - process.env.AZURE_BLOB_CONNECTION_STRING || - (process.env.AZURE_BLOB_ACCOUNT_NAME && process.env.AZURE_BLOB_ACCOUNT_KEY) -); - -const describeIntegration = blobStorageAvailable ? describe : describe.skip; - -describeIntegration('Feedback Screenshot Integration', () => { - let client: FeedbackClient; - const testBaseUrl = process.env.TEST_API_URL || 'http://localhost:4003'; - const testAuthToken = process.env.TEST_AUTH_TOKEN || 'test-token'; - - beforeAll(() => { - client = createFeedbackClient({ - baseUrl: testBaseUrl, - getAuthToken: () => testAuthToken, - }); - }); - - it('should complete full screenshot submission flow', async () => { - // Create a test image blob (1x1 pixel PNG) - const testPngData = new Uint8Array([ - 0x89, - 0x50, - 0x4e, - 0x47, - 0x0d, - 0x0a, - 0x1a, - 0x0a, // PNG signature - 0x00, - 0x00, - 0x00, - 0x0d, - 0x49, - 0x48, - 0x44, - 0x52, // IHDR chunk - 0x00, - 0x00, - 0x00, - 0x01, - 0x00, - 0x00, - 0x00, - 0x01, // 1x1 pixel - 0x08, - 0x02, - 0x00, - 0x00, - 0x00, - 0x90, - 0x77, - 0x53, - 0xde, - 0x00, - 0x00, - 0x00, - 0x0c, - 0x49, - 0x44, - 0x41, // IDAT chunk - 0x54, - 0x08, - 0xd7, - 0x63, - 0xf8, - 0xcf, - 0xc0, - 0x00, - 0x00, - 0x03, - 0x01, - 0x01, - 0x00, - 0x18, - 0xdd, - 0x8d, - 0xb4, - 0x00, - 0x00, - 0x00, - 0x00, - 0x49, - 0x45, - 0x4e, // IEND chunk - 0x44, - 0xae, - 0x42, - 0x60, - 0x82, - ]); - const testBlob = new Blob([testPngData], { type: 'image/png' }); - - // Submit feedback with screenshot - const result = await client.submitWithScreenshot({ - type: 'bug', - title: 'Integration test screenshot', - body: 'This is a test feedback with screenshot', - screenshot: { - blob: testBlob, - contentType: 'image/png', - }, - deviceContext: { - osVersion: 'Test OS 1.0', - appVersion: '1.0.0', - deviceModel: 'Test Device', - screenResolution: '1920x1080', - locale: 'en-US', - }, - }); - - // Verify response - expect(result).toBeDefined(); - expect(result.id).toBeDefined(); - expect(result.type).toBe('bug'); - expect(result.title).toBe('Integration test screenshot'); - expect(result.status).toBe('new'); - expect(result.screenshotBlobPath).toBeDefined(); - expect(result.screenshotBlobPath).toContain('feedbackScreenshots'); - - // TODO: Verify screenshot can be retrieved via admin API - // const screenshotRes = await fetch(`${testBaseUrl}/api/feedback/${result.id}/screenshot`, { - // headers: { Authorization: `Bearer ${adminToken}` }, - // }); - // expect(screenshotRes.ok).toBe(true); - }, 30000); // 30 second timeout for upload - - it('should submit feedback without screenshot', async () => { - const result = await client.submitWithScreenshot({ - type: 'feature', - title: 'Integration test without screenshot', - body: 'This is a test feedback without screenshot', - }); - - expect(result).toBeDefined(); - expect(result.id).toBeDefined(); - expect(result.type).toBe('feature'); - expect(result.screenshotBlobPath).toBeUndefined(); - }); - - it('should track upload progress', async () => { - const progressCallbacks: number[] = []; - const testBlob = new Blob(['test data'], { type: 'image/png' }); - - try { - await client.submitWithScreenshot( - { - type: 'bug', - title: 'Progress test', - screenshot: { - blob: testBlob, - contentType: 'image/png', - }, - }, - (loaded, _total) => { - progressCallbacks.push(loaded); - } - ); - } catch { - // Expected to fail with invalid PNG, but progress should still be called - } - - // Progress callback may or may not be called depending on upload speed - // Just verify the callback mechanism exists - expect(progressCallbacks).toBeDefined(); - }); -}); - -describe('Feedback Client Unit Tests (no blob storage required)', () => { - it('should validate screenshot content types', () => { - const validTypes = ['image/png', 'image/jpeg', 'image/webp']; - const invalidTypes = ['image/gif', 'application/pdf', 'text/plain']; - - for (const type of validTypes) { - expect(type).toMatch(/^image\/(png|jpeg|webp)$/); - } - - for (const type of invalidTypes) { - expect(type).not.toMatch(/^image\/(png|jpeg|webp)$/); - } - }); - - it('should enforce 5MB size limit', () => { - const maxSize = 5 * 1024 * 1024; // 5MB - const underLimit = maxSize - 1; - const overLimit = maxSize + 1; - - expect(underLimit).toBeLessThanOrEqual(maxSize); - expect(overLimit).toBeGreaterThan(maxSize); - }); -}); diff --git a/vendor/bytelyst/feedback-client/tsconfig.json b/vendor/bytelyst/feedback-client/tsconfig.json deleted file mode 100644 index ce78e59..0000000 --- a/vendor/bytelyst/feedback-client/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "./dist", - "rootDir": "./src", - "lib": ["ES2022", "DOM"] - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] -} diff --git a/vendor/bytelyst/field-encrypt/package.json b/vendor/bytelyst/field-encrypt/package.json deleted file mode 100644 index 0a184be..0000000 --- a/vendor/bytelyst/field-encrypt/package.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "name": "@bytelyst/field-encrypt", - "version": "0.1.5", - "type": "module", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks" - }, - "dependencies": { - "@bytelyst/errors": "workspace:*" - }, - "peerDependencies": { - "@azure/keyvault-keys": ">=4.8.0", - "@azure/identity": ">=4.0.0", - "zod": ">=3.22.0" - }, - "peerDependenciesMeta": { - "@azure/keyvault-keys": { - "optional": true - }, - "@azure/identity": { - "optional": true - } - }, - "devDependencies": { - "vitest": "^3.0.0", - "zod": "^3.24.0" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/field-encrypt/src/aes-gcm.ts b/vendor/bytelyst/field-encrypt/src/aes-gcm.ts deleted file mode 100644 index 4eedc37..0000000 --- a/vendor/bytelyst/field-encrypt/src/aes-gcm.ts +++ /dev/null @@ -1,89 +0,0 @@ -/** - * @bytelyst/field-encrypt — AES-256-GCM primitives - * - * Low-level encrypt/decrypt using Node.js native crypto. - * All higher-level APIs delegate to these functions. - */ - -import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'; -import type { EncryptedField } from './types.js'; - -const ALGORITHM = 'aes-256-gcm'; -const IV_BYTES = 12; -const KEY_BYTES = 32; - -/** - * Encrypt a plaintext string with AES-256-GCM. - * - * @param plaintext - UTF-8 string to encrypt - * @param key - 32-byte AES key - * @param dekId - DEK identifier stored in the output - * @param aad - Optional additional authenticated data (e.g., userId + context) - * @returns EncryptedField object ready for Cosmos/SQLite storage - */ -export function encryptField( - plaintext: string, - key: Buffer, - dekId: string, - aad?: string -): EncryptedField { - if (key.length !== KEY_BYTES) { - throw new Error(`AES-256-GCM requires a ${KEY_BYTES}-byte key, got ${key.length}`); - } - - const iv = randomBytes(IV_BYTES); - const cipher = createCipheriv(ALGORITHM, key, iv); - - if (aad) { - cipher.setAAD(Buffer.from(aad, 'utf8')); - } - - let encrypted = cipher.update(plaintext, 'utf8', 'hex'); - encrypted += cipher.final('hex'); - const authTag = cipher.getAuthTag(); - - return { - __encrypted: true, - v: 1, - alg: 'aes-256-gcm', - ct: encrypted, - iv: iv.toString('hex'), - tag: authTag.toString('hex'), - dekId, - }; -} - -/** - * Decrypt an EncryptedField back to plaintext. - * - * @param field - EncryptedField object - * @param key - 32-byte AES key (must match the key used to encrypt) - * @param aad - Optional AAD (must match the AAD used during encryption) - * @returns Decrypted UTF-8 string - * @throws Error if authentication tag verification fails (tampered data) - */ -export function decryptField(field: EncryptedField, key: Buffer, aad?: string): string { - if (key.length !== KEY_BYTES) { - throw new Error(`AES-256-GCM requires a ${KEY_BYTES}-byte key, got ${key.length}`); - } - - const iv = Buffer.from(field.iv, 'hex'); - const authTag = Buffer.from(field.tag, 'hex'); - const decipher = createDecipheriv(ALGORITHM, key, iv); - - decipher.setAuthTag(authTag); - - if (aad) { - decipher.setAAD(Buffer.from(aad, 'utf8')); - } - - let decrypted = decipher.update(field.ct, 'hex', 'utf8'); - decrypted += decipher.final('utf8'); - - return decrypted; -} - -/** Generate a random 32-byte AES-256 key. */ -export function generateAesKey(): Buffer { - return randomBytes(KEY_BYTES); -} diff --git a/vendor/bytelyst/field-encrypt/src/dek-store-cosmos.ts b/vendor/bytelyst/field-encrypt/src/dek-store-cosmos.ts deleted file mode 100644 index 4d322aa..0000000 --- a/vendor/bytelyst/field-encrypt/src/dek-store-cosmos.ts +++ /dev/null @@ -1,75 +0,0 @@ -/** - * @bytelyst/field-encrypt — Cosmos DB DEK store - * - * Production DEK store backed by Azure Cosmos DB. - * Container: `_encryption_keys` (partition key: /dekId) - * - * Each WrappedDek is stored as a document with dekId as both id and partition key. - */ - -import type { Container } from '@azure/cosmos'; -import type { DekStore, WrappedDek } from './types.js'; - -interface CosmosDekDoc { - id: string; - dekId: string; - wrappedKey: string; - mekVersion: string; - createdAt: string; -} - -export class CosmosDekStore implements DekStore { - constructor(private readonly container: Container) {} - - async get(dekId: string): Promise { - try { - const { resource } = await this.container.item(dekId, dekId).read(); - if (!resource) return null; - return { - dekId: resource.dekId, - wrappedKey: resource.wrappedKey, - mekVersion: resource.mekVersion, - createdAt: resource.createdAt, - }; - } catch (err: unknown) { - if (isNotFound(err)) return null; - throw err; - } - } - - async put(dek: WrappedDek): Promise { - const doc: CosmosDekDoc = { - id: dek.dekId, - dekId: dek.dekId, - wrappedKey: dek.wrappedKey, - mekVersion: dek.mekVersion, - createdAt: dek.createdAt, - }; - await this.container.items.upsert(doc); - } - - async listIds(): Promise { - const { resources } = await this.container.items - .query<{ dekId: string }>('SELECT c.dekId FROM c') - .fetchAll(); - return resources.map(r => r.dekId); - } - - async delete(dekId: string): Promise { - try { - await this.container.item(dekId, dekId).delete(); - } catch (err: unknown) { - if (isNotFound(err)) return; // Already deleted — idempotent - throw err; - } - } -} - -function isNotFound(err: unknown): boolean { - return ( - typeof err === 'object' && - err !== null && - 'code' in err && - (err as { code: number }).code === 404 - ); -} diff --git a/vendor/bytelyst/field-encrypt/src/dek-store-memory.ts b/vendor/bytelyst/field-encrypt/src/dek-store-memory.ts deleted file mode 100644 index 79aabc3..0000000 --- a/vendor/bytelyst/field-encrypt/src/dek-store-memory.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * @bytelyst/field-encrypt — In-memory DEK store - * - * Default DEK store for dev/test. Production should use a Cosmos-backed store. - */ - -import type { DekStore, WrappedDek } from './types.js'; - -export class MemoryDekStore implements DekStore { - private readonly deks = new Map(); - - async get(dekId: string): Promise { - return this.deks.get(dekId) ?? null; - } - - async put(dek: WrappedDek): Promise { - this.deks.set(dek.dekId, dek); - } - - async listIds(): Promise { - return [...this.deks.keys()]; - } - - async delete(dekId: string): Promise { - this.deks.delete(dekId); - } -} diff --git a/vendor/bytelyst/field-encrypt/src/envelope.ts b/vendor/bytelyst/field-encrypt/src/envelope.ts deleted file mode 100644 index a062db3..0000000 --- a/vendor/bytelyst/field-encrypt/src/envelope.ts +++ /dev/null @@ -1,107 +0,0 @@ -/** - * @bytelyst/field-encrypt — Envelope encryption - * - * DEK lifecycle: generate → wrap with MEK → store. - * On use: load wrapped DEK → unwrap with MEK → cache → use for AES-GCM. - */ - -import type { KeyProvider, DekStore, WrappedDek } from './types.js'; -import { generateAesKey } from './aes-gcm.js'; -import { DekCache } from './key-cache.js'; - -/** - * Build a deterministic DEK ID from userId + context. - * Format: `dek_{userId}_{context}` - */ -export function buildDekId(userId: string, context: string): string { - return `dek_${userId}_${context}`; -} - -/** - * Get or create a DEK for the given scope. - * - * 1. Check cache → return if found - * 2. Check DEK store → unwrap + cache if found - * 3. Generate new DEK → wrap → store → cache → return - */ -export async function getOrCreateDek( - dekId: string, - keyProvider: KeyProvider, - dekStore: DekStore, - cache: DekCache -): Promise { - // 1. Cache hit - const cached = cache.get(dekId); - if (cached) { - cache.recordHit(); - return cached; - } - cache.recordMiss(); - - // 2. DEK store hit — unwrap + cache - const stored = await dekStore.get(dekId); - if (stored) { - const dek = await keyProvider.unwrapKey(stored.wrappedKey, stored.mekVersion); - cache.set(dekId, dek); - return dek; - } - - // 3. Generate new DEK → wrap → store → cache - const dek = generateAesKey(); - const { wrappedKey, mekVersion } = await keyProvider.wrapKey(dek); - - const wrappedDek: WrappedDek = { - dekId, - wrappedKey, - mekVersion, - createdAt: new Date().toISOString(), - }; - await dekStore.put(wrappedDek); - - cache.set(dekId, dek); - return dek; -} - -/** - * Re-wrap all DEKs after MEK rotation. - * - * Reads each wrapped DEK, unwraps with old MEK, wraps with new MEK, stores updated. - */ -export async function rewrapAllDeks( - oldKeyProvider: KeyProvider, - newKeyProvider: KeyProvider, - dekStore: DekStore, - cache: DekCache, - onProgress?: (completed: number, total: number) => void -): Promise { - const dekIds = await dekStore.listIds(); - let completed = 0; - - for (const dekId of dekIds) { - const stored = await dekStore.get(dekId); - if (!stored) continue; - - // Unwrap with old MEK - const rawDek = await oldKeyProvider.unwrapKey(stored.wrappedKey, stored.mekVersion); - - // Wrap with new MEK - const { wrappedKey, mekVersion } = await newKeyProvider.wrapKey(rawDek); - - // Store updated wrapped DEK - const updated: WrappedDek = { - dekId, - wrappedKey, - mekVersion, - createdAt: stored.createdAt, - }; - await dekStore.put(updated); - - // Invalidate cache entry so it gets re-unwrapped with new MEK next time - cache.invalidate(dekId); - - completed++; - onProgress?.(completed, dekIds.length); - } - - return completed; -} diff --git a/vendor/bytelyst/field-encrypt/src/field-encryptor.ts b/vendor/bytelyst/field-encrypt/src/field-encryptor.ts deleted file mode 100644 index 50f7e32..0000000 --- a/vendor/bytelyst/field-encrypt/src/field-encryptor.ts +++ /dev/null @@ -1,227 +0,0 @@ -/** - * @bytelyst/field-encrypt — FieldEncryptor - * - * Main API — wires key provider + DEK store + cache + AES-GCM. - * Product backends create a singleton via createFieldEncryptor(). - */ - -import type { - EncryptedField, - FieldEncryptContext, - FieldEncryptorConfig, - KeyProvider, - DekStore, -} from './types.js'; -import { encryptField, decryptField } from './aes-gcm.js'; -import { buildDekId, getOrCreateDek, rewrapAllDeks } from './envelope.js'; -import { DekCache } from './key-cache.js'; -import { MemoryDekStore } from './dek-store-memory.js'; -import { MemoryKeyProvider } from './key-provider-memory.js'; -import { EnvKeyProvider } from './key-provider-env.js'; -import { AkvKeyProvider } from './key-provider-akv.js'; -import { isEncryptedField } from './guards.js'; - -export class FieldEncryptor { - private readonly keyProvider: KeyProvider; - private readonly dekStore: DekStore; - private readonly cache: DekCache; - - constructor(config: FieldEncryptorConfig) { - this.keyProvider = resolveKeyProvider(config); - this.dekStore = config.dekStore ?? new MemoryDekStore(); - this.cache = new DekCache( - config.dekCacheTtlMs ?? 15 * 60 * 1000, - config.dekCacheMaxSize ?? 1000 - ); - } - - /** - * Encrypt a plaintext string. - * - * Automatically gets or creates a DEK scoped to the userId + context. - */ - async encrypt(plaintext: string, ctx: FieldEncryptContext): Promise { - const dekId = buildDekId(ctx.userId, ctx.context); - const aad = `${ctx.userId}:${ctx.context}`; - const dek = await getOrCreateDek(dekId, this.keyProvider, this.dekStore, this.cache); - return encryptField(plaintext, dek, dekId, aad); - } - - /** - * Decrypt an EncryptedField back to plaintext. - */ - async decrypt(field: EncryptedField, ctx: FieldEncryptContext): Promise { - const aad = `${ctx.userId}:${ctx.context}`; - const dek = await getOrCreateDek(field.dekId, this.keyProvider, this.dekStore, this.cache); - return decryptField(field, dek, aad); - } - - /** - * Encrypt multiple fields in a single call (optimized — single DEK lookup). - */ - async encryptBatch(plaintexts: string[], ctx: FieldEncryptContext): Promise { - const dekId = buildDekId(ctx.userId, ctx.context); - const aad = `${ctx.userId}:${ctx.context}`; - const dek = await getOrCreateDek(dekId, this.keyProvider, this.dekStore, this.cache); - return plaintexts.map(pt => encryptField(pt, dek, dekId, aad)); - } - - /** - * Decrypt multiple EncryptedFields in a single call. - * - * Groups by dekId for efficient DEK lookup. - */ - async decryptBatch(fields: EncryptedField[], ctx: FieldEncryptContext): Promise { - const aad = `${ctx.userId}:${ctx.context}`; - const dekMap = new Map(); - - const results: string[] = []; - for (const field of fields) { - let dek = dekMap.get(field.dekId); - if (!dek) { - dek = await getOrCreateDek(field.dekId, this.keyProvider, this.dekStore, this.cache); - dekMap.set(field.dekId, dek); - } - results.push(decryptField(field, dek, aad)); - } - - return results; - } - - /** - * Check if a value is an EncryptedField. - */ - isEncrypted(value: unknown): value is EncryptedField { - return isEncryptedField(value); - } - - /** - * Re-wrap all DEKs after MEK rotation. - */ - async rewrapDeks( - newKeyProvider: KeyProvider, - onProgress?: (completed: number, total: number) => void - ): Promise { - return rewrapAllDeks(this.keyProvider, newKeyProvider, this.dekStore, this.cache, onProgress); - } - - /** DEK cache hit rate (0-100). */ - get cacheHitRate(): number { - return this.cache.hitRate; - } - - /** Number of cached DEKs. */ - get cacheSize(): number { - return this.cache.size; - } - - /** Reset cache statistics. */ - resetCacheStats(): void { - this.cache.resetStats(); - } - - /** Clear DEK cache (e.g., on shutdown). */ - clearCache(): void { - this.cache.clear(); - } -} - -function resolveKeyProvider(config: FieldEncryptorConfig): KeyProvider { - switch (config.keyProvider) { - case 'memory': - return new MemoryKeyProvider(); - - case 'env': { - const key = config.encryptionKey; - if (!key) { - throw new Error('FieldEncryptor: "env" key provider requires encryptionKey (hex string)'); - } - return new EnvKeyProvider(key); - } - - case 'akv': { - if (!config.keyVaultUrl) { - throw new Error('FieldEncryptor: "akv" key provider requires keyVaultUrl'); - } - if (!config.mekName) { - throw new Error('FieldEncryptor: "akv" key provider requires mekName'); - } - return new AkvKeyProvider(config.keyVaultUrl, config.mekName); - } - - default: - throw new Error(`FieldEncryptor: unknown key provider "${config.keyProvider}"`); - } -} - -/** - * No-op encryptor — stores/returns plaintext unchanged. - * - * Returned by createFieldEncryptor({ enabled: false, ... }). - * All repositories continue calling encrypt()/decrypt() without branching. - */ -export class NullFieldEncryptor extends FieldEncryptor { - constructor() { - // Use memory provider — it will never be called, but satisfies the constructor - super({ keyProvider: 'memory' }); - } - - override async encrypt(plaintext: string, _ctx: FieldEncryptContext): Promise { - // Return a sentinel object that looks encrypted but stores plaintext - return { - __encrypted: true, - v: 1, - alg: 'aes-256-gcm', - ct: plaintext, - iv: 'disabled', - tag: 'disabled', - dekId: 'disabled', - }; - } - - override async decrypt(field: EncryptedField, _ctx: FieldEncryptContext): Promise { - // If encryption was disabled, ct contains the plaintext directly - return field.ct; - } - - override async encryptBatch( - plaintexts: string[], - ctx: FieldEncryptContext - ): Promise { - return Promise.all(plaintexts.map(pt => this.encrypt(pt, ctx))); - } - - override async decryptBatch( - fields: EncryptedField[], - ctx: FieldEncryptContext - ): Promise { - return Promise.all(fields.map(f => this.decrypt(f, ctx))); - } -} - -/** - * Create a FieldEncryptor instance. - * - * Typical usage (one per backend service): - * ```typescript - * const encryptor = createFieldEncryptor({ - * keyProvider: config.FIELD_ENCRYPT_KEY_PROVIDER ?? 'memory', - * mekName: 'lysnr-mek', - * keyVaultUrl: config.AZURE_KEYVAULT_URL, - * }); - * ``` - * - * To disable encryption globally or per-product: - * ```typescript - * const encryptor = createFieldEncryptor({ - * enabled: false, // ← no-op passthrough - * keyProvider: 'memory', - * }); - * ``` - */ -export function createFieldEncryptor(config: FieldEncryptorConfig): FieldEncryptor { - if (config.enabled === false) { - return new NullFieldEncryptor(); - } - return new FieldEncryptor(config); -} diff --git a/vendor/bytelyst/field-encrypt/src/guards.ts b/vendor/bytelyst/field-encrypt/src/guards.ts deleted file mode 100644 index f8c63b8..0000000 --- a/vendor/bytelyst/field-encrypt/src/guards.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * @bytelyst/field-encrypt — Type guards - * - * Utility to detect encrypted vs plaintext fields during migration. - */ - -import type { EncryptedField } from './types.js'; - -/** - * Check if a value is an EncryptedField. - * - * Use this in repositories to handle both encrypted and plaintext fields - * during the migration period. - */ -export function isEncryptedField(value: unknown): value is EncryptedField { - return ( - typeof value === 'object' && - value !== null && - '__encrypted' in value && - (value as Record).__encrypted === true && - 'v' in value && - 'ct' in value && - 'iv' in value && - 'tag' in value && - 'dekId' in value - ); -} diff --git a/vendor/bytelyst/field-encrypt/src/index.test.ts b/vendor/bytelyst/field-encrypt/src/index.test.ts deleted file mode 100644 index bff9494..0000000 --- a/vendor/bytelyst/field-encrypt/src/index.test.ts +++ /dev/null @@ -1,608 +0,0 @@ -/** - * @bytelyst/field-encrypt — Tests - * - * ~35 tests covering AES-GCM, key providers, envelope, cache, - * field encryptor factory, type guards, and migration. - */ - -import { describe, it, expect, beforeEach } from 'vitest'; -import { randomBytes } from 'node:crypto'; -import { - createFieldEncryptor, - FieldEncryptor, - NullFieldEncryptor, - isEncryptedField, - encryptField, - decryptField, - generateAesKey, - buildDekId, - getOrCreateDek, - rewrapAllDeks, - DekCache, - MemoryDekStore, - CosmosDekStore, - MemoryKeyProvider, - EnvKeyProvider, - migrateDocuments, -} from './index.js'; -import type { EncryptedField, FieldEncryptContext } from './types.js'; - -// ── AES-256-GCM ───────────────────────────────────── - -describe('aes-gcm', () => { - const key = generateAesKey(); - const dekId = 'dek_test_ctx'; - - it('encrypt → decrypt roundtrip', () => { - const plaintext = 'Hello, sensitive data!'; - const encrypted = encryptField(plaintext, key, dekId); - const decrypted = decryptField(encrypted, key); - expect(decrypted).toBe(plaintext); - }); - - it('encrypt → decrypt with AAD', () => { - const plaintext = 'With AAD'; - const aad = 'user_123:transcripts'; - const encrypted = encryptField(plaintext, key, dekId, aad); - const decrypted = decryptField(encrypted, key, aad); - expect(decrypted).toBe(plaintext); - }); - - it('rejects decryption with wrong AAD', () => { - const encrypted = encryptField('secret', key, dekId, 'correct_aad'); - expect(() => decryptField(encrypted, key, 'wrong_aad')).toThrow(); - }); - - it('rejects decryption with tampered ciphertext', () => { - const encrypted = encryptField('secret', key, dekId); - const tampered: EncryptedField = { ...encrypted, ct: 'deadbeef' }; - expect(() => decryptField(tampered, key)).toThrow(); - }); - - it('rejects decryption with tampered auth tag', () => { - const encrypted = encryptField('secret', key, dekId); - const tampered: EncryptedField = { ...encrypted, tag: '00'.repeat(16) }; - expect(() => decryptField(tampered, key)).toThrow(); - }); - - it('handles empty string', () => { - const encrypted = encryptField('', key, dekId); - const decrypted = decryptField(encrypted, key); - expect(decrypted).toBe(''); - }); - - it('handles unicode content', () => { - const plaintext = '日本語テスト 🔐 Ñoño'; - const encrypted = encryptField(plaintext, key, dekId); - const decrypted = decryptField(encrypted, key); - expect(decrypted).toBe(plaintext); - }); - - it('handles large payload (100 KB)', () => { - const plaintext = 'x'.repeat(100_000); - const encrypted = encryptField(plaintext, key, dekId); - const decrypted = decryptField(encrypted, key); - expect(decrypted).toBe(plaintext); - }); - - it('rejects wrong key size', () => { - const shortKey = randomBytes(16); - expect(() => encryptField('test', shortKey, dekId)).toThrow(/32-byte key/); - }); - - it('produces correct EncryptedField shape', () => { - const encrypted = encryptField('test', key, dekId); - expect(encrypted.__encrypted).toBe(true); - expect(encrypted.v).toBe(1); - expect(encrypted.alg).toBe('aes-256-gcm'); - expect(encrypted.dekId).toBe(dekId); - expect(encrypted.iv).toHaveLength(24); // 12 bytes = 24 hex chars - expect(encrypted.tag).toHaveLength(32); // 16 bytes = 32 hex chars - expect(encrypted.ct.length).toBeGreaterThan(0); - }); - - it('generates unique IVs per encryption', () => { - const e1 = encryptField('same', key, dekId); - const e2 = encryptField('same', key, dekId); - expect(e1.iv).not.toBe(e2.iv); - expect(e1.ct).not.toBe(e2.ct); - }); -}); - -// ── Type guard ────────────────────────────────────── - -describe('isEncryptedField', () => { - it('returns true for valid EncryptedField', () => { - const field: EncryptedField = { - __encrypted: true, - v: 1, - alg: 'aes-256-gcm', - ct: 'abc', - iv: '012', - tag: '345', - dekId: 'dek_1', - }; - expect(isEncryptedField(field)).toBe(true); - }); - - it('returns false for string', () => { - expect(isEncryptedField('hello')).toBe(false); - }); - - it('returns false for null', () => { - expect(isEncryptedField(null)).toBe(false); - }); - - it('returns false for undefined', () => { - expect(isEncryptedField(undefined)).toBe(false); - }); - - it('returns false for object without __encrypted', () => { - expect(isEncryptedField({ v: 1, ct: 'abc' })).toBe(false); - }); - - it('returns false for object with __encrypted: false', () => { - expect( - isEncryptedField({ __encrypted: false, v: 1, ct: 'a', iv: 'b', tag: 'c', dekId: 'd' }) - ).toBe(false); - }); -}); - -// ── Key providers ─────────────────────────────────── - -describe('MemoryKeyProvider', () => { - it('wrap → unwrap roundtrip', async () => { - const provider = new MemoryKeyProvider(); - const dek = generateAesKey(); - const { wrappedKey, mekVersion } = await provider.wrapKey(dek); - const unwrapped = await provider.unwrapKey(wrappedKey, mekVersion); - expect(unwrapped).toEqual(dek); - }); -}); - -describe('EnvKeyProvider', () => { - it('wrap → unwrap roundtrip with 64-char hex key', async () => { - const hexKey = randomBytes(32).toString('hex'); - const provider = new EnvKeyProvider(hexKey); - const dek = generateAesKey(); - const { wrappedKey, mekVersion } = await provider.wrapKey(dek); - const unwrapped = await provider.unwrapKey(wrappedKey, mekVersion); - expect(unwrapped).toEqual(dek); - }); - - it('wrap → unwrap roundtrip with short key (hashed to 32 bytes)', async () => { - const provider = new EnvKeyProvider('my-dev-secret-key'); - const dek = generateAesKey(); - const { wrappedKey, mekVersion } = await provider.wrapKey(dek); - const unwrapped = await provider.unwrapKey(wrappedKey, mekVersion); - expect(unwrapped).toEqual(dek); - }); - - it('throws on empty key', () => { - expect(() => new EnvKeyProvider('')).toThrow(/must not be empty/); - }); -}); - -// ── DEK cache ─────────────────────────────────────── - -describe('DekCache', () => { - let cache: DekCache; - - beforeEach(() => { - cache = new DekCache(1000, 3); // 1s TTL, max 3 entries - }); - - it('get returns null on miss', () => { - expect(cache.get('nonexistent')).toBeNull(); - }); - - it('set + get roundtrip', () => { - const key = generateAesKey(); - cache.set('dek_1', key); - expect(cache.get('dek_1')).toEqual(key); - }); - - it('expires entries after TTL', async () => { - const shortCache = new DekCache(50, 100); // 50ms TTL - const key = generateAesKey(); - shortCache.set('dek_1', key); - expect(shortCache.get('dek_1')).toEqual(key); - await new Promise(r => setTimeout(r, 60)); - expect(shortCache.get('dek_1')).toBeNull(); - }); - - it('evicts oldest on max size', () => { - cache.set('a', generateAesKey()); - cache.set('b', generateAesKey()); - cache.set('c', generateAesKey()); - // At max size (3), adding 'd' should evict 'a' - cache.set('d', generateAesKey()); - expect(cache.get('a')).toBeNull(); - expect(cache.get('d')).not.toBeNull(); - }); - - it('invalidate removes specific entry', () => { - cache.set('dek_1', generateAesKey()); - cache.invalidate('dek_1'); - expect(cache.get('dek_1')).toBeNull(); - }); - - it('tracks hit rate', () => { - cache.set('dek_1', generateAesKey()); - cache.recordHit(); - cache.recordHit(); - cache.recordMiss(); - expect(cache.hitRate).toBe(67); // 2/3 = 67% - }); -}); - -// ── Envelope ──────────────────────────────────────── - -describe('envelope', () => { - it('buildDekId produces correct format', () => { - expect(buildDekId('user_123', 'transcripts')).toBe('dek_user_123_transcripts'); - }); - - it('getOrCreateDek creates and caches a new DEK', async () => { - const provider = new MemoryKeyProvider(); - const store = new MemoryDekStore(); - const cache = new DekCache(); - - const dek = await getOrCreateDek('dek_u1_ctx', provider, store, cache); - expect(dek).toHaveLength(32); - - // Should be in store - const stored = await store.get('dek_u1_ctx'); - expect(stored).not.toBeNull(); - - // Should be cached — second call should return same key - const dek2 = await getOrCreateDek('dek_u1_ctx', provider, store, cache); - expect(dek2).toEqual(dek); - }); - - it('rewrapAllDeks re-wraps with new provider', async () => { - const oldProvider = new MemoryKeyProvider(undefined, 'old-v1'); - const newProvider = new MemoryKeyProvider(undefined, 'new-v1'); - const store = new MemoryDekStore(); - const cache = new DekCache(); - - // Create 3 DEKs with old provider - await getOrCreateDek('dek_1', oldProvider, store, cache); - await getOrCreateDek('dek_2', oldProvider, store, cache); - await getOrCreateDek('dek_3', oldProvider, store, cache); - - // Re-wrap - const count = await rewrapAllDeks(oldProvider, newProvider, store, cache); - expect(count).toBe(3); - - // Verify new provider can unwrap - const stored = await store.get('dek_1'); - expect(stored).not.toBeNull(); - expect(stored!.mekVersion).toBe('new-v1'); - - const unwrapped = await newProvider.unwrapKey(stored!.wrappedKey, stored!.mekVersion); - expect(unwrapped).toHaveLength(32); - }); -}); - -// ── FieldEncryptor (integration) ──────────────────── - -describe('FieldEncryptor', () => { - let encryptor: FieldEncryptor; - const ctx: FieldEncryptContext = { userId: 'user_42', context: 'notes' }; - - beforeEach(() => { - encryptor = createFieldEncryptor({ keyProvider: 'memory' }); - }); - - it('encrypt → decrypt roundtrip', async () => { - const encrypted = await encryptor.encrypt('Hello World', ctx); - expect(encrypted.__encrypted).toBe(true); - const decrypted = await encryptor.decrypt(encrypted, ctx); - expect(decrypted).toBe('Hello World'); - }); - - it('encryptBatch → decryptBatch roundtrip', async () => { - const plaintexts = ['one', 'two', 'three']; - const encrypted = await encryptor.encryptBatch(plaintexts, ctx); - expect(encrypted).toHaveLength(3); - const decrypted = await encryptor.decryptBatch(encrypted, ctx); - expect(decrypted).toEqual(plaintexts); - }); - - it('isEncrypted works via encryptor', async () => { - const encrypted = await encryptor.encrypt('test', ctx); - expect(encryptor.isEncrypted(encrypted)).toBe(true); - expect(encryptor.isEncrypted('plaintext')).toBe(false); - }); - - it('different users get different DEKs', async () => { - const ctx1: FieldEncryptContext = { userId: 'user_1', context: 'notes' }; - const ctx2: FieldEncryptContext = { userId: 'user_2', context: 'notes' }; - - const e1 = await encryptor.encrypt('same text', ctx1); - const e2 = await encryptor.encrypt('same text', ctx2); - - expect(e1.dekId).not.toBe(e2.dekId); - expect(e1.ct).not.toBe(e2.ct); - }); - - it('JSON-serialized array encryption roundtrip', async () => { - const transcript = [ - { role: 'user', content: 'Hello', ts: '2026-01-01T00:00:00Z' }, - { role: 'agent', content: 'Hi there!', ts: '2026-01-01T00:00:01Z' }, - ]; - const serialized = JSON.stringify(transcript); - const encrypted = await encryptor.encrypt(serialized, ctx); - const decrypted = await encryptor.decrypt(encrypted, ctx); - expect(JSON.parse(decrypted)).toEqual(transcript); - }); -}); - -// ── Factory config validation ─────────────────────── - -describe('createFieldEncryptor config', () => { - it('throws on unknown provider', () => { - expect(() => createFieldEncryptor({ keyProvider: 'nope' as never })).toThrow(/unknown/); - }); - - it('throws on env provider without key', () => { - expect(() => createFieldEncryptor({ keyProvider: 'env' })).toThrow(/encryptionKey/); - }); - - it('throws on akv provider without vaultUrl', () => { - expect(() => createFieldEncryptor({ keyProvider: 'akv', mekName: 'mek' })).toThrow( - /keyVaultUrl/ - ); - }); - - it('throws on akv provider without mekName', () => { - expect(() => - createFieldEncryptor({ keyProvider: 'akv', keyVaultUrl: 'https://kv.vault.azure.net' }) - ).toThrow(/mekName/); - }); - - it('env provider works with hex key', async () => { - const hexKey = randomBytes(32).toString('hex'); - const enc = createFieldEncryptor({ keyProvider: 'env', encryptionKey: hexKey }); - const ctx: FieldEncryptContext = { userId: 'u1', context: 'test' }; - const encrypted = await enc.encrypt('secret', ctx); - const decrypted = await enc.decrypt(encrypted, ctx); - expect(decrypted).toBe('secret'); - }); -}); - -// ── Encryption toggle (enabled/disabled) ──────────── - -describe('enabled: false (NullFieldEncryptor)', () => { - const ctx: FieldEncryptContext = { userId: 'u1', context: 'test' }; - - it('createFieldEncryptor returns NullFieldEncryptor when enabled=false', () => { - const enc = createFieldEncryptor({ enabled: false, keyProvider: 'memory' }); - expect(enc).toBeInstanceOf(NullFieldEncryptor); - }); - - it('createFieldEncryptor returns real FieldEncryptor when enabled=true', () => { - const enc = createFieldEncryptor({ enabled: true, keyProvider: 'memory' }); - expect(enc).not.toBeInstanceOf(NullFieldEncryptor); - expect(enc).toBeInstanceOf(FieldEncryptor); - }); - - it('createFieldEncryptor returns real FieldEncryptor when enabled is omitted', () => { - const enc = createFieldEncryptor({ keyProvider: 'memory' }); - expect(enc).not.toBeInstanceOf(NullFieldEncryptor); - }); - - it('encrypt returns sentinel with plaintext in ct field', async () => { - const enc = createFieldEncryptor({ enabled: false, keyProvider: 'memory' }); - const result = await enc.encrypt('hello world', ctx); - expect(result.__encrypted).toBe(true); - expect(result.ct).toBe('hello world'); - expect(result.iv).toBe('disabled'); - expect(result.tag).toBe('disabled'); - expect(result.dekId).toBe('disabled'); - }); - - it('decrypt returns plaintext from ct field', async () => { - const enc = createFieldEncryptor({ enabled: false, keyProvider: 'memory' }); - const encrypted = await enc.encrypt('secret', ctx); - const decrypted = await enc.decrypt(encrypted, ctx); - expect(decrypted).toBe('secret'); - }); - - it('encryptBatch returns sentinels for all items', async () => { - const enc = createFieldEncryptor({ enabled: false, keyProvider: 'memory' }); - const results = await enc.encryptBatch(['a', 'b', 'c'], ctx); - expect(results).toHaveLength(3); - expect(results[0].ct).toBe('a'); - expect(results[1].ct).toBe('b'); - expect(results[2].ct).toBe('c'); - }); - - it('decryptBatch returns plaintexts from ct fields', async () => { - const enc = createFieldEncryptor({ enabled: false, keyProvider: 'memory' }); - const encrypted = await enc.encryptBatch(['x', 'y'], ctx); - const decrypted = await enc.decryptBatch(encrypted, ctx); - expect(decrypted).toEqual(['x', 'y']); - }); - - it('isEncrypted returns true for disabled sentinel', async () => { - const enc = createFieldEncryptor({ enabled: false, keyProvider: 'memory' }); - const result = await enc.encrypt('test', ctx); - expect(enc.isEncrypted(result)).toBe(true); - }); -}); - -// ── Migration ─────────────────────────────────────── - -describe('migrateDocuments', () => { - it('encrypts plaintext fields and skips already-encrypted', async () => { - const encryptor = createFieldEncryptor({ keyProvider: 'memory' }); - const ctx: FieldEncryptContext = { userId: 'u1', context: 'notes' }; - - const alreadyEncrypted = await encryptor.encrypt('old', ctx); - const docs = [ - { id: '1', body: 'plaintext note' }, - { id: '2', body: alreadyEncrypted }, - { id: '3', body: 'another note' }, - { id: '4', body: null }, - ]; - - const written: Array<{ id: string; body: EncryptedField }> = []; - - const result = await migrateDocuments({ - fetchBatch: async (offset, batchSize) => docs.slice(offset, offset + batchSize), - getId: doc => doc.id, - getField: doc => doc.body, - encryptValue: plaintext => encryptor.encrypt(plaintext, ctx), - writeBack: async (doc, encrypted) => { - written.push({ id: doc.id, body: encrypted }); - }, - batchSize: 10, - }); - - expect(result.scanned).toBe(4); - expect(result.encrypted).toBe(2); // id 1 + id 3 - expect(result.skipped).toBe(2); // id 2 (already encrypted) + id 4 (null) - expect(result.errors).toBe(0); - expect(written).toHaveLength(2); - }); - - it('dry run does not write', async () => { - const encryptor = createFieldEncryptor({ keyProvider: 'memory' }); - const ctx: FieldEncryptContext = { userId: 'u1', context: 'notes' }; - - const docs = [{ id: '1', body: 'plaintext' }]; - let writeCount = 0; - - const result = await migrateDocuments({ - fetchBatch: async (offset, batchSize) => docs.slice(offset, offset + batchSize), - getId: doc => doc.id, - getField: doc => doc.body, - encryptValue: plaintext => encryptor.encrypt(plaintext, ctx), - writeBack: async () => { - writeCount++; - }, - dryRun: true, - }); - - expect(result.encrypted).toBe(1); - expect(writeCount).toBe(0); - }); -}); - -// ── CosmosDekStore ────────────────────────────────── - -describe('CosmosDekStore', () => { - function createMockContainer() { - const docs = new Map>(); - - const container = { - item: (id: string, _pk: string) => ({ - read: async () => { - const resource = docs.get(id) as T | undefined; - if (!resource) { - const err = new Error('Not found') as Error & { code: number }; - err.code = 404; - throw err; - } - return { resource }; - }, - delete: async () => { - if (!docs.has(id)) { - const err = new Error('Not found') as Error & { code: number }; - err.code = 404; - throw err; - } - docs.delete(id); - }, - }), - items: { - upsert: async (doc: Record) => { - docs.set(doc.id as string, doc); - }, - query: (_sql: string) => ({ - fetchAll: async () => ({ - resources: [...docs.values()].map(d => ({ dekId: d.dekId })), - }), - }), - }, - }; - return container as unknown as import('@azure/cosmos').Container; - } - - it('put and get a DEK', async () => { - const store = new CosmosDekStore(createMockContainer()); - const dek = { - dekId: 'dek_test', - wrappedKey: 'aabbcc', - mekVersion: 'v1', - createdAt: '2026-01-01T00:00:00Z', - }; - await store.put(dek); - const got = await store.get('dek_test'); - expect(got).toEqual(dek); - }); - - it('get returns null for missing DEK', async () => { - const store = new CosmosDekStore(createMockContainer()); - const got = await store.get('nonexistent'); - expect(got).toBeNull(); - }); - - it('listIds returns all stored DEK IDs', async () => { - const store = new CosmosDekStore(createMockContainer()); - await store.put({ - dekId: 'dek_a', - wrappedKey: '11', - mekVersion: 'v1', - createdAt: '2026-01-01T00:00:00Z', - }); - await store.put({ - dekId: 'dek_b', - wrappedKey: '22', - mekVersion: 'v1', - createdAt: '2026-01-01T00:00:00Z', - }); - const ids = await store.listIds(); - expect(ids).toContain('dek_a'); - expect(ids).toContain('dek_b'); - expect(ids.length).toBe(2); - }); - - it('delete removes a DEK', async () => { - const store = new CosmosDekStore(createMockContainer()); - await store.put({ - dekId: 'dek_del', - wrappedKey: '33', - mekVersion: 'v1', - createdAt: '2026-01-01T00:00:00Z', - }); - await store.delete('dek_del'); - expect(await store.get('dek_del')).toBeNull(); - }); - - it('delete is idempotent (no error on missing)', async () => { - const store = new CosmosDekStore(createMockContainer()); - await expect(store.delete('nonexistent')).resolves.toBeUndefined(); - }); - - it('put overwrites existing DEK (upsert)', async () => { - const store = new CosmosDekStore(createMockContainer()); - await store.put({ - dekId: 'dek_up', - wrappedKey: 'old', - mekVersion: 'v1', - createdAt: '2026-01-01T00:00:00Z', - }); - await store.put({ - dekId: 'dek_up', - wrappedKey: 'new', - mekVersion: 'v2', - createdAt: '2026-01-01T00:00:00Z', - }); - const got = await store.get('dek_up'); - expect(got?.wrappedKey).toBe('new'); - expect(got?.mekVersion).toBe('v2'); - }); -}); diff --git a/vendor/bytelyst/field-encrypt/src/index.ts b/vendor/bytelyst/field-encrypt/src/index.ts deleted file mode 100644 index 7ea2e0d..0000000 --- a/vendor/bytelyst/field-encrypt/src/index.ts +++ /dev/null @@ -1,58 +0,0 @@ -/** - * @bytelyst/field-encrypt - * - * Application-layer field encryption for ByteLyst ecosystem. - * AES-256-GCM with envelope encryption (MEK → DEK). - * - * @example - * ```typescript - * import { createFieldEncryptor } from '@bytelyst/field-encrypt'; - * - * const encryptor = createFieldEncryptor({ - * keyProvider: 'memory', // 'akv' | 'env' | 'memory' - * }); - * - * const encrypted = await encryptor.encrypt('sensitive data', { - * userId: 'user_123', - * context: 'transcripts', - * }); - * - * const plaintext = await encryptor.decrypt(encrypted, { - * userId: 'user_123', - * context: 'transcripts', - * }); - * ``` - */ - -// ── Main API ──────────────────────────────────────── -export { createFieldEncryptor, FieldEncryptor, NullFieldEncryptor } from './field-encryptor.js'; - -// ── Type guards ───────────────────────────────────── -export { isEncryptedField } from './guards.js'; - -// ── Types ─────────────────────────────────────────── -export type { - EncryptedField, - WrappedDek, - FieldEncryptContext, - FieldEncryptorConfig, - KeyProvider, - KeyProviderType, - DekStore, -} from './types.js'; - -// ── Low-level (for custom integrations) ───────────── -export { encryptField, decryptField, generateAesKey } from './aes-gcm.js'; -export { buildDekId, getOrCreateDek, rewrapAllDeks } from './envelope.js'; -export { DekCache } from './key-cache.js'; -export { MemoryDekStore } from './dek-store-memory.js'; -export { CosmosDekStore } from './dek-store-cosmos.js'; - -// ── Key providers (for direct use / testing) ──────── -export { MemoryKeyProvider } from './key-provider-memory.js'; -export { EnvKeyProvider } from './key-provider-env.js'; -export { AkvKeyProvider } from './key-provider-akv.js'; - -// ── Migration ─────────────────────────────────────── -export { migrateDocuments } from './migration.js'; -export type { MigrationResult, MigrateDocumentsOptions } from './migration.js'; diff --git a/vendor/bytelyst/field-encrypt/src/key-cache.ts b/vendor/bytelyst/field-encrypt/src/key-cache.ts deleted file mode 100644 index 8668252..0000000 --- a/vendor/bytelyst/field-encrypt/src/key-cache.ts +++ /dev/null @@ -1,94 +0,0 @@ -/** - * @bytelyst/field-encrypt — DEK cache - * - * In-memory LRU cache with TTL for unwrapped DEKs. - * Avoids repeated AKV round-trips on every encrypt/decrypt. - */ - -interface CacheEntry { - key: Buffer; - expiresAt: number; -} - -export class DekCache { - private readonly cache = new Map(); - private readonly ttlMs: number; - private readonly maxSize: number; - - constructor(ttlMs: number = 15 * 60 * 1000, maxSize: number = 1000) { - this.ttlMs = ttlMs; - this.maxSize = maxSize; - } - - /** Get an unwrapped DEK from cache. Returns null on miss or expiry. */ - get(dekId: string): Buffer | null { - const entry = this.cache.get(dekId); - if (!entry) return null; - - if (Date.now() > entry.expiresAt) { - this.cache.delete(dekId); - return null; - } - - // Move to end (LRU refresh) - this.cache.delete(dekId); - this.cache.set(dekId, entry); - return entry.key; - } - - /** Store an unwrapped DEK in cache. */ - set(dekId: string, key: Buffer): void { - // Evict oldest if at max size - if (this.cache.size >= this.maxSize && !this.cache.has(dekId)) { - const oldestKey = this.cache.keys().next().value; - if (oldestKey !== undefined) { - this.cache.delete(oldestKey); - } - } - - this.cache.set(dekId, { - key, - expiresAt: Date.now() + this.ttlMs, - }); - } - - /** Invalidate a specific DEK (e.g., after rotation). */ - invalidate(dekId: string): void { - this.cache.delete(dekId); - } - - /** Clear all cached DEKs. */ - clear(): void { - this.cache.clear(); - } - - /** Current cache size. */ - get size(): number { - return this.cache.size; - } - - /** Cache hit rate stats. */ - private _hits = 0; - private _misses = 0; - - /** Record a cache hit (called internally). */ - recordHit(): void { - this._hits++; - } - /** Record a cache miss (called internally). */ - recordMiss(): void { - this._misses++; - } - - /** Get hit rate as a percentage (0-100). */ - get hitRate(): number { - const total = this._hits + this._misses; - return total === 0 ? 0 : Math.round((this._hits / total) * 100); - } - - /** Reset stats counters. */ - resetStats(): void { - this._hits = 0; - this._misses = 0; - } -} diff --git a/vendor/bytelyst/field-encrypt/src/key-provider-akv.ts b/vendor/bytelyst/field-encrypt/src/key-provider-akv.ts deleted file mode 100644 index e2a9ff5..0000000 --- a/vendor/bytelyst/field-encrypt/src/key-provider-akv.ts +++ /dev/null @@ -1,68 +0,0 @@ -/** - * @bytelyst/field-encrypt — Azure Key Vault key provider - * - * Production provider — uses AKV RSA keys for DEK wrapping. - * Requires @azure/keyvault-keys and @azure/identity as peer deps. - */ - -import type { KeyProvider } from './types.js'; - -/** - * Azure Key Vault key provider. - * - * Uses RSA-OAEP wrapping — the MEK never leaves AKV. - * Requires: - * - @azure/keyvault-keys (CryptographyClient) - * - @azure/identity (DefaultAzureCredential) - * - AKV RBAC: Key Vault Crypto User role on the managed identity - */ -export class AkvKeyProvider implements KeyProvider { - private readonly vaultUrl: string; - private readonly mekName: string; - private cryptoClient: unknown | null = null; - - constructor(vaultUrl: string, mekName: string) { - if (!vaultUrl) throw new Error('AkvKeyProvider: vaultUrl is required'); - if (!mekName) throw new Error('AkvKeyProvider: mekName is required'); - this.vaultUrl = vaultUrl; - this.mekName = mekName; - } - - private async getClient(): Promise<{ - wrapKey(alg: string, key: Uint8Array): Promise<{ result: Uint8Array }>; - unwrapKey(alg: string, key: Uint8Array): Promise<{ result: Uint8Array }>; - }> { - if (this.cryptoClient) return this.cryptoClient as never; - - // Dynamic import to keep peer deps optional - const { KeyClient, CryptographyClient } = await import('@azure/keyvault-keys'); - const { DefaultAzureCredential } = await import('@azure/identity'); - - const credential = new DefaultAzureCredential(); - const keyClient = new KeyClient(this.vaultUrl, credential); - const key = await keyClient.getKey(this.mekName); - - if (!key.id) { - throw new Error(`AkvKeyProvider: MEK '${this.mekName}' not found in ${this.vaultUrl}`); - } - - this.cryptoClient = new CryptographyClient(key.id, credential); - return this.cryptoClient as never; - } - - async wrapKey(dek: Buffer): Promise<{ wrappedKey: string; mekVersion: string }> { - const client = await this.getClient(); - const result = await client.wrapKey('RSA-OAEP-256', new Uint8Array(dek)); - return { - wrappedKey: Buffer.from(result.result).toString('hex'), - mekVersion: this.mekName, - }; - } - - async unwrapKey(wrappedKeyHex: string, _mekVersion: string): Promise { - const client = await this.getClient(); - const wrappedBytes = new Uint8Array(Buffer.from(wrappedKeyHex, 'hex')); - const result = await client.unwrapKey('RSA-OAEP-256', wrappedBytes); - return Buffer.from(result.result); - } -} diff --git a/vendor/bytelyst/field-encrypt/src/key-provider-env.ts b/vendor/bytelyst/field-encrypt/src/key-provider-env.ts deleted file mode 100644 index 4b7de08..0000000 --- a/vendor/bytelyst/field-encrypt/src/key-provider-env.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * @bytelyst/field-encrypt — Environment variable key provider - * - * For dev/staging — uses a hex-encoded symmetric key from an env var. - * Matches the existing MFA pattern (AUTH_TOTP_ENCRYPTION_KEY). - * - * Wrapping uses AES-256-GCM with the env key as MEK. - */ - -import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'node:crypto'; -import type { KeyProvider } from './types.js'; - -const ALGORITHM = 'aes-256-gcm'; -const IV_BYTES = 12; - -export class EnvKeyProvider implements KeyProvider { - private readonly mek: Buffer; - private readonly version: string; - - /** - * @param keyHex - Hex-encoded 32-byte key (64 hex chars). - * If shorter, it will be SHA-256 hashed to derive a 32-byte key. - */ - constructor(keyHex: string) { - if (!keyHex || keyHex.length === 0) { - throw new Error('EnvKeyProvider: encryption key must not be empty'); - } - - if (keyHex.length === 64) { - this.mek = Buffer.from(keyHex, 'hex'); - } else { - // Hash to 32 bytes — same approach as existing MFA encryption - this.mek = createHash('sha256').update(keyHex).digest(); - } - - this.version = 'env-v1'; - } - - async wrapKey(dek: Buffer): Promise<{ wrappedKey: string; mekVersion: string }> { - const iv = randomBytes(IV_BYTES); - const cipher = createCipheriv(ALGORITHM, this.mek, iv); - let encrypted = cipher.update(dek); - encrypted = Buffer.concat([encrypted, cipher.final()]); - const tag = cipher.getAuthTag(); - - const wrapped = Buffer.concat([iv, tag, encrypted]); - return { wrappedKey: wrapped.toString('hex'), mekVersion: this.version }; - } - - async unwrapKey(wrappedKeyHex: string, _mekVersion: string): Promise { - const wrapped = Buffer.from(wrappedKeyHex, 'hex'); - const iv = wrapped.subarray(0, IV_BYTES); - const tag = wrapped.subarray(IV_BYTES, IV_BYTES + 16); - const ciphertext = wrapped.subarray(IV_BYTES + 16); - - const decipher = createDecipheriv(ALGORITHM, this.mek, iv); - decipher.setAuthTag(tag); - let decrypted = decipher.update(ciphertext); - decrypted = Buffer.concat([decrypted, decipher.final()]); - return decrypted; - } -} diff --git a/vendor/bytelyst/field-encrypt/src/key-provider-memory.ts b/vendor/bytelyst/field-encrypt/src/key-provider-memory.ts deleted file mode 100644 index e586798..0000000 --- a/vendor/bytelyst/field-encrypt/src/key-provider-memory.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * @bytelyst/field-encrypt — In-memory key provider - * - * For unit tests — no external dependencies. - * Generates a random MEK on instantiation. Wrapping is just XOR for simplicity in tests, - * but uses AES-256-GCM to match production semantics. - */ - -import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'; -import type { KeyProvider } from './types.js'; - -const ALGORITHM = 'aes-256-gcm'; -const IV_BYTES = 12; - -export class MemoryKeyProvider implements KeyProvider { - private readonly mek: Buffer; - private readonly version: string; - - constructor(mek?: Buffer, version?: string) { - this.mek = mek ?? randomBytes(32); - this.version = version ?? 'memory-v1'; - } - - async wrapKey(dek: Buffer): Promise<{ wrappedKey: string; mekVersion: string }> { - const iv = randomBytes(IV_BYTES); - const cipher = createCipheriv(ALGORITHM, this.mek, iv); - let encrypted = cipher.update(dek); - encrypted = Buffer.concat([encrypted, cipher.final()]); - const tag = cipher.getAuthTag(); - - // Format: iv (12) + tag (16) + ciphertext - const wrapped = Buffer.concat([iv, tag, encrypted]); - return { wrappedKey: wrapped.toString('hex'), mekVersion: this.version }; - } - - async unwrapKey(wrappedKeyHex: string, _mekVersion: string): Promise { - const wrapped = Buffer.from(wrappedKeyHex, 'hex'); - const iv = wrapped.subarray(0, IV_BYTES); - const tag = wrapped.subarray(IV_BYTES, IV_BYTES + 16); - const ciphertext = wrapped.subarray(IV_BYTES + 16); - - const decipher = createDecipheriv(ALGORITHM, this.mek, iv); - decipher.setAuthTag(tag); - let decrypted = decipher.update(ciphertext); - decrypted = Buffer.concat([decrypted, decipher.final()]); - return decrypted; - } -} diff --git a/vendor/bytelyst/field-encrypt/src/migration.ts b/vendor/bytelyst/field-encrypt/src/migration.ts deleted file mode 100644 index 978c4ac..0000000 --- a/vendor/bytelyst/field-encrypt/src/migration.ts +++ /dev/null @@ -1,110 +0,0 @@ -/** - * @bytelyst/field-encrypt — Migration helpers - * - * Utilities for encrypting existing plaintext fields in-place. - * Idempotent — skips already-encrypted fields via __encrypted sentinel. - */ - -import type { EncryptedField } from './types.js'; -import { isEncryptedField } from './guards.js'; - -/** Result of a migration run. */ -export interface MigrationResult { - /** Total documents scanned. */ - scanned: number; - /** Documents encrypted in this run. */ - encrypted: number; - /** Documents skipped (already encrypted). */ - skipped: number; - /** Documents that failed to encrypt. */ - errors: number; - /** Error details (first 10). */ - errorDetails: Array<{ id: string; error: string }>; -} - -/** Options for migrateDocuments(). */ -export interface MigrateDocumentsOptions { - /** Fetch a batch of documents. Return empty array when done. */ - fetchBatch: (offset: number, batchSize: number) => Promise; - /** Get the document ID for logging. */ - getId: (doc: T) => string; - /** Get the field value to check/encrypt. */ - getField: (doc: T) => unknown; - /** Encrypt the plaintext value. Returns the EncryptedField. */ - encryptValue: (plaintext: string, doc: T) => Promise; - /** Write the encrypted value back to the store. */ - writeBack: (doc: T, encrypted: EncryptedField) => Promise; - /** Batch size (default: 100). */ - batchSize?: number; - /** If true, don't write — just count. */ - dryRun?: boolean; - /** Progress callback. */ - onProgress?: (result: MigrationResult) => void; -} - -/** - * Migrate plaintext fields to encrypted fields in batches. - * - * Idempotent: skips documents where the field is already an EncryptedField. - */ -export async function migrateDocuments( - options: MigrateDocumentsOptions -): Promise { - const batchSize = options.batchSize ?? 100; - const result: MigrationResult = { - scanned: 0, - encrypted: 0, - skipped: 0, - errors: 0, - errorDetails: [], - }; - - let offset = 0; - let batch: T[]; - - do { - batch = await options.fetchBatch(offset, batchSize); - - for (const doc of batch) { - result.scanned++; - const fieldValue = options.getField(doc); - - // Skip already-encrypted - if (isEncryptedField(fieldValue)) { - result.skipped++; - continue; - } - - // Skip null/undefined - if (fieldValue == null) { - result.skipped++; - continue; - } - - const plaintext = typeof fieldValue === 'string' ? fieldValue : JSON.stringify(fieldValue); - - try { - const encrypted = await options.encryptValue(plaintext, doc); - - if (!options.dryRun) { - await options.writeBack(doc, encrypted); - } - - result.encrypted++; - } catch (err) { - result.errors++; - if (result.errorDetails.length < 10) { - result.errorDetails.push({ - id: options.getId(doc), - error: err instanceof Error ? err.message : String(err), - }); - } - } - } - - offset += batch.length; - options.onProgress?.(result); - } while (batch.length === batchSize); - - return result; -} diff --git a/vendor/bytelyst/field-encrypt/src/types.ts b/vendor/bytelyst/field-encrypt/src/types.ts deleted file mode 100644 index 2273fcd..0000000 --- a/vendor/bytelyst/field-encrypt/src/types.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** - * @bytelyst/field-encrypt — Types - * - * Core type definitions for field-level encryption. - */ - -/** Encrypted field stored in Cosmos DB or SQLite. */ -export interface EncryptedField { - /** Sentinel — always true for encrypted fields. */ - readonly __encrypted: true; - /** Schema version for future algorithm changes. */ - readonly v: 1; - /** Algorithm identifier. */ - readonly alg: 'aes-256-gcm'; - /** Ciphertext (hex-encoded). */ - readonly ct: string; - /** Initialization vector (hex-encoded, 12 bytes / 24 hex chars). */ - readonly iv: string; - /** GCM authentication tag (hex-encoded, 16 bytes / 32 hex chars). */ - readonly tag: string; - /** DEK identifier — identifies which key to unwrap for decryption. */ - readonly dekId: string; -} - -/** Wrapped DEK stored alongside data (e.g., in a `_encryption_keys` Cosmos container). */ -export interface WrappedDek { - /** Unique DEK identifier, e.g. `dek_user123_transcripts`. */ - readonly dekId: string; - /** Wrapped (encrypted) DEK bytes (hex-encoded). */ - readonly wrappedKey: string; - /** MEK name/version used to wrap this DEK. */ - readonly mekVersion: string; - /** ISO 8601 creation timestamp. */ - readonly createdAt: string; -} - -/** Options for encrypt/decrypt operations. */ -export interface FieldEncryptContext { - /** Scope for DEK isolation (typically userId). */ - readonly userId: string; - /** Additional context for DEK naming and AAD (e.g., 'transcripts', 'notes'). */ - readonly context: string; -} - -/** Key provider — abstraction over key storage backends. */ -export interface KeyProvider { - /** Wrap (encrypt) a DEK with the master key. Returns hex-encoded wrapped key + mek version string. */ - wrapKey(dek: Buffer): Promise<{ wrappedKey: string; mekVersion: string }>; - /** Unwrap (decrypt) a wrapped DEK. Returns raw DEK buffer. */ - unwrapKey(wrappedKeyHex: string, mekVersion: string): Promise; -} - -/** Supported key provider types. */ -export type KeyProviderType = 'akv' | 'env' | 'memory'; - -/** DEK store — abstraction over DEK persistence. */ -export interface DekStore { - /** Get a wrapped DEK by its ID. Returns null if not found. */ - get(dekId: string): Promise; - /** Store a wrapped DEK. */ - put(dek: WrappedDek): Promise; - /** List all DEK IDs (for rotation). */ - listIds(): Promise; - /** Delete a DEK. */ - delete(dekId: string): Promise; -} - -/** Configuration for createFieldEncryptor(). */ -export interface FieldEncryptorConfig { - /** - * Master toggle — set to false to disable encryption entirely. - * When disabled, encrypt() returns plaintext as-is and decrypt() passes through. - * Default: true. - */ - enabled?: boolean; - /** Key provider type. */ - keyProvider: KeyProviderType; - /** Azure Key Vault URL (required for 'akv' provider). */ - keyVaultUrl?: string; - /** MEK name in AKV (required for 'akv' provider). */ - mekName?: string; - /** Hex-encoded encryption key (required for 'env' provider). */ - encryptionKey?: string; - /** DEK cache TTL in milliseconds (default: 15 minutes). */ - dekCacheTtlMs?: number; - /** DEK cache max size (default: 1000). */ - dekCacheMaxSize?: number; - /** DEK store implementation (default: in-memory). */ - dekStore?: DekStore; -} diff --git a/vendor/bytelyst/field-encrypt/tsconfig.json b/vendor/bytelyst/field-encrypt/tsconfig.json deleted file mode 100644 index 5edad81..0000000 --- a/vendor/bytelyst/field-encrypt/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/vendor/bytelyst/gentle-notifications/package.json b/vendor/bytelyst/gentle-notifications/package.json deleted file mode 100644 index 71dd299..0000000 --- a/vendor/bytelyst/gentle-notifications/package.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "@bytelyst/gentle-notifications", - "version": "0.1.5", - "type": "module", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "scripts": { - "build": "tsc" - }, - "devDependencies": { - "typescript": "^5.7.3" - } -} diff --git a/vendor/bytelyst/gentle-notifications/src/client.test.ts b/vendor/bytelyst/gentle-notifications/src/client.test.ts deleted file mode 100644 index 22db4ce..0000000 --- a/vendor/bytelyst/gentle-notifications/src/client.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { createGentleNotificationEngine, FORBIDDEN_PHRASES } from './client.js'; - -describe('createGentleNotificationEngine', () => { - it('should return default config', () => { - const engine = createGentleNotificationEngine(); - const config = engine.getDefaultConfig(); - expect(config.maxPerHour).toBe(3); - expect(config.tone).toBe('encouraging'); - expect(config.adaptiveFrequency).toBe(true); - expect(config.dismissCount).toBe(0); - expect(config.suppressThreshold).toBe(5); - }); - - it('should return a message for known type', () => { - const engine = createGentleNotificationEngine(); - const msg = engine.getMessage('reminder'); - expect(msg.title.length).toBeGreaterThan(0); - expect(msg.body.length).toBeGreaterThan(0); - expect(['encouraging', 'neutral', 'minimal']).toContain(msg.tone); - }); - - it('should return fallback for unknown type', () => { - const engine = createGentleNotificationEngine(); - const msg = engine.getMessage('nonexistent_type'); - expect(msg.title).toBe('Hey'); - expect(msg.body.length).toBeGreaterThan(0); - }); - - it('should suppress when dismiss threshold reached', () => { - const engine = createGentleNotificationEngine(); - const config = { ...engine.getDefaultConfig(), dismissCount: 5 }; - expect(engine.shouldSuppress(config)).toBe(true); - }); - - it('should not suppress when below threshold', () => { - const engine = createGentleNotificationEngine(); - const config = { ...engine.getDefaultConfig(), dismissCount: 2 }; - expect(engine.shouldSuppress(config)).toBe(false); - }); - - it('should record dismissal and reduce frequency', () => { - const engine = createGentleNotificationEngine(); - let config = engine.getDefaultConfig(); - expect(config.dismissCount).toBe(0); - - config = engine.recordDismissal(config); - expect(config.dismissCount).toBe(1); - - // Record enough to trigger suppression - for (let i = 0; i < 4; i++) { - config = engine.recordDismissal(config); - } - expect(config.dismissCount).toBe(5); - expect(config.maxPerHour).toBeLessThan(3); - }); - - it('should reset dismissals', () => { - const engine = createGentleNotificationEngine(); - let config = engine.getDefaultConfig(); - config = engine.recordDismissal(config); - config = engine.recordDismissal(config); - expect(config.dismissCount).toBe(2); - - config = engine.resetDismissals(config); - expect(config.dismissCount).toBe(0); - }); - - it('should allow registering custom messages', () => { - const engine = createGentleNotificationEngine(); - engine.registerMessages('fasting', [ - { title: 'Fasting Reminder', body: 'Your body is doing great things!', tone: 'encouraging' }, - ]); - const msg = engine.getMessage('fasting'); - expect(msg.title).toBe('Fasting Reminder'); - }); - - it('should export FORBIDDEN_PHRASES', () => { - expect(FORBIDDEN_PHRASES).toContain("You haven't"); - expect(FORBIDDEN_PHRASES).toContain('You failed'); - expect(FORBIDDEN_PHRASES.length).toBeGreaterThanOrEqual(8); - }); - - it('should detect forbidden phrases', () => { - const engine = createGentleNotificationEngine(); - expect(engine.containsForbiddenPhrase("You haven't done this yet")).toBe(true); - expect(engine.containsForbiddenPhrase('Great job today!')).toBe(false); - expect(engine.containsForbiddenPhrase('you failed the test')).toBe(true); - }); - - it('should respect custom initial config', () => { - const engine = createGentleNotificationEngine({ maxPerHour: 10, tone: 'minimal' }); - const config = engine.getDefaultConfig(); - expect(config.maxPerHour).toBe(10); - expect(config.tone).toBe('minimal'); - }); -}); diff --git a/vendor/bytelyst/gentle-notifications/src/client.ts b/vendor/bytelyst/gentle-notifications/src/client.ts deleted file mode 100644 index c4444da..0000000 --- a/vendor/bytelyst/gentle-notifications/src/client.ts +++ /dev/null @@ -1,157 +0,0 @@ -/** - * Neurodivergent-friendly notification messaging system. - * - * Encouraging tone, adaptive frequency, forbidden phrases. - * Pure client-side TS — no backend dependency. - */ - -import type { GentleMessage, GentleNotificationConfig, GentleNotificationEngine } from './types.js'; - -export const FORBIDDEN_PHRASES: readonly string[] = [ - "You haven't", - 'You forgot', - "Don't forget", - 'You should have', - "Why didn't you", - 'You missed', - 'You failed', - 'You need to', -] as const; - -const DEFAULT_MESSAGES: Record = { - reminder: [ - { - title: 'Gentle Reminder', - body: 'Whenever you are ready, there is something waiting for you.', - tone: 'encouraging', - }, - { - title: 'Quick Note', - body: 'No rush — just a friendly nudge when the time feels right.', - tone: 'encouraging', - }, - { - title: 'Hey there', - body: 'Take your time. We will be here when you are ready.', - tone: 'neutral', - }, - ], - progress: [ - { - title: 'Nice Progress', - body: 'Look at what you have accomplished — every step matters!', - tone: 'encouraging', - }, - { - title: 'Moving Forward', - body: 'You are making progress at your own pace. That is perfect.', - tone: 'encouraging', - }, - ], - check_in: [ - { - title: 'Check In', - body: 'How are you feeling? Remember, there is no wrong answer.', - tone: 'encouraging', - }, - { title: 'Quick Check', body: 'Just checking in — hope you are doing well!', tone: 'neutral' }, - ], - streak: [ - { - title: 'Streak Update', - body: 'Your consistency is impressive — keep it going if it feels right!', - tone: 'encouraging', - }, - ], - idle: [ - { - title: 'Welcome Back', - body: 'Great to see you again — no judgment, just glad you are here!', - tone: 'encouraging', - }, - { - title: 'Hi Again', - body: 'Whenever you are ready to jump back in, we are here.', - tone: 'neutral', - }, - ], -}; - -export function createGentleNotificationEngine( - initialConfig?: Partial -): GentleNotificationEngine { - const messagePools: Record = { ...DEFAULT_MESSAGES }; - - function getDefaultConfig(): GentleNotificationConfig { - return { - maxPerHour: 3, - tone: 'encouraging', - adaptiveFrequency: true, - dismissCount: 0, - suppressThreshold: 5, - ...initialConfig, - }; - } - - function getMessage(type: string, config?: GentleNotificationConfig): GentleMessage { - const tone = config?.tone ?? 'encouraging'; - const pool = messagePools[type]; - - if (!pool || pool.length === 0) { - return { - title: 'Hey', - body: 'Hope you are having a good day!', - tone, - }; - } - - // Filter by tone if possible, fallback to any - const toneFiltered = pool.filter(m => m.tone === tone); - const candidates = toneFiltered.length > 0 ? toneFiltered : pool; - const index = Math.floor(Math.random() * candidates.length); - return candidates[index]; - } - - function shouldSuppress(config: GentleNotificationConfig): boolean { - if (config.adaptiveFrequency && config.dismissCount >= config.suppressThreshold) { - return true; - } - return false; - } - - function recordDismissal(config: GentleNotificationConfig): GentleNotificationConfig { - const newConfig = { ...config, dismissCount: config.dismissCount + 1 }; - if (newConfig.adaptiveFrequency && newConfig.dismissCount >= newConfig.suppressThreshold) { - newConfig.maxPerHour = Math.max(1, Math.floor(newConfig.maxPerHour / 2)); - } - return newConfig; - } - - function resetDismissals(config: GentleNotificationConfig): GentleNotificationConfig { - return { ...config, dismissCount: 0 }; - } - - function registerMessages(type: string, messages: GentleMessage[]): void { - messagePools[type] = [...(messagePools[type] ?? []), ...messages]; - } - - function getForbiddenPhrases(): readonly string[] { - return FORBIDDEN_PHRASES; - } - - function containsForbiddenPhrase(text: string): boolean { - const lower = text.toLowerCase(); - return FORBIDDEN_PHRASES.some(phrase => lower.includes(phrase.toLowerCase())); - } - - return { - getDefaultConfig, - getMessage, - shouldSuppress, - recordDismissal, - resetDismissals, - registerMessages, - getForbiddenPhrases, - containsForbiddenPhrase, - }; -} diff --git a/vendor/bytelyst/gentle-notifications/src/index.ts b/vendor/bytelyst/gentle-notifications/src/index.ts deleted file mode 100644 index 2be8615..0000000 --- a/vendor/bytelyst/gentle-notifications/src/index.ts +++ /dev/null @@ -1,44 +0,0 @@ -export interface GentleConfig { - maxPerDay: number; - quietHoursStart: number; - quietHoursEnd: number; - minIntervalMinutes: number; - dismissCount?: number; -} - -const FORBIDDEN_PHRASES = [ - "you failed", - "you broke", - "you gave up", - "disappointed", - "shame", - "guilt", - "lazy", - "weak", - "cheat", -] as const; - -export function createGentleNotificationEngine() { - return { - containsForbiddenPhrase(text: string): boolean { - const lower = text.toLowerCase(); - return FORBIDDEN_PHRASES.some((phrase) => lower.includes(phrase)); - }, - - getDefaultConfig(): GentleConfig { - return { - maxPerDay: 8, - quietHoursStart: 22, - quietHoursEnd: 7, - minIntervalMinutes: 30, - }; - }, - - recordDismissal(config: GentleConfig): GentleConfig { - return { - ...config, - dismissCount: (config.dismissCount ?? 0) + 1, - }; - }, - }; -} diff --git a/vendor/bytelyst/gentle-notifications/src/types.ts b/vendor/bytelyst/gentle-notifications/src/types.ts deleted file mode 100644 index 7cdc031..0000000 --- a/vendor/bytelyst/gentle-notifications/src/types.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Types for @bytelyst/gentle-notifications. - * Pure client-side TS — no backend dependency. - */ - -export interface GentleNotificationConfig { - maxPerHour: number; - tone: 'encouraging' | 'neutral' | 'minimal'; - adaptiveFrequency: boolean; - dismissCount: number; - suppressThreshold: number; -} - -export interface GentleMessage { - title: string; - body: string; - tone: 'encouraging' | 'neutral' | 'minimal'; -} - -export interface GentleNotificationEngine { - getDefaultConfig(): GentleNotificationConfig; - getMessage(type: string, config?: GentleNotificationConfig): GentleMessage; - shouldSuppress(config: GentleNotificationConfig): boolean; - recordDismissal(config: GentleNotificationConfig): GentleNotificationConfig; - resetDismissals(config: GentleNotificationConfig): GentleNotificationConfig; - registerMessages(type: string, messages: GentleMessage[]): void; - getForbiddenPhrases(): readonly string[]; - containsForbiddenPhrase(text: string): boolean; -} diff --git a/vendor/bytelyst/gentle-notifications/tsconfig.json b/vendor/bytelyst/gentle-notifications/tsconfig.json deleted file mode 100644 index 8c5e8c2..0000000 --- a/vendor/bytelyst/gentle-notifications/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "outDir": "dist", - "rootDir": "src", - "declaration": true, - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true - }, - "include": ["src"] -} diff --git a/vendor/bytelyst/kill-switch-client/package.json b/vendor/bytelyst/kill-switch-client/package.json deleted file mode 100644 index 5e2458c..0000000 --- a/vendor/bytelyst/kill-switch-client/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "@bytelyst/kill-switch-client", - "version": "0.1.5", - "type": "module", - "description": "Browser/React Native-safe kill switch client for platform-service", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/kill-switch-client/src/index.test.ts b/vendor/bytelyst/kill-switch-client/src/index.test.ts deleted file mode 100644 index 7ac17a3..0000000 --- a/vendor/bytelyst/kill-switch-client/src/index.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { describe, it, expect, vi, afterEach } from 'vitest'; -import { createKillSwitchClient } from './index.js'; - -describe('createKillSwitchClient', () => { - const baseConfig = { - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - }; - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('should return disabled=false when app is not disabled', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ disabled: false, message: null }), - }) - ); - - const ks = createKillSwitchClient(baseConfig); - const result = await ks.check(); - - expect(result.disabled).toBe(false); - expect(result.message).toBeNull(); - }); - - it('should return disabled=true with message when app is disabled', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ disabled: true, message: 'Maintenance in progress' }), - }) - ); - - const ks = createKillSwitchClient(baseConfig); - const result = await ks.check(); - - expect(result.disabled).toBe(true); - expect(result.message).toBe('Maintenance in progress'); - }); - - it('should fail-open on network error', async () => { - vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network'))); - - const ks = createKillSwitchClient(baseConfig); - const result = await ks.check(); - - expect(result.disabled).toBe(false); - expect(result.message).toBeNull(); - }); - - it('should fail-open on non-OK response', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: false, - status: 500, - }) - ); - - const ks = createKillSwitchClient(baseConfig); - const result = await ks.check(); - - expect(result.disabled).toBe(false); - }); - - it('should send correct product-id header', async () => { - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ disabled: false }), - }); - vi.stubGlobal('fetch', fetchMock); - - const ks = createKillSwitchClient(baseConfig); - await ks.check(); - - expect(fetchMock).toHaveBeenCalledWith( - expect.stringContaining('/flags/kill-switch'), - expect.objectContaining({ - headers: expect.objectContaining({ 'x-product-id': 'testapp' }), - }) - ); - }); - - it('should include platform in query string', async () => { - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ disabled: false }), - }); - vi.stubGlobal('fetch', fetchMock); - - const ks = createKillSwitchClient({ ...baseConfig, platform: 'ios' }); - await ks.check(); - - const url = fetchMock.mock.calls[0][0] as string; - expect(url).toContain('platform=ios'); - }); -}); diff --git a/vendor/bytelyst/kill-switch-client/src/index.ts b/vendor/bytelyst/kill-switch-client/src/index.ts deleted file mode 100644 index e41339d..0000000 --- a/vendor/bytelyst/kill-switch-client/src/index.ts +++ /dev/null @@ -1,73 +0,0 @@ -/** - * Browser/React Native-safe kill switch client for platform-service. - * - * Checks GET /api/flags/kill-switch to determine if the app is disabled. - * Fail-open: returns { disabled: false } on any network error. - * - * @example - * ```ts - * import { createKillSwitchClient } from '@bytelyst/kill-switch-client'; - * - * const ks = createKillSwitchClient({ - * baseUrl: 'http://localhost:4003/api', - * productId: 'nomgap', - * }); - * - * const result = await ks.check(); - * if (result.disabled) showBlockScreen(result.message); - * ``` - */ - -export interface KillSwitchClientConfig { - /** Platform-service base URL (e.g. "http://localhost:4003/api"). */ - baseUrl: string; - - /** Product identifier sent as x-product-id header. */ - productId: string; - - /** Platform string for the query (e.g. "mobile", "web"). Default: "mobile". */ - platform?: string; -} - -export interface KillSwitchResult { - disabled: boolean; - message: string | null; -} - -export interface KillSwitchClient { - /** Check if the app is disabled. Fail-open on any error. */ - check(): Promise; -} - -export function createKillSwitchClient(config: KillSwitchClientConfig): KillSwitchClient { - const { baseUrl, productId, platform = 'mobile' } = config; - - async function check(): Promise { - try { - const requestId = - typeof globalThis.crypto?.randomUUID === 'function' - ? globalThis.crypto.randomUUID() - : `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; - - const res = await globalThis.fetch( - `${baseUrl}/flags/kill-switch?platform=${encodeURIComponent(platform)}`, - { - headers: { 'x-product-id': productId, 'x-request-id': requestId }, - } - ); - - if (!res.ok) return { disabled: false, message: null }; - - const data = (await res.json()) as KillSwitchResult; - return { - disabled: data.disabled ?? false, - message: data.message ?? null, - }; - } catch { - // Fail-open: network errors should NOT block the user - return { disabled: false, message: null }; - } - } - - return { check }; -} diff --git a/vendor/bytelyst/kill-switch-client/tsconfig.json b/vendor/bytelyst/kill-switch-client/tsconfig.json deleted file mode 100644 index 318c075..0000000 --- a/vendor/bytelyst/kill-switch-client/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src", - "lib": ["ES2022", "DOM"] - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/vendor/bytelyst/kotlin-platform-sdk/.gitignore b/vendor/bytelyst/kotlin-platform-sdk/.gitignore deleted file mode 100644 index 4705270..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/.gitignore +++ /dev/null @@ -1,11 +0,0 @@ -# Gradle -.gradle/ -build/ -local.properties - -# IDE -.idea/ -*.iml - -# OS -.DS_Store diff --git a/vendor/bytelyst/kotlin-platform-sdk/README.md b/vendor/bytelyst/kotlin-platform-sdk/README.md deleted file mode 100644 index 78688e1..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/README.md +++ /dev/null @@ -1,574 +0,0 @@ -# ByteLyst Platform SDK — Android (Kotlin) - -Kotlin SDK for the ByteLyst platform. Provides broadcast messaging, surveys, authentication, telemetry, and more. - -## Installation - -### Gradle - -Add to your `build.gradle.kts`: - -```kotlin -dependencies { - implementation("com.bytelyst:platform-sdk:1.0.0") -} -``` - -### Maven - -```xml - - com.bytelyst - platform-sdk - 1.0.0 - -``` - -## Quick Start - -```kotlin -import com.bytelyst.platform.* - -// Configure the SDK -val config = BLPlatformConfig( - productId = "lysnrai", - baseURL = "https://api.bytelyst.io/v1", - getAuthToken = { authRepository.getToken() } -) - -// Create clients -val broadcastClient = BLBroadcastClient(config) -val surveyClient = BLSurveyClient(config) -``` - -## Broadcast Client - -### Basic Usage - -```kotlin -import com.bytelyst.platform.* -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow - -class BroadcastManager( - private val client: BLBroadcastClient -) { - private val _messages = MutableStateFlow>(emptyList()) - val messages: StateFlow> = _messages - - private val _unreadCount = MutableStateFlow(0) - val unreadCount: StateFlow = _unreadCount - - fun startListening() { - client.startPolling(60000L) { messages -> - _messages.value = messages - _unreadCount.value = messages.count { it.status == MessageStatus.UNREAD } - } - } - - fun stopListening() { - client.stopPolling() - } - - suspend fun markRead(messageId: String) { - client.markRead(messageId) - } - - suspend fun dismiss(messageId: String) { - client.markDismissed(messageId) - } - - suspend fun handleTap(message: InAppMessage) { - client.trackClick(message.id) - - message.ctaUrl?.let { url -> - // Open URL with your navigation system - navigationService.openUrl(url) - } - - markRead(message.id) - } -} -``` - -### Jetpack Compose Integration - -```kotlin -import com.bytelyst.platform.ui.* - -@Composable -fun AppContent() { - val broadcastManager = remember { BroadcastManager(broadcastClient) } - val messages by broadcastManager.messages.collectAsState() - val unreadCount by broadcastManager.unreadCount.collectAsState() - - LaunchedEffect(Unit) { - broadcastManager.startListening() - } - - Scaffold( - topBar = { - // Banner for unread messages - InAppMessageBanner( - client = broadcastClient, - position = BannerPosition.TOP - ) - } - ) { padding -> - MainContent(modifier = Modifier.padding(padding)) - } -} -``` - -### Modal Messages - -```kotlin -@Composable -fun AppRoot() { - val broadcastClient = remember { BLBroadcastClient(config) } - - Box(modifier = Modifier.fillMaxSize()) { - NavigationHost() - - // Modal overlay - BroadcastModal(client = broadcastClient) - } -} -``` - -## Survey Client - -### Basic Usage - -```kotlin -import com.bytelyst.platform.* -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow - -class SurveyManager( - private val client: BLSurveyClient -) { - private val _activeSurvey = MutableStateFlow(null) - val activeSurvey: StateFlow = _activeSurvey - - private val _currentQuestionIndex = MutableStateFlow(0) - val currentQuestionIndex: StateFlow = _currentQuestionIndex - - private val _answers = MutableStateFlow>(emptyMap()) - val answers: StateFlow> = _answers - - private val _isComplete = MutableStateFlow(false) - val isComplete: StateFlow = _isComplete - - suspend fun checkForSurveys() { - val result = client.getActiveSurvey() - result.onSuccess { survey -> - survey?.let { - _activeSurvey.value = it - client.startSurvey(it.id) - } - } - } - - suspend fun submitAnswer(question: Question, answer: SurveyAnswer) { - val survey = _activeSurvey.value ?: return - - val result = client.submitAnswer( - surveyId = survey.id, - questionId = question.id, - answer = answer - ) - - result.onSuccess { response -> - _currentQuestionIndex.value = response.currentQuestionIndex - _answers.value = _answers.value + (question.id to answer) - - if (response.isComplete) { - completeSurvey() - } - } - } - - suspend fun completeSurvey() { - val survey = _activeSurvey.value ?: return - - val result = client.completeSurvey(survey.id) - result.onSuccess { completion -> - if (completion.success) { - _isComplete.value = true - - if (completion.incentiveClaimed) { - showIncentiveToast( - amount = completion.incentiveAmount, - type = completion.incentiveType - ) - } - } - } - } - - suspend fun dismiss() { - val survey = _activeSurvey.value ?: return - client.dismissSurvey(survey.id) - - _activeSurvey.value = null - _currentQuestionIndex.value = 0 - _answers.value = emptyMap() - } -} -``` - -### Jetpack Compose Survey Modal - -```kotlin -@Composable -fun AppRoot() { - val surveyClient = remember { BLSurveyClient(config) } - - Box(modifier = Modifier.fillMaxSize()) { - NavigationHost() - - // Survey modal overlay - SurveyModal(client = surveyClient) - } -} -``` - -### Custom Survey UI - -```kotlin -@Composable -fun CustomSurveyView( - manager: SurveyManager = viewModel() -) { - val survey by manager.activeSurvey.collectAsState() - val currentIndex by manager.currentQuestionIndex.collectAsState() - val isComplete by manager.isComplete.collectAsState() - - if (survey != null && !isComplete) { - val question = survey!!.questions[currentIndex] - val isLast = currentIndex == survey!!.questions.size - 1 - - Column(modifier = Modifier.padding(16.dp)) { - // Progress - LinearProgressIndicator( - progress = (currentIndex + 1) / survey!!.questions.size.toFloat(), - modifier = Modifier.fillMaxWidth() - ) - - Text( - text = "Question ${currentIndex + 1} of ${survey!!.questions.size}", - style = MaterialTheme.typography.labelSmall - ) - - // Question - Text( - text = question.text, - style = MaterialTheme.typography.titleLarge - ) - - question.description?.let { - Text( - text = it, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - - // Answer input based on type - QuestionInput( - question = question, - onAnswer = { answer -> - coroutineScope.launch { - manager.submitAnswer(question, answer) - } - } - ) - - // Navigation - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - if (!question.required) { - TextButton(onClick = { /* Skip */ }) { - Text("Skip") - } - } - - Button( - onClick = { /* Submit */ }, - enabled = canSubmit(question) - ) { - Text(if (isLast) "Complete" else "Next") - } - } - } - } -} - -@Composable -fun QuestionInput( - question: Question, - onAnswer: (SurveyAnswer) -> Unit -) { - when (question.type) { - QuestionType.SINGLE_CHOICE, QuestionType.DROPDOWN -> { - var selected by remember { mutableStateOf(null) } - - Column { - question.options?.forEach { option -> - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { - selected = option.id - onAnswer(SurveyAnswer( - type = "single_choice", - value = JsonObject(mapOf("value" to JsonPrimitive(option.id))) - )) - } - .padding(12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text(text = option.emoji ?: "") - Text(text = option.text, modifier = Modifier.weight(1f)) - RadioButton( - selected = selected == option.id, - onClick = null - ) - } - } - } - } - - QuestionType.MULTIPLE_CHOICE -> { - var selected by remember { mutableStateOf>(emptySet()) } - - Column { - question.options?.forEach { option -> - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { - selected = if (selected.contains(option.id)) { - selected - option.id - } else { - selected + option.id - } - onAnswer(SurveyAnswer( - type = "multiple_choice", - value = JsonObject(mapOf("values" to JsonArray(selected.map { JsonPrimitive(it) }))) - )) - } - .padding(12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text(text = option.emoji ?: "") - Text(text = option.text, modifier = Modifier.weight(1f)) - Checkbox( - checked = selected.contains(option.id), - onCheckedChange = null - ) - } - } - } - } - - QuestionType.NPS, QuestionType.RATING, QuestionType.SCALE -> { - var rating by remember { mutableIntStateOf(0) } - val minValue = question.minValue ?: if (question.type == QuestionType.NPS) 0 else 1 - val maxValue = question.maxValue ?: if (question.type == QuestionType.NPS) 10 else 5 - - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - (minValue..maxValue).forEach { value -> - Button( - onClick = { - rating = value - onAnswer(SurveyAnswer( - type = "rating", - value = JsonObject(mapOf("value" to JsonPrimitive(value))) - )) - }, - colors = ButtonDefaults.buttonColors( - containerColor = if (rating == value) MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.surfaceVariant - ) - ) { - Text("$value") - } - } - } - } - - QuestionType.TEXT_SHORT -> { - var text by remember { mutableStateOf("") } - OutlinedTextField( - value = text, - onValueChange = { - text = it - onAnswer(SurveyAnswer( - type = "text", - value = JsonObject(mapOf("value" to JsonPrimitive(it))) - )) - }, - modifier = Modifier.fillMaxWidth() - ) - } - - QuestionType.TEXT_LONG -> { - var text by remember { mutableStateOf("") } - OutlinedTextField( - value = text, - onValueChange = { - text = it - onAnswer(SurveyAnswer( - type = "text", - value = JsonObject(mapOf("value" to JsonPrimitive(it))) - )) - }, - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 120.dp), - maxLines = 6 - ) - } - - else -> { /* Handle other types */ } - } -} -``` - -## Push Notifications - -### Firebase Cloud Messaging - -```kotlin -import com.google.firebase.messaging.FirebaseMessagingService -import com.google.firebase.messaging.RemoteMessage -import com.bytelyst.platform.* - -class PushNotificationService : FirebaseMessagingService() { - private lateinit var broadcastClient: BLBroadcastClient - - override fun onCreate() { - super.onCreate() - val config = BLPlatformConfig( - productId = "lysnrai", - baseURL = "https://api.bytelyst.io/v1", - getAuthToken = { authRepository.getToken() } - ) - broadcastClient = BLBroadcastClient(config) - } - - override fun onNewToken(token: String) { - super.onNewToken(token) - - // Register device token - coroutineScope.launch { - broadcastClient.registerDeviceToken(token, Platform.ANDROID) - } - } - - override fun onMessageReceived(message: RemoteMessage) { - super.onMessageReceived(message) - - // Handle broadcast notification - message.data["broadcastId"]?.let { broadcastId -> - showNotification(message) - } - } -} -``` - -### Register Device Token - -```kotlin -FirebaseMessaging.getInstance().token.addOnCompleteListener { task -> - if (task.isSuccessful) { - val token = task.result - coroutineScope.launch { - broadcastClient.registerDeviceToken(token, Platform.ANDROID) - } - } -} -``` - -## Error Handling - -```kotlin -val result = client.getActiveSurvey() - -result.onSuccess { survey -> - survey?.let { showSurvey(it) } -}.onError { error -> - when (error) { - is BLApiError.Unauthorized -> { - // Re-authenticate user - authRepository.refreshToken() - } - is BLApiError.NetworkError -> { - // Retry with exponential backoff - Log.w("Survey", "Network error, will retry") - } - is BLApiError.ServerError -> { - Log.e("Survey", "Server error: ${error.code}") - } - else -> { - Log.e("Survey", "Unknown error: ${error.message}") - } - } -} -``` - -## Configuration - -### Environment-Based Config - -```kotlin -sealed class Environment( - val baseURL: String -) { - object Development : Environment("http://localhost:4003") - object Staging : Environment("https://api-staging.bytelyst.io/v1") - object Production : Environment("https://api.bytelyst.io/v1") -} - -val config = BLPlatformConfig( - productId = "lysnrai", - baseURL = Environment.Production.baseURL, - getAuthToken = { authRepository.getToken() }, - enableLogging = BuildConfig.DEBUG -) -``` - -## Offline Support - -The SDK automatically caches survey responses offline: - -```kotlin -val client = BLSurveyClient( - config = config, - enableOfflineCache = true // Enabled by default -) - -// Responses are queued when offline -// Flush manually or on network restore -connectivityManager.registerDefaultNetworkCallback(object : ConnectivityManager.NetworkCallback() { - override fun onAvailable(network: Network) { - coroutineScope.launch { - client.flushOfflineQueue() - } - } -}) -``` - -## Requirements - -- Android 8.0+ (API 26+) -- Kotlin 1.9+ -- Jetpack Compose (optional, for UI components) - -## License - -MIT © ByteLyst diff --git a/vendor/bytelyst/kotlin-platform-sdk/build.gradle.kts b/vendor/bytelyst/kotlin-platform-sdk/build.gradle.kts deleted file mode 100644 index e0e47a5..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/build.gradle.kts +++ /dev/null @@ -1,77 +0,0 @@ -plugins { - id("com.android.library") version "8.7.3" - id("org.jetbrains.kotlin.android") version "2.1.0" - id("org.jetbrains.kotlin.plugin.serialization") version "2.1.0" - id("org.jetbrains.kotlin.plugin.compose") version "2.1.0" -} - -android { - namespace = "com.bytelyst.platform" - compileSdk = 35 - - defaultConfig { - minSdk = 26 - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - consumerProguardFiles("consumer-rules.pro") - } - - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - - kotlinOptions { - jvmTarget = "17" - } - - buildFeatures { - compose = true - } -} - -group = "com.bytelyst.platform" -version = "0.1.0" - -dependencies { - // HTTP - api("com.squareup.okhttp3:okhttp:4.12.0") - - // Serialization - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") - - // Coroutines - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0") - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0") - - // Compose UI (for SurveyUI, InAppMessageUI, BroadcastUI) - implementation(platform("androidx.compose:compose-bom:2024.12.01")) - implementation("androidx.compose.ui:ui") - implementation("androidx.compose.material3:material3") - implementation("androidx.compose.material:material-icons-extended") - implementation("androidx.compose.foundation:foundation") - - // Image loading (for BroadcastUI AsyncImage) - implementation("io.coil-kt:coil-compose:2.7.0") - - // Android - implementation("androidx.security:security-crypto:1.0.0") - implementation("androidx.biometric:biometric:1.1.0") - implementation("androidx.core:core-ktx:1.15.0") - implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7") - - // Credential Manager (Passkeys) - implementation("androidx.credentials:credentials:1.5.0-beta01") - implementation("androidx.credentials:credentials-play-services-auth:1.5.0-beta01") - - // Testing - testImplementation("org.junit.jupiter:junit-jupiter-api:5.11.4") - testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.11.4") - testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0") - testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0") - testImplementation("org.robolectric:robolectric:4.14.1") -} - -tasks.withType { - useJUnitPlatform() -} diff --git a/vendor/bytelyst/kotlin-platform-sdk/consumer-rules.pro b/vendor/bytelyst/kotlin-platform-sdk/consumer-rules.pro deleted file mode 100644 index a774057..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/consumer-rules.pro +++ /dev/null @@ -1,3 +0,0 @@ -# ByteLyst Platform SDK — consumer ProGuard rules -# Keep all SDK public API classes --keep class com.bytelyst.platform.** { *; } diff --git a/vendor/bytelyst/kotlin-platform-sdk/gradle.properties b/vendor/bytelyst/kotlin-platform-sdk/gradle.properties deleted file mode 100644 index 5bac8ac..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/gradle.properties +++ /dev/null @@ -1 +0,0 @@ -android.useAndroidX=true diff --git a/vendor/bytelyst/kotlin-platform-sdk/settings.gradle.kts b/vendor/bytelyst/kotlin-platform-sdk/settings.gradle.kts deleted file mode 100644 index b4d1db6..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/settings.gradle.kts +++ /dev/null @@ -1,16 +0,0 @@ -pluginManagement { - repositories { - google() - mavenCentral() - gradlePluginPortal() - } -} - -dependencyResolutionManagement { - repositories { - google() - mavenCentral() - } -} - -rootProject.name = "kotlin-platform-sdk" diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/AndroidManifest.xml b/vendor/bytelyst/kotlin-platform-sdk/src/main/AndroidManifest.xml deleted file mode 100644 index 1f26d1e..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/main/AndroidManifest.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLAuditLogger.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLAuditLogger.kt deleted file mode 100644 index 57fb13b..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLAuditLogger.kt +++ /dev/null @@ -1,115 +0,0 @@ -package com.bytelyst.platform - -import android.content.Context -import kotlinx.serialization.Serializable -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json -import java.io.File -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale -import java.util.TimeZone - -/** - * Local rotating JSON audit log. - * - * Writes audit entries to a JSON lines file in the app's files directory. - * Rotates when the file exceeds [maxFileSizeBytes]. Keeps [maxFiles] rotated files. - * - * Mirrors the Swift BLAuditLogger API. - */ -class BLAuditLogger( - context: Context, - private val config: BLPlatformConfig, - private val maxFileSizeBytes: Long = 1_000_000L, - private val maxFiles: Int = 5, -) { - @Serializable - data class AuditEntry( - val timestamp: String, - val productId: String, - val action: String, - val module: String, - val detail: String? = null, - val userId: String? = null, - ) - - private val json = Json { encodeDefaults = true } - private val logDir = File(context.filesDir, "audit_logs") - private val currentFile: File - get() = File(logDir, "${config.productId}_audit.jsonl") - - private val isoFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply { - timeZone = TimeZone.getTimeZone("UTC") - } - - /** Thread-safe ISO timestamp — SimpleDateFormat is NOT thread-safe. */ - @Synchronized - private fun isoNow(): String = isoFormat.format(Date()) - - init { - logDir.mkdirs() - } - - /** - * Log an audit event. - */ - fun log(action: String, module: String, detail: String? = null, userId: String? = null) { - val entry = AuditEntry( - timestamp = isoNow(), - productId = config.productId, - action = action, - module = module, - detail = detail, - userId = userId, - ) - - try { - rotateIfNeeded() - currentFile.appendText(json.encodeToString(entry) + "\n") - } catch (_: Exception) { - // Audit logging should never crash the app - } - } - - /** - * Read all entries from the current log file. - */ - fun readEntries(): List { - return try { - if (!currentFile.exists()) return emptyList() - currentFile.readLines() - .filter { it.isNotBlank() } - .mapNotNull { - try { json.decodeFromString(it) } catch (_: Exception) { null } - } - } catch (_: Exception) { - emptyList() - } - } - - /** - * Clear all audit log files. - */ - fun clear() { - try { - logDir.listFiles()?.forEach { it.delete() } - } catch (_: Exception) { - // Ignore - } - } - - private fun rotateIfNeeded() { - if (!currentFile.exists() || currentFile.length() < maxFileSizeBytes) return - - // Rotate: current → .1, .1 → .2, etc. - for (i in maxFiles downTo 1) { - val from = if (i == 1) currentFile else File(logDir, "${config.productId}_audit.${i - 1}.jsonl") - val to = File(logDir, "${config.productId}_audit.$i.jsonl") - if (from.exists()) { - to.delete() - from.renameTo(to) - } - } - } -} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLAuthClient.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLAuthClient.kt deleted file mode 100644 index 25222b1..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLAuthClient.kt +++ /dev/null @@ -1,641 +0,0 @@ -package com.bytelyst.platform - -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import kotlinx.serialization.builtins.MapSerializer -import kotlinx.serialization.builtins.serializer - -/** - * Auth client for platform-service. - * - * Manages login, register, token refresh, password operations. - * SmartAuth v2: social login, MFA (TOTP), device trust, step-up auth. - * Tokens are stored in [BLSecureStore] (EncryptedSharedPreferences). - * Auth state is exposed as a [StateFlow] for reactive UI binding. - * - * Mirrors the Swift BLAuthClient API. - */ -class BLAuthClient( - private val config: BLPlatformConfig, - private val secureStore: BLSecureStore, -) { - // ── Data classes ────────────────────────────────────────── - - @Serializable - data class AuthUser( - val id: String = "", - val email: String = "", - @SerialName("displayName") - val name: String = "", - val plan: String = "free", - val role: String = "user", - ) - - @Serializable - data class TokenResponse( - val accessToken: String, - val refreshToken: String, - val user: AuthUser, - ) - - @Serializable - data class RefreshResponse( - val accessToken: String, - val refreshToken: String, - ) - - @Serializable - data class MessageResponse(val message: String) - - // ── SmartAuth v2 types ──────────────────────────────────── - - @Serializable - data class MfaChallenge( - val mfaRequired: Boolean = false, - val mfaChallenge: String = "", - val methods: List = emptyList(), - ) - - @Serializable - data class TotpSetup( - val otpauthUri: String, - val qrCode: String, - val recoveryCodes: List, - ) - - @Serializable - data class MfaStatus( - val mfaEnabled: Boolean, - val methods: List, - val recoveryCodesRemaining: Int, - ) - - @Serializable - data class AuthProvider( - val provider: String, - val email: String, - val linkedAt: String, - val lastUsedAt: String? = null, - ) - - @Serializable - data class Device( - val fingerprint: String, - val trustLevel: String, - val deviceInfo: DeviceInfo? = null, - val lastIp: String? = null, - val lastLocation: String? = null, - val trustExpiresAt: String? = null, - val createdAt: String, - val lastSeenAt: String, - val isTrusted: Boolean, - ) { - /** Convenience: display name from device info. */ - val name: String get() = deviceInfo?.model ?: deviceInfo?.platform ?: fingerprint.take(8) - /** Convenience: platform string. */ - val platform: String get() = deviceInfo?.platform ?: "unknown" - } - - @Serializable - data class DeviceInfo( - val userAgent: String? = null, - val platform: String? = null, - val model: String? = null, - val os: String? = null, - ) - - @Serializable - data class LoginEvent( - val id: String, - val eventType: String, - val method: String, - val ip: String, - val geo: Geo? = null, - val riskScore: Int, - val createdAt: String, - ) - - @Serializable - data class Geo( - val country: String, - val city: String, - ) - - @Serializable - data class RecoveryCodesResponse( - val recoveryCodes: List, - ) - - @Serializable - data class Passkey( - val id: String, - val friendlyName: String, - val deviceType: String, - val lastUsedAt: String? = null, - val createdAt: String = "", - ) - - @Serializable - data class StepUpResponse( - val stepUpToken: String, - ) - - // ── Phase 5C–5E types ───────────────────────────────────── - - @Serializable - data class TotpSecret( - val secret: String, - val issuer: String, - val accountName: String, - val digits: Int = 6, - val period: Int = 30, - val algorithm: String = "SHA1", - ) - - @Serializable - data class PushApproval( - val id: String, - val requestProductId: String, - val requestPlatform: String, - val requestIp: String, - val requestGeo: Geo? = null, - val createdAt: String, - val expiresAt: String, - ) - - @Serializable - data class PushApprovalResponse( - val id: String, - val status: String, - val respondedAt: String? = null, - ) - - @Serializable - data class QrChallenge( - val id: String, - val challengeToken: String, - val expiresAt: String, - ) - - @Serializable - data class QrStatus( - val status: String, - val accessToken: String? = null, - val refreshToken: String? = null, - val user: AuthUser? = null, - ) - - /** Exception when MFA is required after login. */ - class MfaRequiredException(val challenge: MfaChallenge) : Exception("MFA required") - - sealed class AuthState { - data object Loading : AuthState() - data object LoggedOut : AuthState() - data class LoggedIn(val user: AuthUser) : AuthState() - data class MfaRequired(val challenge: MfaChallenge) : AuthState() - data class Error(val message: String) : AuthState() - } - - // ── State ──────────────────────────────────────────────── - - private val _state = MutableStateFlow(AuthState.Loading) - val state: StateFlow = _state.asStateFlow() - - val isLoggedIn: Boolean - get() = _state.value is AuthState.LoggedIn - - val currentUser: AuthUser? - get() = (_state.value as? AuthState.LoggedIn)?.user - - // ── Storage keys (bare — applicationId provides namespace) ── - - companion object { - private const val KEY_ACCESS_TOKEN = "access_token" - private const val KEY_REFRESH_TOKEN = "refresh_token" - private const val KEY_USER_EMAIL = "user_email" - private const val KEY_USER_NAME = "user_name" - private const val KEY_USER_PLAN = "user_plan" - private const val KEY_USER_ID = "user_id" - } - - // ── Platform client ────────────────────────────────────── - - internal val client = BLPlatformClient(config) { getAccessToken() } - - // ── Token management ───────────────────────────────────── - - fun getAccessToken(): String? = secureStore.read(KEY_ACCESS_TOKEN) - - fun getRefreshToken(): String? = secureStore.read(KEY_REFRESH_TOKEN) - - private fun setTokens(accessToken: String, refreshToken: String) { - secureStore.save(KEY_ACCESS_TOKEN, accessToken) - secureStore.save(KEY_REFRESH_TOKEN, refreshToken) - } - - private fun saveUser(user: AuthUser) { - secureStore.save(KEY_USER_ID, user.id) - secureStore.save(KEY_USER_EMAIL, user.email) - secureStore.save(KEY_USER_NAME, user.name) - secureStore.save(KEY_USER_PLAN, user.plan) - } - - private fun clearAll() { - secureStore.delete(KEY_ACCESS_TOKEN) - secureStore.delete(KEY_REFRESH_TOKEN) - secureStore.delete(KEY_USER_EMAIL) - secureStore.delete(KEY_USER_NAME) - secureStore.delete(KEY_USER_PLAN) - secureStore.delete(KEY_USER_ID) - } - - // ── Session check ──────────────────────────────────────── - - /** - * Check for an existing session from stored tokens. - * Call once at app startup to restore auth state. - */ - fun checkExistingSession() { - val token = secureStore.read(KEY_ACCESS_TOKEN) - val email = secureStore.read(KEY_USER_EMAIL) - if (!token.isNullOrBlank() && !email.isNullOrBlank()) { - val user = AuthUser( - id = secureStore.read(KEY_USER_ID) ?: "", - email = email, - name = secureStore.read(KEY_USER_NAME) ?: "", - plan = secureStore.read(KEY_USER_PLAN) ?: "free", - ) - _state.value = AuthState.LoggedIn(user) - } else { - _state.value = AuthState.LoggedOut - } - } - - // ── Auth operations ────────────────────────────────────── - - suspend fun login(email: String, password: String) { - _state.value = AuthState.Loading - try { - val body = encodeMap(mapOf("email" to email, "password" to password, "productId" to config.productId)) - val response = client.request("POST", "/api/auth/login", body, skipAuth = true) - // Check for MFA challenge - try { - val challenge = client.json.decodeFromString(response) - if (challenge.mfaRequired) { - _state.value = AuthState.MfaRequired(challenge) - return - } - } catch (_: Exception) { /* Not an MFA response, continue */ } - val result = client.json.decodeFromString(response) - handleAuthResult(result) - } catch (e: Exception) { - _state.value = AuthState.Error(e.message ?: "Login failed") - } - } - - suspend fun register(name: String, email: String, password: String) { - _state.value = AuthState.Loading - try { - val body = client.json.encodeToString( - MapSerializer(String.serializer(), String.serializer()), - mapOf( - "email" to email, - "displayName" to name, - "password" to password, - "productId" to config.productId, - ), - ) - val response = client.request("POST", "/api/auth/register", body, skipAuth = true) - val result = client.json.decodeFromString(response) - handleAuthResult(result) - } catch (e: Exception) { - _state.value = AuthState.Error(e.message ?: "Registration failed") - } - } - - fun logout() { - clearAll() - _state.value = AuthState.LoggedOut - } - - // ── Token refresh (singleton guard) ────────────────────── - - private val refreshMutex = Mutex() - - suspend fun refreshAccessToken(): Boolean = refreshMutex.withLock { - val rt = getRefreshToken() ?: return false - return try { - val body = client.json.encodeToString( - MapSerializer(String.serializer(), String.serializer()), - mapOf("refreshToken" to rt), - ) - val response = client.request("POST", "/api/auth/refresh", body, skipAuth = true) - val result = client.json.decodeFromString(response) - setTokens(result.accessToken, result.refreshToken) - true - } catch (e: BLApiException) { - if (e.statusCode == 401) { - withContext(Dispatchers.Main) { logout() } - } - false - } catch (_: Exception) { - false - } - } - - // ── Password management ────────────────────────────────── - - suspend fun forgotPassword(email: String): String { - val body = client.json.encodeToString( - MapSerializer(String.serializer(), String.serializer()), - mapOf("email" to email, "productId" to config.productId), - ) - val response = client.request("POST", "/api/auth/forgot-password", body, skipAuth = true) - return client.json.decodeFromString(response).message - } - - suspend fun resetPassword(token: String, newPassword: String): String { - val body = client.json.encodeToString( - MapSerializer(String.serializer(), String.serializer()), - mapOf("token" to token, "newPassword" to newPassword), - ) - val response = client.request("POST", "/api/auth/reset-password", body, skipAuth = true) - return client.json.decodeFromString(response).message - } - - suspend fun changePassword(currentPassword: String, newPassword: String): String { - val body = client.json.encodeToString( - MapSerializer(String.serializer(), String.serializer()), - mapOf("currentPassword" to currentPassword, "newPassword" to newPassword), - ) - val response = client.request("POST", "/api/auth/change-password", body) - return client.json.decodeFromString(response).message - } - - // ── Email verification ─────────────────────────────────── - - suspend fun verifyEmail(token: String): String { - val body = client.json.encodeToString( - MapSerializer(String.serializer(), String.serializer()), - mapOf("token" to token), - ) - val response = client.request("POST", "/api/auth/verify-email", body) - return client.json.decodeFromString(response).message - } - - suspend fun resendVerification(email: String): String { - val body = client.json.encodeToString( - MapSerializer(String.serializer(), String.serializer()), - mapOf("email" to email, "productId" to config.productId), - ) - val response = client.request("POST", "/api/auth/resend-verification", body) - return client.json.decodeFromString(response).message - } - - // ── Account management ─────────────────────────────────── - - suspend fun deleteAccount(password: String): String { - val body = client.json.encodeToString( - MapSerializer(String.serializer(), String.serializer()), - mapOf("password" to password), - ) - val response = client.request("DELETE", "/api/auth/account", body) - clearAll() - _state.value = AuthState.LoggedOut - return client.json.decodeFromString(response).message - } - - suspend fun getMe(): AuthUser { - val response = client.request("GET", "/api/auth/me") - return client.json.decodeFromString(response) - } - - // ── Social Login (SmartAuth v2) ───────────────────────── - - /** Login with Google id_token. */ - suspend fun loginWithGoogle(idToken: String): AuthUser = socialLogin("google", idToken) - - /** Login with Microsoft id_token. */ - suspend fun loginWithMicrosoft(idToken: String): AuthUser = socialLogin("microsoft", idToken) - - /** Login with Apple id_token. */ - suspend fun loginWithApple(idToken: String): AuthUser = socialLogin("apple", idToken) - - private suspend fun socialLogin(provider: String, idToken: String): AuthUser { - _state.value = AuthState.Loading - val body = encodeMap(mapOf("idToken" to idToken, "productId" to config.productId)) - val response = client.request("POST", "/api/auth/oauth/$provider", body, skipAuth = true) - // Check for MFA challenge - try { - val challenge = client.json.decodeFromString(response) - if (challenge.mfaRequired) { - _state.value = AuthState.MfaRequired(challenge) - throw MfaRequiredException(challenge) - } - } catch (e: MfaRequiredException) { - throw e - } catch (_: Exception) { /* Not an MFA response */ } - val result = client.json.decodeFromString(response) - handleAuthResult(result) - return result.user - } - - // ── MFA (SmartAuth v2) ─────────────────────────────────── - - /** Verify MFA challenge (TOTP code or recovery code). */ - suspend fun verifyMfa(challengeToken: String, code: String, method: String = "totp"): AuthUser { - val body = encodeMap(mapOf( - "challengeToken" to challengeToken, - "code" to code, - "method" to method, - )) - val response = client.request("POST", "/api/auth/mfa/verify", body, skipAuth = true) - val result = client.json.decodeFromString(response) - handleAuthResult(result) - return result.user - } - - /** Begin TOTP setup — returns otpauth URI, QR code, and recovery codes. */ - suspend fun setupTotp(): TotpSetup { - val response = client.request("POST", "/api/auth/mfa/totp/setup") - return client.json.decodeFromString(response) - } - - /** Verify TOTP setup with a code from the authenticator app. */ - suspend fun verifyTotpSetup(code: String) { - val body = encodeMap(mapOf("code" to code)) - client.request("POST", "/api/auth/mfa/totp/verify-setup", body) - } - - /** Disable MFA (requires step-up token). */ - suspend fun disableMfa() { - client.request("DELETE", "/api/auth/mfa/totp") - } - - /** Get current MFA status. */ - suspend fun getMfaStatus(): MfaStatus { - val response = client.request("GET", "/api/auth/mfa/status") - return client.json.decodeFromString(response) - } - - /** Regenerate recovery codes (requires step-up). */ - suspend fun regenerateRecoveryCodes(): List { - val response = client.request("POST", "/api/auth/mfa/recovery/regenerate") - return client.json.decodeFromString(response).recoveryCodes - } - - // ── Providers (SmartAuth v2) ───────────────────────────── - - /** List linked OAuth providers. */ - suspend fun getProviders(): List { - val response = client.request("GET", "/api/auth/providers") - return client.json.decodeFromString>(response) - } - - /** Link an OAuth provider to the current account. */ - suspend fun linkProvider(provider: String, idToken: String) { - val body = encodeMap(mapOf("provider" to provider, "idToken" to idToken)) - client.request("POST", "/api/auth/providers/link", body) - } - - /** Unlink an OAuth provider. */ - suspend fun unlinkProvider(provider: String) { - client.request("DELETE", "/api/auth/providers/$provider") - } - - // ── Devices (SmartAuth v2) ─────────────────────────────── - - @Serializable - private data class DevicesResponse(val devices: List) - - /** List devices for current user. */ - suspend fun listDevices(): List { - val response = client.request("GET", "/api/auth/devices") - return client.json.decodeFromString(response).devices - } - - /** Trust the current device (promotes to trusted, skips MFA for 90 days). */ - suspend fun trustDevice() { - client.request("POST", "/api/auth/devices/trust") - } - - /** Revoke trust on a specific device by fingerprint. */ - suspend fun revokeDevice(fingerprint: String) { - client.request("DELETE", "/api/auth/devices/$fingerprint") - } - - /** Revoke all device trust. */ - suspend fun revokeAllDevices() { - client.request("POST", "/api/auth/devices/revoke-all") - } - - // ── Step-Up Auth (SmartAuth v2) ────────────────────────── - - /** Perform step-up authentication. Returns a short-lived step-up token. */ - suspend fun stepUp(method: String, credential: String): String { - val body = encodeMap(mapOf("method" to method, "credential" to credential)) - val response = client.request("POST", "/api/auth/step-up", body) - return client.json.decodeFromString(response).stepUpToken - } - - // ── Login History (SmartAuth v2) ───────────────────────── - - @Serializable - private data class EventsResponse(val events: List) - - /** Get login events for the current user. */ - suspend fun getLoginHistory(limit: Int = 20): List { - val response = client.request("GET", "/api/auth/login-events?limit=$limit") - return client.json.decodeFromString(response).events - } - - // ── TOTP Secret Retrieval (Phase 5C) ───────────────────── - - /** Get the decrypted TOTP secret for local code generation (auth app). */ - suspend fun getTotpSecret(): TotpSecret { - val response = client.request("GET", "/api/auth/mfa/totp/secret") - return client.json.decodeFromString(response) - } - - // ── Push Approvals (Phase 5D) ──────────────────────────── - - /** List pending push MFA approvals for the current user. */ - suspend fun getPendingApprovals(): List { - val response = client.request("GET", "/api/auth/mfa/push/pending") - return client.json.decodeFromString>(response) - } - - /** Respond to a push MFA approval (approve or deny). */ - suspend fun respondToApproval(approvalId: String, action: String): PushApprovalResponse { - val body = encodeMap(mapOf("action" to action)) - val response = client.request("POST", "/api/auth/mfa/push/$approvalId/respond", body) - return client.json.decodeFromString(response) - } - - // ── QR Auth (Phase 5E) ─────────────────────────────────── - - /** Confirm a QR login challenge from the auth app. */ - suspend fun confirmQrLogin(challengeToken: String): MessageResponse { - val body = encodeMap(mapOf("challengeToken" to challengeToken)) - val response = client.request("POST", "/api/auth/qr/confirm", body) - return client.json.decodeFromString(response) - } - - // ── Session restore ───────────────────────────────────── - - /** - * Restore session from stored tokens. Call on app launch. - * Attempts to fetch current user; falls back to token refresh. - * Mirrors Swift BLAuthClient.restoreSession(). - */ - suspend fun restoreSession() { - val token = getAccessToken() - if (token.isNullOrBlank()) { - _state.value = AuthState.LoggedOut - return - } - _state.value = AuthState.Loading - try { - val user = getMe() - _state.value = AuthState.LoggedIn(user) - } catch (_: Exception) { - // Access token may be expired — try refresh - val refreshed = refreshAccessToken() - if (refreshed) { - try { - val user = getMe() - _state.value = AuthState.LoggedIn(user) - } catch (_: Exception) { - _state.value = AuthState.LoggedOut - } - } else { - _state.value = AuthState.LoggedOut - } - } - } - - // ── Private ────────────────────────────────────────────── - - private fun handleAuthResult(result: TokenResponse) { - setTokens(result.accessToken, result.refreshToken) - saveUser(result.user) - _state.value = AuthState.LoggedIn(result.user) - } - - /** Called by [BLPasskeyManager] after successful passkey authentication. */ - internal fun handleLoginResult(result: TokenResponse) = handleAuthResult(result) - - /** Encode a Map to JSON string. */ - private fun encodeMap(map: Map): String = - client.json.encodeToString( - MapSerializer(String.serializer(), String.serializer()), - map, - ) -} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLBiometricAuth.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLBiometricAuth.kt deleted file mode 100644 index 0da5b11..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLBiometricAuth.kt +++ /dev/null @@ -1,83 +0,0 @@ -package com.bytelyst.platform - -import androidx.biometric.BiometricManager -import androidx.biometric.BiometricPrompt -import androidx.core.content.ContextCompat -import androidx.fragment.app.FragmentActivity -import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine - -/** - * Biometric authentication wrapper (Face / Fingerprint). - * - * Uses AndroidX BiometricPrompt for hardware-backed authentication. - * Mirrors the Swift BLBiometricAuth API. - */ -object BLBiometricAuth { - - enum class BiometricResult { - SUCCESS, - CANCELLED, - NOT_AVAILABLE, - ERROR, - } - - /** - * Check if biometric authentication is available on this device. - */ - fun isAvailable(activity: FragmentActivity): Boolean { - val manager = BiometricManager.from(activity) - return manager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) == - BiometricManager.BIOMETRIC_SUCCESS - } - - /** - * Show a biometric prompt and return the result. - * - * Must be called from a FragmentActivity (Compose activities extend this). - */ - suspend fun authenticate( - activity: FragmentActivity, - title: String = "Authenticate", - subtitle: String? = null, - negativeButtonText: String = "Cancel", - ): BiometricResult { - if (!isAvailable(activity)) return BiometricResult.NOT_AVAILABLE - - return suspendCoroutine { continuation -> - val executor = ContextCompat.getMainExecutor(activity) - - val callback = object : BiometricPrompt.AuthenticationCallback() { - override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { - continuation.resume(BiometricResult.SUCCESS) - } - - override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { - val result = if ( - errorCode == BiometricPrompt.ERROR_USER_CANCELED || - errorCode == BiometricPrompt.ERROR_NEGATIVE_BUTTON || - errorCode == BiometricPrompt.ERROR_CANCELED - ) { - BiometricResult.CANCELLED - } else { - BiometricResult.ERROR - } - continuation.resume(result) - } - - override fun onAuthenticationFailed() { - // Individual attempt failed, prompt stays open — don't resume yet - } - } - - val prompt = BiometricPrompt(activity, executor, callback) - val info = BiometricPrompt.PromptInfo.Builder() - .setTitle(title) - .apply { subtitle?.let { setSubtitle(it) } } - .setNegativeButtonText(negativeButtonText) - .build() - - prompt.authenticate(info) - } - } -} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLBlobClient.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLBlobClient.kt deleted file mode 100644 index 1b6ec9d..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLBlobClient.kt +++ /dev/null @@ -1,93 +0,0 @@ -package com.bytelyst.platform - -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import kotlinx.serialization.Serializable -import kotlinx.serialization.builtins.MapSerializer -import kotlinx.serialization.builtins.serializer -import kotlinx.serialization.json.Json -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.RequestBody.Companion.toRequestBody -import java.util.UUID -import java.util.concurrent.TimeUnit - -/** - * Azure Blob Storage client via platform-service SAS tokens. - * - * Flow: Get SAS token from POST /api/blob/sas → Upload directly to Azure Blob. - * Mirrors the Swift BLBlobClient API. - */ -class BLBlobClient( - private val config: BLPlatformConfig, - private val tokenProvider: () -> String? = { null }, -) { - @Serializable - data class SasResponse( - val sasUrl: String, - val blobUrl: String, - ) - - private val json = Json { ignoreUnknownKeys = true } - - private val platformClient = BLPlatformClient(config, tokenProvider) - - private val uploadClient = OkHttpClient.Builder() - .connectTimeout(60, TimeUnit.SECONDS) - .writeTimeout(120, TimeUnit.SECONDS) - .readTimeout(30, TimeUnit.SECONDS) - .build() - - /** - * Upload data to Azure Blob Storage. - * - * @param data The raw bytes to upload. - * @param container Azure Blob container name (e.g., "audio", "attachments"). - * @param fileName The blob name / file path. - * @param contentType MIME type (e.g., "audio/wav", "image/png"). - * @return The public blob URL on success, or null on failure. - */ - suspend fun upload( - data: ByteArray, - container: String, - fileName: String, - contentType: String, - ): String? = withContext(Dispatchers.IO) { - try { - // Step 1: Get SAS token from platform-service - val sasBody = json.encodeToString( - MapSerializer(String.serializer(), String.serializer()), - mapOf( - "container" to container, - "blobName" to fileName, - "permissions" to "w", - "contentType" to contentType, - ), - ) - val sasResponse = platformClient.request("POST", "/api/blob/sas", sasBody) - val sas = json.decodeFromString(sasResponse) - - // Step 2: Upload directly to Azure Blob via SAS URL - val request = Request.Builder() - .url(sas.sasUrl) - .put(data.toRequestBody(contentType.toMediaType())) - .header("x-ms-blob-type", "BlockBlob") - .header("x-ms-blob-content-type", contentType) - .build() - - uploadClient.newCall(request).execute().use { response -> - if (response.isSuccessful) sas.blobUrl else null - } - } catch (_: Exception) { - null - } - } - - /** - * Upload audio data (convenience method). - */ - suspend fun uploadAudio(data: ByteArray, fileName: String): String? { - return upload(data, "audio", fileName, "audio/wav") - } -} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLBroadcastClient.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLBroadcastClient.kt deleted file mode 100644 index 52d12c2..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLBroadcastClient.kt +++ /dev/null @@ -1,223 +0,0 @@ -package com.bytelyst.platform - -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.RequestBody.Companion.toRequestBody -import java.util.concurrent.TimeUnit - -// Top-level enums used by BroadcastUI -enum class MessagePriority { LOW, NORMAL, HIGH, URGENT } -enum class MessageStyle { BANNER, MODAL, TOAST, FULLSCREEN } -enum class MessageStatus { UNREAD, READ, DISMISSED } - -// Top-level type alias for convenience -typealias InAppMessage = BLBroadcastClient.InAppMessage - -/** - * Broadcast Client — In-app message client for Android. - * Part of ByteLystPlatformSDK. - */ -class BLBroadcastClient( - private val config: BLPlatformConfig, - private val tokenProvider: () -> String? = { null }, -) { - private val httpClient = OkHttpClient.Builder() - .connectTimeout(30, TimeUnit.SECONDS) - .readTimeout(30, TimeUnit.SECONDS) - .build() - - private val json = Json { ignoreUnknownKeys = true } - private var pollingJob: kotlinx.coroutines.Job? = null - - enum class Priority { - LOW, NORMAL, HIGH, URGENT - } - - enum class Style { - BANNER, MODAL, TOAST, FULLSCREEN - } - - enum class Status { - UNREAD, READ, DISMISSED - } - - @Serializable - data class InAppMessage( - val id: String, - val userId: String, - val productId: String, - val broadcastId: String, - val title: String, - val body: String, - val bodyMarkdown: String? = null, - val ctaText: String? = null, - val ctaUrl: String? = null, - val priority: String, - val style: String, - val dismissible: Boolean, - val expiresAt: String? = null, - val imageUrl: String? = null, - val status: String, - val createdAt: String, - val updatedAt: String, - ) - - @Serializable - private data class MessagesResponse( - val messages: List - ) - - @Serializable - private data class ClickResponse( - val success: Boolean, - val redirectUrl: String? = null, - ) - - /** - * List active in-app messages for the current user. - */ - suspend fun getMessages() = listMessages() - - suspend fun listMessages(): Result> = withContext(Dispatchers.IO) { - try { - val request = buildRequest(path = "/broadcasts") - val response = httpClient.newCall(request).execute() - - if (!response.isSuccessful) { - return@withContext Result.failure(Exception("HTTP ${response.code}")) - } - - val body = response.body?.string() ?: return@withContext Result.failure(Exception("Empty response")) - val result = json.decodeFromString(MessagesResponse.serializer(), body) - Result.success(result.messages) - } catch (e: Exception) { - Result.failure(e) - } - } - - /** - * Mark a message as read. - */ - suspend fun markRead(messageId: String): Result = withContext(Dispatchers.IO) { - try { - val request = buildRequest( - path = "/broadcasts/$messageId/read", - method = "POST" - ) - val response = httpClient.newCall(request).execute() - - if (!response.isSuccessful) { - return@withContext Result.failure(Exception("HTTP ${response.code}")) - } - - Result.success(Unit) - } catch (e: Exception) { - Result.failure(e) - } - } - - /** - * Mark a message as dismissed. - */ - suspend fun markDismissed(messageId: String): Result = withContext(Dispatchers.IO) { - try { - val request = buildRequest( - path = "/broadcasts/$messageId/dismiss", - method = "POST" - ) - val response = httpClient.newCall(request).execute() - - if (!response.isSuccessful) { - return@withContext Result.failure(Exception("HTTP ${response.code}")) - } - - Result.success(Unit) - } catch (e: Exception) { - Result.failure(e) - } - } - - /** - * Track a CTA click and get the redirect URL. - */ - suspend fun trackClick(messageId: String): Result = withContext(Dispatchers.IO) { - try { - val request = buildRequest( - path = "/broadcasts/$messageId/click", - method = "POST" - ) - val response = httpClient.newCall(request).execute() - - if (!response.isSuccessful) { - return@withContext Result.failure(Exception("HTTP ${response.code}")) - } - - val body = response.body?.string() ?: return@withContext Result.success(null) - val result = json.decodeFromString(ClickResponse.serializer(), body) - Result.success(result.redirectUrl) - } catch (e: Exception) { - Result.failure(e) - } - } - - /** - * Start polling for new messages. - */ - fun startPolling( - intervalMs: Long = 60000L, - onUpdate: (List) -> Unit, - ) { - stopPolling() - pollingJob = kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch { - while (isActive) { - listMessages() - .onSuccess { messages -> onUpdate(messages) } - .onFailure { /* Silently ignore polling errors */ } - delay(intervalMs) - } - } - } - - /** - * Stop polling for messages. - */ - fun stopPolling() { - pollingJob?.cancel() - pollingJob = null - } - - private fun buildRequest( - path: String, - method: String = "GET", - body: String? = null, - ): Request { - val url = "${config.baseUrl}$path" - val token = tokenProvider() ?: "" - - val builder = Request.Builder() - .url(url) - .header("Authorization", "Bearer $token") - .header("x-product-id", config.productId) - .header("x-platform", "android") - .header("x-app-version", config.appVersion) - .header("x-os-version", config.osVersion) - - if (body != null) { - builder.method(method, body.toRequestBody("application/json".toMediaType())) - } else if (method != "GET") { - builder.method(method, "".toRequestBody(null)) - } - - return builder.build() - } -} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLCrashReporter.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLCrashReporter.kt deleted file mode 100644 index 515f109..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLCrashReporter.kt +++ /dev/null @@ -1,106 +0,0 @@ -package com.bytelyst.platform - -import android.content.Context -import android.content.SharedPreferences -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.launch -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.put -import java.io.PrintWriter -import java.io.StringWriter -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale -import java.util.TimeZone -import java.util.UUID - -/** - * Crash reporter — captures uncaught exceptions and reports to telemetry. - * - * Installs a [Thread.UncaughtExceptionHandler] that: - * 1. Saves crash info to SharedPreferences - * 2. On next app launch, sends the crash report to platform-service telemetry - * 3. Delegates to the previous handler (so the system can still show ANR/crash dialogs) - * - * Mirrors the Swift BLCrashReporter API (MetricKit equivalent for Android). - */ -class BLCrashReporter( - context: Context, - private val config: BLPlatformConfig, -) { - private val prefs: SharedPreferences = - context.getSharedPreferences("${config.productId}_crash_reporter", Context.MODE_PRIVATE) - private val client = BLPlatformClient(config) - private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - private val json = Json { encodeDefaults = true } - - private val isoFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply { - timeZone = TimeZone.getTimeZone("UTC") - } - - /** Thread-safe ISO timestamp — SimpleDateFormat is NOT thread-safe. */ - @Synchronized - private fun isoNow(): String = isoFormat.format(Date()) - - companion object { - private const val KEY_PENDING_CRASH = "pending_crash" - } - - /** - * Install the crash handler and send any pending crash reports. - * Call once from Application.onCreate(). - */ - fun install() { - sendPendingCrashReport() - installHandler() - } - - private fun installHandler() { - val previousHandler = Thread.getDefaultUncaughtExceptionHandler() - - Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> - val sw = StringWriter() - throwable.printStackTrace(PrintWriter(sw)) - - val crashData = buildJsonObject { - put("id", UUID.randomUUID().toString()) - put("productId", config.productId) - put("platform", config.platform) - put("timestamp", isoNow()) - put("thread", thread.name) - put("exception", throwable.javaClass.name) - put("message", throwable.message ?: "") - put("stackTrace", sw.toString().take(4096)) - } - - // Persist synchronously (we may be about to die) - prefs.edit().putString(KEY_PENDING_CRASH, json.encodeToString(crashData)).commit() - - // Delegate to previous handler - previousHandler?.uncaughtException(thread, throwable) - } - } - - private fun sendPendingCrashReport() { - val pending = prefs.getString(KEY_PENDING_CRASH, null) ?: return - prefs.edit().remove(KEY_PENDING_CRASH).apply() - - scope.launch { - try { - val crashEvent = json.parseToJsonElement(pending) - val payload = buildJsonObject { - put("productId", config.productId) - put("events", JsonArray(listOf(crashEvent))) - } - client.fireAndForget("POST", "/api/telemetry/events", json.encodeToString(payload)) - } catch (_: Exception) { - // Best-effort — don't re-queue - } - } - } -} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLFeatureFlagClient.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLFeatureFlagClient.kt deleted file mode 100644 index 34be026..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLFeatureFlagClient.kt +++ /dev/null @@ -1,92 +0,0 @@ -package com.bytelyst.platform - -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json -import java.net.URLEncoder - -/** - * Feature flag client for platform-service. - * - * Polls GET /api/flags/poll on a configurable interval and caches results - * in memory. Consumers call [isEnabled] with a flag key. - * - * Mirrors the Swift BLFeatureFlagClient API. - */ -class BLFeatureFlagClient( - private val config: BLPlatformConfig, - private val pollIntervalMs: Long = 5 * 60 * 1000L, -) { - @Serializable - private data class FlagResponse(val flags: Map = emptyMap()) - - private val json = Json { ignoreUnknownKeys = true } - private val client = BLPlatformClient(config) - private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - private var pollJob: Job? = null - - @Volatile - private var flags: Map = emptyMap() - - private var userId: String? = null - - // ── Public API ─────────────────────────────────────────── - - /** - * Initialize and start polling. Call once at app startup. - */ - fun init(userId: String? = null) { - this.userId = userId - scope.launch { fetchFlags() } - startPolling() - } - - fun isEnabled(key: String): Boolean = flags[key] == true - - fun getAllFlags(): Map = flags.toMap() - - /** - * Force a refresh of feature flags. - */ - suspend fun refresh() { - fetchFlags() - } - - fun stop() { - pollJob?.cancel() - pollJob = null - } - - // ── Private ────────────────────────────────────────────── - - private fun startPolling() { - if (pollJob != null) return - pollJob = scope.launch { - while (isActive) { - delay(pollIntervalMs) - fetchFlags() - } - } - } - - private suspend fun fetchFlags() { - try { - val enc = { v: String -> URLEncoder.encode(v, "UTF-8") } - val qs = buildString { - append("?platform=${enc(config.platform)}") - userId?.let { append("&userId=${enc(it)}") } - } - val response = client.request("GET", "/api/flags/poll$qs", skipAuth = true) - val result = json.decodeFromString(response) - flags = result.flags - } catch (_: Exception) { - // Keep existing flags on failure - } - } -} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLFeedbackClient.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLFeedbackClient.kt deleted file mode 100644 index 6fefb58..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLFeedbackClient.kt +++ /dev/null @@ -1,266 +0,0 @@ -package com.bytelyst.platform - -import android.content.Context -import android.graphics.Bitmap -import android.view.View -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import kotlinx.serialization.Serializable -import kotlinx.serialization.builtins.MapSerializer -import kotlinx.serialization.builtins.serializer -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.jsonPrimitive -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.RequestBody.Companion.toRequestBody -import java.io.ByteArrayOutputStream -import java.util.Locale -import java.util.concurrent.TimeUnit - -/** - * Feedback client for submitting user feedback with optional screenshots. - * - * TODO-3: Full implementation for Android - * - * Flow: - * 1. Capture screenshot (optional) - * 2. Get SAS URL for upload - * 3. Upload screenshot to blob storage - * 4. Submit feedback with metadata - */ -class BLFeedbackClient( - private val config: BLPlatformConfig, - private val tokenProvider: () -> String? = { null }, -) { - enum class FeedbackType { - BUG, FEATURE, PRAISE, OTHER - } - - enum class ScreenshotFormat { - PNG, JPEG, WEBP - } - - data class DeviceContext( - val osVersion: String, - val appVersion: String, - val deviceModel: String, - val screenResolution: String, - val locale: String, - ) { - companion object { - fun fromContext(context: Context): DeviceContext { - val displayMetrics = context.resources.displayMetrics - return DeviceContext( - osVersion = android.os.Build.VERSION.RELEASE, - appVersion = context.packageManager.getPackageInfo( - context.packageName, 0 - ).versionName ?: "unknown", - deviceModel = "${android.os.Build.MANUFACTURER} ${android.os.Build.MODEL}", - screenResolution = "${displayMetrics.widthPixels}x${displayMetrics.heightPixels}", - locale = Locale.getDefault().toString(), - ) - } - } - } - - @Serializable - data class SasResponse( - val blobPath: String, - val uploadUrl: String, - val expiresIn: Int, - val maxSizeBytes: Int, - ) - - @Serializable - data class FeedbackResponse( - val id: String, - val productId: String, - val userId: String, - val type: String, - val title: String, - val status: String, - val createdAt: String, - val screenshotBlobPath: String? = null, - ) - - data class FeedbackParams( - val type: FeedbackType, - val title: String, - val body: String? = null, - val screen: String? = null, - val rating: Int? = null, - val screenshot: Pair? = null, - val deviceContext: DeviceContext? = null, - ) - - private val json = Json { ignoreUnknownKeys = true } - private val platformClient = BLPlatformClient(config, tokenProvider) - private val uploadClient = OkHttpClient.Builder() - .connectTimeout(60, TimeUnit.SECONDS) - .writeTimeout(120, TimeUnit.SECONDS) - .readTimeout(30, TimeUnit.SECONDS) - .build() - - /** - * Submit feedback with optional screenshot. - * - * TODO-3: Full implementation - */ - suspend fun submitFeedback(params: FeedbackParams): FeedbackResponse? = withContext(Dispatchers.IO) { - try { - // Step 1: Handle screenshot upload if provided - var screenshotMeta: Triple? = null - params.screenshot?.let { (data, format) -> - val contentType = when (format) { - ScreenshotFormat.PNG -> "image/png" - ScreenshotFormat.JPEG -> "image/jpeg" - ScreenshotFormat.WEBP -> "image/webp" - } - - // Get SAS URL - val sas = generateSASUrl(contentType) ?: return@withContext null - - // Upload screenshot - val uploaded = uploadScreenshot(data, sas.uploadUrl, contentType) - if (!uploaded) return@withContext null - - screenshotMeta = Triple(sas.blobPath, contentType, data.size) - } - - // Step 2: Submit feedback - val body = buildMap { - put("type", params.type.name.lowercase()) - put("title", params.title) - params.body?.let { put("body", it) } - params.screen?.let { put("screen", it) } - params.rating?.let { put("rating", it) } - screenshotMeta?.let { (path, type, size) -> - put("screenshotBlobPath", path) - put("screenshotContentType", type) - put("screenshotSizeBytes", size) - } - params.deviceContext?.let { ctx -> - put("deviceContext", mapOf( - "osVersion" to ctx.osVersion, - "appVersion" to ctx.appVersion, - "deviceModel" to ctx.deviceModel, - "screenResolution" to ctx.screenResolution, - "locale" to ctx.locale, - )) - } - } - - // TODO-3: Implement actual API call - throw NotImplementedError( - "submitFeedback API call not yet implemented. " + - "Use platformClient.request(\"POST\", \"/api/feedback\", jsonBody)" - ) - } catch (_: Exception) { - null - } - } - - /** - * Capture screenshot and submit feedback in one operation. - * - * TODO-3: Full implementation using MediaProjection or View.draw() - */ - suspend fun captureAndSubmit( - context: Context, - type: FeedbackType, - title: String, - body: String? = null, - ): FeedbackResponse? { - throw NotImplementedError( - "captureAndSubmit not yet implemented.\n\n" + - "To implement:\n" + - "1. Option A - MediaProjection API (requires permission):\n" + - " - Request MediaProjection permission\n" + - " - Use MediaProjection.createVirtualDisplay()\n" + - " - Capture ImageReader frame\n\n" + - "2. Option B - View.draw() (limited to app window):\n" + - " - val view = window.decorView.rootView\n" + - " - val bitmap = Bitmap.createBitmap(view.width, view.height)\n" + - " - val canvas = Canvas(bitmap)\n" + - " - view.draw(canvas)\n\n" + - "3. Convert Bitmap to ByteArray\n" + - "4. Call submitFeedback with screenshot" - ) - } - - /** - * Capture current screen as Bitmap. - * - * TODO-3: Full implementation - */ - fun captureScreen(): Bitmap { - throw NotImplementedError( - "captureScreen requires MediaProjection API. " + - "See: https://developer.android.com/reference/android/media/projection/MediaProjection" - ) - } - - /** - * Capture specific View as Bitmap. - * - * TODO-3: Full implementation - */ - fun captureView(view: View): Bitmap { - val bitmap = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888) - val canvas = android.graphics.Canvas(bitmap) - view.draw(canvas) - return bitmap - } - - /** - * Convert Bitmap to PNG ByteArray. - */ - fun bitmapToBytes(bitmap: Bitmap, format: ScreenshotFormat = ScreenshotFormat.PNG): ByteArray { - val androidFormat = when (format) { - ScreenshotFormat.PNG -> Bitmap.CompressFormat.PNG - ScreenshotFormat.JPEG -> Bitmap.CompressFormat.JPEG - ScreenshotFormat.WEBP -> Bitmap.CompressFormat.WEBP_LOSSY - } - val stream = ByteArrayOutputStream() - bitmap.compress(androidFormat, 90, stream) - return stream.toByteArray() - } - - // MARK: - Private - - private suspend fun generateSASUrl(contentType: String): SasResponse? = withContext(Dispatchers.IO) { - try { - val body = json.encodeToString( - MapSerializer(String.serializer(), String.serializer()), - mapOf("contentType" to contentType), - ) - val response = platformClient.request("POST", "/api/feedback/sas", body) - json.decodeFromString(SasResponse.serializer(), response) - } catch (_: Exception) { - null - } - } - - private suspend fun uploadScreenshot( - data: ByteArray, - sasUrl: String, - contentType: String, - ): Boolean = withContext(Dispatchers.IO) { - try { - val request = Request.Builder() - .url(sasUrl) - .put(data.toRequestBody(contentType.toMediaType())) - .header("x-ms-blob-type", "BlockBlob") - .header("x-ms-blob-content-type", contentType) - .build() - - uploadClient.newCall(request).execute().use { response -> - response.isSuccessful - } - } catch (_: Exception) { - false - } - } -} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLFieldEncrypt.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLFieldEncrypt.kt deleted file mode 100644 index 318276e..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLFieldEncrypt.kt +++ /dev/null @@ -1,253 +0,0 @@ -package com.bytelyst.platform - -import javax.crypto.Cipher -import javax.crypto.KeyGenerator -import javax.crypto.SecretKey -import javax.crypto.spec.GCMParameterSpec -import javax.crypto.spec.SecretKeySpec -import java.security.SecureRandom - -/** - * Encrypted field structure — wire-compatible with @bytelyst/field-encrypt (TypeScript). - * All byte arrays are hex-encoded strings for JSON serialization. - */ -data class BLEncryptedField( - /** Sentinel — always true for encrypted fields. */ - val __encrypted: Boolean = true, - /** Schema version (currently 1). */ - val v: Int = 1, - /** Algorithm identifier. */ - val alg: String = "aes-256-gcm", - /** Ciphertext (hex-encoded). */ - val ct: String, - /** Initialization vector (hex-encoded, 12 bytes = 24 hex chars). */ - val iv: String, - /** GCM authentication tag (hex-encoded, 16 bytes = 32 hex chars). */ - val tag: String, - /** DEK identifier — identifies which key was used for encryption. */ - val dekId: String, -) - -/** - * AES-256-GCM field-level encryption. - * - * Produces [BLEncryptedField] objects that are wire-compatible with the - * TypeScript `@bytelyst/field-encrypt` package. Backends and native clients - * can encrypt/decrypt the same fields interchangeably. - * - * Usage: - * ```kotlin - * val key = BLFieldEncrypt.generateKey() - * val encrypted = BLFieldEncrypt.encrypt("sensitive data", key, "dek_user1_notes") - * val decrypted = BLFieldEncrypt.decrypt(encrypted, key) - * ``` - */ -object BLFieldEncrypt { - - private const val ALGORITHM = "AES/GCM/NoPadding" - private const val KEY_SIZE_BYTES = 32 - private const val KEY_SIZE_BITS = KEY_SIZE_BYTES * 8 - private const val IV_SIZE_BYTES = 12 - private const val TAG_SIZE_BITS = 128 - - private val secureRandom = SecureRandom() - - // ── Encrypt ───────────────────────────────────────────── - - /** - * Encrypt a plaintext string with AES-256-GCM. - * - * @param plaintext UTF-8 string to encrypt. - * @param key 32-byte AES secret key. - * @param dekId DEK identifier stored in the output for key lookup on decrypt. - * @param aad Optional additional authenticated data (e.g., "userId:context"). - * @return [BLEncryptedField] with hex-encoded ciphertext, IV, and tag. - * @throws IllegalArgumentException if key size is wrong. - */ - fun encrypt( - plaintext: String, - key: SecretKey, - dekId: String, - aad: String? = null, - ): BLEncryptedField { - require(key.encoded.size == KEY_SIZE_BYTES) { - "AES-256-GCM requires a $KEY_SIZE_BYTES-byte key, got ${key.encoded.size}" - } - - val iv = ByteArray(IV_SIZE_BYTES).also { secureRandom.nextBytes(it) } - - val cipher = Cipher.getInstance(ALGORITHM) - val spec = GCMParameterSpec(TAG_SIZE_BITS, iv) - cipher.init(Cipher.ENCRYPT_MODE, key, spec) - - if (aad != null) { - cipher.updateAAD(aad.toByteArray(Charsets.UTF_8)) - } - - // GCM output = ciphertext || tag (last 16 bytes) - val ciphertextWithTag = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8)) - val tagOffset = ciphertextWithTag.size - TAG_SIZE_BITS / 8 - val ct = ciphertextWithTag.copyOfRange(0, tagOffset) - val tag = ciphertextWithTag.copyOfRange(tagOffset, ciphertextWithTag.size) - - return BLEncryptedField( - ct = ct.toHexString(), - iv = iv.toHexString(), - tag = tag.toHexString(), - dekId = dekId, - ) - } - - // ── Decrypt ───────────────────────────────────────────── - - /** - * Decrypt a [BLEncryptedField] back to plaintext. - * - * @param field Encrypted field object (from Cosmos DB or API response). - * @param key 32-byte AES secret key (must match the key used to encrypt). - * @param aad Optional AAD (must match the AAD used during encryption). - * @return Decrypted UTF-8 string. - * @throws javax.crypto.AEADBadTagException if authentication fails (tampered data or wrong key). - */ - fun decrypt( - field: BLEncryptedField, - key: SecretKey, - aad: String? = null, - ): String { - require(key.encoded.size == KEY_SIZE_BYTES) { - "AES-256-GCM requires a $KEY_SIZE_BYTES-byte key, got ${key.encoded.size}" - } - - val iv = field.iv.hexToByteArray() - val ct = field.ct.hexToByteArray() - val tag = field.tag.hexToByteArray() - - // GCM expects ciphertext || tag as input - val ciphertextWithTag = ct + tag - - val cipher = Cipher.getInstance(ALGORITHM) - val spec = GCMParameterSpec(TAG_SIZE_BITS, iv) - cipher.init(Cipher.DECRYPT_MODE, key, spec) - - if (aad != null) { - cipher.updateAAD(aad.toByteArray(Charsets.UTF_8)) - } - - val plaintextBytes = cipher.doFinal(ciphertextWithTag) - return String(plaintextBytes, Charsets.UTF_8) - } - - // ── Key Generation ────────────────────────────────────── - - /** - * Generate a random 32-byte AES-256 secret key. - */ - fun generateKey(): SecretKey { - val keyGen = KeyGenerator.getInstance("AES") - keyGen.init(KEY_SIZE_BITS, secureRandom) - return keyGen.generateKey() - } - - /** - * Create a [SecretKey] from a hex-encoded string (64 hex chars = 32 bytes). - */ - fun keyFromHex(hex: String): SecretKey { - val bytes = hex.hexToByteArray() - require(bytes.size == KEY_SIZE_BYTES) { - "AES-256-GCM requires a $KEY_SIZE_BYTES-byte key, got ${bytes.size}" - } - return SecretKeySpec(bytes, "AES") - } - - // ── Secure Store Key Derivation ──────────────────────── - - /** - * Get or create a persistent encryption key in [BLSecureStore]. - * - * On first call, generates a random 32-byte AES-256 key and stores it - * as a hex string in EncryptedSharedPreferences. On subsequent calls, - * loads the existing key. This provides a stable per-device DEK for - * client-side encryption without requiring the backend to provision keys. - * - * @param store The [BLSecureStore] instance for the current app. - * @param account Storage key name (default: `"field_encrypt_dek"`). - * @return A 32-byte [SecretKey] backed by secure storage. - */ - fun getOrCreateKey(store: BLSecureStore, account: String = "field_encrypt_dek"): SecretKey { - val existingHex = store.read(account) - if (existingHex != null) { - return keyFromHex(existingHex) - } - - val newKey = generateKey() - val hex = newKey.encoded.toHexString() - store.save(account, hex) - return newKey - } - - /** - * Load an existing encryption key from [BLSecureStore] without creating one. - * - * @param store The [BLSecureStore] instance. - * @param account Storage key name (default: `"field_encrypt_dek"`). - * @return The stored [SecretKey], or `null` if none exists. - */ - fun loadKey(store: BLSecureStore, account: String = "field_encrypt_dek"): SecretKey? { - val hex = store.read(account) ?: return null - return try { - keyFromHex(hex) - } catch (_: IllegalArgumentException) { - null - } - } - - /** - * Delete the stored encryption key from [BLSecureStore]. - * - * @param store The [BLSecureStore] instance. - * @param account Storage key name. - * @return `true` if the key was deleted. - */ - fun deleteKey(store: BLSecureStore, account: String = "field_encrypt_dek"): Boolean { - return store.delete(account) - } - - // ── Type Guard ────────────────────────────────────────── - - /** - * Check if a JSON-like map represents an encrypted field. - * Compatible with the TypeScript `isEncryptedField()` type guard. - */ - fun isEncrypted(value: Map?): Boolean { - if (value == null) return false - return value["__encrypted"] == true && - value["v"] != null && - value["alg"] != null && - value["ct"] != null && - value["iv"] != null && - value["tag"] != null && - value["dekId"] != null - } - - /** - * Check if a [BLEncryptedField] is valid. - */ - fun isEncrypted(field: BLEncryptedField?): Boolean { - return field?.__encrypted == true - } -} - -// ── Hex Helpers ───────────────────────────────────────────── - -/** Hex-encode a byte array to a lowercase string. */ -fun ByteArray.toHexString(): String = - joinToString("") { "%02x".format(it) } - -/** Decode a hex-encoded string to a byte array. */ -fun String.hexToByteArray(): ByteArray { - require(length % 2 == 0) { "Hex string must have even length, got $length" } - return ByteArray(length / 2) { i -> - val offset = i * 2 - substring(offset, offset + 2).toInt(16).toByte() - } -} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLKillSwitchClient.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLKillSwitchClient.kt deleted file mode 100644 index b05d172..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLKillSwitchClient.kt +++ /dev/null @@ -1,43 +0,0 @@ -package com.bytelyst.platform - -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json -import java.net.URLEncoder - -/** - * Kill switch client for platform-service. - * - * Checks GET /api/settings/kill-switch to determine if the app should be disabled. - * Fail-open: returns [KillSwitchResult.ok] on any error. - * - * Mirrors the Swift BLKillSwitchClient API. - */ -class BLKillSwitchClient( - private val config: BLPlatformConfig, -) { - @Serializable - data class KillSwitchResult( - val disabled: Boolean = false, - val message: String? = null, - ) { - companion object { - fun ok() = KillSwitchResult(disabled = false, message = null) - } - } - - private val json = Json { ignoreUnknownKeys = true } - private val client = BLPlatformClient(config) - - /** - * Check if the app is disabled. Fail-open on any error. - */ - suspend fun check(): KillSwitchResult { - return try { - val enc = { v: String -> URLEncoder.encode(v, "UTF-8") } - val response = client.request("GET", "/api/settings/kill-switch?productId=${enc(config.productId)}&platform=${enc(config.platform)}", skipAuth = true) - json.decodeFromString(response) - } catch (_: Exception) { - KillSwitchResult.ok() - } - } -} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLLicenseClient.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLLicenseClient.kt deleted file mode 100644 index 417141f..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLLicenseClient.kt +++ /dev/null @@ -1,81 +0,0 @@ -package com.bytelyst.platform - -import kotlinx.serialization.Serializable -import kotlinx.serialization.builtins.MapSerializer -import kotlinx.serialization.builtins.serializer -import kotlinx.serialization.json.Json -import java.net.URLEncoder - -/** - * License client for platform-service. - * - * Activates and checks license keys via /api/licenses endpoints. - * URL-encodes the license key in the path to handle special characters. - * - * Mirrors the Swift BLLicenseClient API. - */ -class BLLicenseClient( - private val config: BLPlatformConfig, - private val tokenProvider: () -> String? = { null }, -) { - @Serializable - data class LicenseStatus( - val valid: Boolean = false, - val plan: String = "free", - val expiresAt: String? = null, - val message: String? = null, - ) - - @Serializable - data class ActivationResult( - val success: Boolean = false, - val plan: String = "free", - val message: String? = null, - ) - - private val json = Json { ignoreUnknownKeys = true } - private val client = BLPlatformClient(config, tokenProvider) - - /** - * Activate a license key. - */ - suspend fun activate(licenseKey: String): ActivationResult { - return try { - val encoded = URLEncoder.encode(licenseKey, "UTF-8") - val body = json.encodeToString( - MapSerializer(String.serializer(), String.serializer()), - mapOf("productId" to config.productId), - ) - val response = client.request("POST", "/api/licenses/$encoded/activate", body) - json.decodeFromString(response) - } catch (e: Exception) { - ActivationResult(success = false, message = e.message) - } - } - - /** - * Check the status of a license key. - */ - suspend fun checkStatus(licenseKey: String): LicenseStatus { - return try { - val encoded = URLEncoder.encode(licenseKey, "UTF-8") - val response = client.request("GET", "/api/licenses/$encoded/status") - json.decodeFromString(response) - } catch (e: Exception) { - LicenseStatus(valid = false, message = e.message) - } - } - - /** - * Deactivate a license key. - */ - suspend fun deactivate(licenseKey: String): Boolean { - return try { - val encoded = URLEncoder.encode(licenseKey, "UTF-8") - client.request("POST", "/api/licenses/$encoded/deactivate") - true - } catch (_: Exception) { - false - } - } -} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLPasskeyManager.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLPasskeyManager.kt deleted file mode 100644 index a14f596..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLPasskeyManager.kt +++ /dev/null @@ -1,132 +0,0 @@ -package com.bytelyst.platform - -import android.content.Context -import androidx.credentials.CreatePublicKeyCredentialRequest -import androidx.credentials.CredentialManager -import androidx.credentials.GetCredentialRequest -import androidx.credentials.GetPublicKeyCredentialOption -import androidx.credentials.PublicKeyCredential -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.jsonPrimitive - -/** - * Passkey manager wrapping Android Credential Manager API. - * - * Handles FIDO2/WebAuthn passkey registration and authentication - * by coordinating between the platform-service backend and the - * Android Credential Manager. - * - * Usage: - * ```kotlin - * val manager = BLPasskeyManager(context, authClient) - * // Register a new passkey - * manager.registerPasskey("My Pixel 9") - * // Authenticate with an existing passkey - * val user = manager.authenticateWithPasskey() - * ``` - */ -class BLPasskeyManager( - private val context: Context, - private val authClient: BLAuthClient, -) { - private val credentialManager = CredentialManager.create(context) - private val json get() = authClient.client.json - - /** - * Register a new passkey for the current user. - * - * 1. Fetches registration options from backend - * 2. Invokes Credential Manager to create credential - * 3. Sends attestation response to backend for verification - * - * @param friendlyName Human-readable name for this passkey (e.g. "Pixel 9") - * @throws Exception if any step fails - */ - suspend fun registerPasskey(friendlyName: String) { - // Step 1: Get registration options from backend - val optionsResponse = authClient.client.request( - "POST", - "/api/auth/passkeys/register/options", - ) - - // Step 2: Create credential via Credential Manager - val request = CreatePublicKeyCredentialRequest( - requestJson = optionsResponse, - ) - val result = credentialManager.createCredential(context, request) - val credential = result as? androidx.credentials.CreatePublicKeyCredentialResponse - ?: throw IllegalStateException("Unexpected credential type") - - // Step 3: Send attestation to backend - val attestationJson = credential.registrationResponseJson - // Append friendlyName to the response - val bodyObj = json.decodeFromString(attestationJson) - val mutableMap = bodyObj.toMutableMap() - mutableMap["friendlyName"] = kotlinx.serialization.json.JsonPrimitive(friendlyName) - val body = json.encodeToString(JsonObject.serializer(), JsonObject(mutableMap)) - - authClient.client.request( - "POST", - "/api/auth/passkeys/register/verify", - body, - ) - } - - /** - * Authenticate using an existing passkey. - * - * 1. Fetches authentication options from backend - * 2. Invokes Credential Manager to select and sign with credential - * 3. Sends assertion response to backend for verification - * 4. Returns authenticated user and stores tokens - * - * @return Authenticated user - * @throws Exception if any step fails - */ - suspend fun authenticateWithPasskey(): BLAuthClient.AuthUser { - // Step 1: Get authentication options from backend - val optionsResponse = authClient.client.request( - "POST", - "/api/auth/passkeys/authenticate/options", - skipAuth = true, - ) - - // Step 2: Get credential via Credential Manager - val getRequest = GetCredentialRequest( - listOf(GetPublicKeyCredentialOption(requestJson = optionsResponse)), - ) - val result = credentialManager.getCredential(context, getRequest) - val credential = result.credential as? PublicKeyCredential - ?: throw IllegalStateException("Unexpected credential type") - - // Step 3: Send assertion to backend - val assertionJson = credential.authenticationResponseJson - val response = authClient.client.request( - "POST", - "/api/auth/passkeys/authenticate/verify", - assertionJson, - skipAuth = true, - ) - - // Step 4: Parse tokens and update auth state - val tokenResult = json.decodeFromString(response) - // Use reflection-free approach: directly set tokens - authClient.handleLoginResult(tokenResult) - return tokenResult.user - } - - /** - * List registered passkeys for the current user. - */ - suspend fun listPasskeys(): List { - val response = authClient.client.request("GET", "/api/auth/passkeys") - return json.decodeFromString>(response) - } - - /** - * Delete a passkey (requires step-up authentication). - */ - suspend fun deletePasskey(passkeyId: String) { - authClient.client.request("DELETE", "/api/auth/passkeys/$passkeyId") - } -} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLPlatformClient.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLPlatformClient.kt deleted file mode 100644 index cc8334a..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLPlatformClient.kt +++ /dev/null @@ -1,99 +0,0 @@ -package com.bytelyst.platform - -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import kotlinx.serialization.json.Json -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.RequestBody.Companion.toRequestBody -import java.util.UUID -import java.util.concurrent.TimeUnit - -/** - * Generic HTTP client for platform-service. - * - * Injects auth token, x-product-id, and x-request-id on every request. - * Supports fire-and-forget mode for telemetry-style calls. - */ -class BLPlatformClient( - private val config: BLPlatformConfig, - private val tokenProvider: () -> String? = { null }, -) { - val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } - - private val httpClient = OkHttpClient.Builder() - .connectTimeout(config.timeoutMs, TimeUnit.MILLISECONDS) - .writeTimeout(config.timeoutMs, TimeUnit.MILLISECONDS) - .readTimeout(config.timeoutMs, TimeUnit.MILLISECONDS) - .build() - - /** - * Execute a request and return the response body as a String. - * Throws on non-2xx responses. - */ - suspend fun request( - method: String, - path: String, - body: String? = null, - extraHeaders: Map = emptyMap(), - skipAuth: Boolean = false, - ): String = withContext(Dispatchers.IO) { - val builder = Request.Builder() - .url("${config.baseUrl}$path") - .header("Content-Type", "application/json") - .header("X-Product-Id", config.productId) - .header("X-Request-Id", UUID.randomUUID().toString()) - - if (!skipAuth) { - tokenProvider()?.let { token -> - builder.header("Authorization", "Bearer $token") - } - } - - extraHeaders.forEach { (k, v) -> builder.header(k, v) } - - val requestBody = body?.toRequestBody("application/json".toMediaType()) - when (method.uppercase()) { - "GET" -> builder.get() - "POST" -> builder.post(requestBody ?: "".toRequestBody(null)) - "PUT" -> builder.put(requestBody ?: "".toRequestBody(null)) - "DELETE" -> if (requestBody != null) builder.delete(requestBody) else builder.delete() - "PATCH" -> builder.patch(requestBody ?: "".toRequestBody(null)) - else -> builder.method(method, requestBody) - } - - val response = httpClient.newCall(builder.build()).execute() - val responseBody = response.body?.string() ?: "" - - if (!response.isSuccessful) { - throw BLApiException(response.code, responseBody) - } - - responseBody - } - - /** - * Fire-and-forget: execute request, silently swallow errors. - */ - suspend fun fireAndForget( - method: String, - path: String, - body: String? = null, - extraHeaders: Map = emptyMap(), - ) { - try { - request(method, path, body, extraHeaders) - } catch (_: Exception) { - // Silently swallow — fire-and-forget - } - } -} - -/** - * Exception thrown when the platform API returns a non-2xx status. - */ -class BLApiException( - val statusCode: Int, - val responseBody: String, -) : Exception("Platform API error $statusCode: $responseBody") diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLPlatformConfig.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLPlatformConfig.kt deleted file mode 100644 index 62520dd..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLPlatformConfig.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.bytelyst.platform - -/** - * Product-specific configuration for the ByteLyst platform SDK. - * - * Each Android app creates one instance at startup and passes it to - * all SDK components via DI (Hilt, Koin, or manual). - */ -data class BLPlatformConfig( - /** Product identifier (e.g., "chronomind", "lysnrai", "mindlyst"). */ - val productId: String, - - /** Platform-service base URL (e.g., "http://localhost:4003/api"). */ - val baseUrl: String, - - /** Platform string for telemetry (e.g., "android", "wear_os"). */ - val platform: String = "android", - - /** Channel string for telemetry (e.g., "native", "keyboard"). */ - val channel: String = "native", - - /** Application ID / bundle ID (e.g., "com.chronomind.app"). */ - val applicationId: String, - - /** App version string for telemetry headers (e.g., "1.2.0"). */ - val appVersion: String = "0.0.0", - - /** OS version string for telemetry headers (e.g., "14"). */ - val osVersion: String = "unknown", - - /** Request timeout in milliseconds. Default: 15 seconds. */ - val timeoutMs: Long = 15_000L, -) diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLSecureStore.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLSecureStore.kt deleted file mode 100644 index 2550bea..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLSecureStore.kt +++ /dev/null @@ -1,51 +0,0 @@ -package com.bytelyst.platform - -import android.content.Context -import android.content.SharedPreferences -import androidx.security.crypto.EncryptedSharedPreferences -import androidx.security.crypto.MasterKeys - -/** - * EncryptedSharedPreferences-backed secure storage. - * - * Replaces Keychain on iOS. Uses Android Keystore for key material. - * Each app gets its own namespace via [applicationId]. - */ -class BLSecureStore( - context: Context, - applicationId: String, -) { - private val prefs: SharedPreferences = try { - val masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC) - EncryptedSharedPreferences.create( - "${applicationId}_secure_store", - masterKeyAlias, - context, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, - ) - } catch (_: Exception) { - // Fallback to plain SharedPreferences if encryption fails (e.g., test environment) - context.getSharedPreferences("${applicationId}_secure_store_fallback", Context.MODE_PRIVATE) - } - - fun save(key: String, value: String): Boolean { - return prefs.edit().putString(key, value).commit() - } - - fun read(key: String): String? { - return prefs.getString(key, null) - } - - fun delete(key: String): Boolean { - return prefs.edit().remove(key).commit() - } - - fun clear(): Boolean { - return prefs.edit().clear().commit() - } - - fun contains(key: String): Boolean { - return prefs.contains(key) - } -} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLSurveyClient.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLSurveyClient.kt deleted file mode 100644 index 2158714..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLSurveyClient.kt +++ /dev/null @@ -1,367 +0,0 @@ -package com.bytelyst.platform - -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.isActive -import kotlinx.coroutines.withContext -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.jsonPrimitive -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.RequestBody.Companion.toRequestBody -import java.util.concurrent.TimeUnit - -/** - * Survey Client — In-app survey client for Android. - * Part of ByteLystPlatformSDK. - */ -class BLSurveyClient( - private val config: BLPlatformConfig, - private val tokenProvider: () -> String? = { null }, -) { - private val httpClient = OkHttpClient.Builder() - .connectTimeout(30, TimeUnit.SECONDS) - .readTimeout(30, TimeUnit.SECONDS) - .build() - - private val json = Json { ignoreUnknownKeys = true } - private var pollingJob: Job? = null - private val responseCache = mutableMapOf() - - enum class QuestionType { - SINGLE_CHOICE, MULTIPLE_CHOICE, RATING, NPS, TEXT_SHORT, TEXT_LONG, DROPDOWN, SCALE, RANKING - } - - @Serializable - data class QuestionOption( - val id: String, - val text: String, - val emoji: String? = null, - ) - - @Serializable - data class Question( - val id: String, - val type: String, - val text: String, - val description: String? = null, - val required: Boolean, - val options: List? = null, - val minLength: Int? = null, - val maxLength: Int? = null, - val minValue: Int? = null, - val maxValue: Int? = null, - ) - - @Serializable - data class SurveyIncentive( - val type: String, - val amount: Int, - ) - - @Serializable - data class SurveyTrigger( - val type: String, - val seconds: Int? = null, - val eventName: String? = null, - val pagePattern: String? = null, - ) - - @Serializable - data class ActiveSurvey( - val id: String, - val title: String, - val description: String? = null, - val questions: List, - val incentive: SurveyIncentive? = null, - val displayTrigger: SurveyTrigger, - ) - - @Serializable - data class SurveyAnswer( - val type: String, - val value: JsonObject, - ) - - @Serializable - data class SurveyResponse( - val id: String, - val surveyId: String, - val userId: String, - val answers: Map, - val currentQuestionIndex: Int, - val startedAt: String, - val completedAt: String? = null, - val isComplete: Boolean, - val incentiveClaimed: Boolean, - val incentiveClaimedAt: String? = null, - val createdAt: String, - val updatedAt: String, - ) - - @Serializable - private data class ActiveSurveyResponse( - val survey: ActiveSurvey?, - ) - - @Serializable - private data class StartSurveyResponse( - val responseId: String, - val startedAt: String, - val currentQuestionIndex: Int, - val answers: Map, - ) - - @Serializable - private data class SubmitAnswerResponse( - val responseId: String, - val currentQuestionIndex: Int, - val answers: Map, - ) - - @Serializable - data class SurveyCompletionResult( - val success: Boolean, - val timeSpentSeconds: Int, - val incentiveClaimed: Boolean, - ) - - /** - * Get active survey for the current user (if any). - */ - suspend fun getActiveSurvey(): Result = withContext(Dispatchers.IO) { - try { - val request = buildRequest(path = "/surveys/active") - val response = httpClient.newCall(request).execute() - - if (!response.isSuccessful) { - return@withContext Result.failure(Exception("HTTP ${response.code}")) - } - - val body = response.body?.string() ?: return@withContext Result.failure(Exception("Empty response")) - val result = json.decodeFromString(ActiveSurveyResponse.serializer(), body) - Result.success(result.survey) - } catch (e: Exception) { - Result.failure(e) - } - } - - /** - * Start a survey session. - */ - suspend fun startSurvey(surveyId: String): Result = withContext(Dispatchers.IO) { - try { - val request = buildRequest( - path = "/surveys/$surveyId/start", - method = "POST" - ) - val response = httpClient.newCall(request).execute() - - if (!response.isSuccessful) { - return@withContext Result.failure(Exception("HTTP ${response.code}")) - } - - val body = response.body?.string() ?: return@withContext Result.failure(Exception("Empty response")) - val result = json.decodeFromString(StartSurveyResponse.serializer(), body) - - val surveyResponse = SurveyResponse( - id = result.responseId, - surveyId = surveyId, - userId = "", - answers = result.answers, - currentQuestionIndex = result.currentQuestionIndex, - startedAt = result.startedAt, - completedAt = null, - isComplete = false, - incentiveClaimed = false, - incentiveClaimedAt = null, - createdAt = result.startedAt, - updatedAt = result.startedAt, - ) - - // Cache the response - responseCache[surveyId] = surveyResponse - Result.success(surveyResponse) - } catch (e: Exception) { - Result.failure(e) - } - } - - /** - * Submit an answer to a survey question. - */ - suspend fun submitAnswer( - surveyId: String, - questionId: String, - answer: SurveyAnswer, - ): Result = withContext(Dispatchers.IO) { - try { - val body = json.encodeToString( - SubmitAnswerRequest.serializer(), - SubmitAnswerRequest(questionId, answer) - ) - - val request = buildRequest( - path = "/surveys/$surveyId/response", - method = "POST", - body = body - ) - val response = httpClient.newCall(request).execute() - - if (!response.isSuccessful) { - return@withContext Result.failure(Exception("HTTP ${response.code}")) - } - - val responseBody = response.body?.string() ?: return@withContext Result.failure(Exception("Empty response")) - val result = json.decodeFromString(SubmitAnswerResponse.serializer(), responseBody) - - // Update cache - val cached = responseCache[surveyId] - if (cached != null) { - val updated = cached.copy( - answers = result.answers, - currentQuestionIndex = result.currentQuestionIndex, - ) - responseCache[surveyId] = updated - } - - Result.success( - SurveyResponse( - id = result.responseId, - surveyId = surveyId, - userId = "", - answers = result.answers, - currentQuestionIndex = result.currentQuestionIndex, - startedAt = "", - completedAt = null, - isComplete = false, - incentiveClaimed = false, - incentiveClaimedAt = null, - createdAt = "", - updatedAt = java.time.Instant.now().toString(), - ) - ) - } catch (e: Exception) { - Result.failure(e) - } - } - - /** - * Complete a survey. - */ - suspend fun completeSurvey(surveyId: String): Result = withContext(Dispatchers.IO) { - try { - val request = buildRequest( - path = "/surveys/$surveyId/complete", - method = "POST" - ) - val response = httpClient.newCall(request).execute() - - if (!response.isSuccessful) { - return@withContext Result.failure(Exception("HTTP ${response.code}")) - } - - val body = response.body?.string() ?: return@withContext Result.failure(Exception("Empty response")) - val result = json.decodeFromString(SurveyCompletionResult.serializer(), body) - - // Clear cache on completion - responseCache.remove(surveyId) - Result.success(result) - } catch (e: Exception) { - Result.failure(e) - } - } - - /** - * Dismiss a survey (won't show again). - */ - suspend fun dismissSurvey(surveyId: String): Result = withContext(Dispatchers.IO) { - try { - val request = buildRequest( - path = "/surveys/$surveyId/dismiss", - method = "POST" - ) - val response = httpClient.newCall(request).execute() - - if (!response.isSuccessful) { - return@withContext Result.failure(Exception("HTTP ${response.code}")) - } - - // Clear cache - responseCache.remove(surveyId) - Result.success(Unit) - } catch (e: Exception) { - Result.failure(e) - } - } - - /** - * Get cached response for a survey. - */ - fun getCachedResponse(surveyId: String): SurveyResponse? { - return responseCache[surveyId] - } - - /** - * Start polling for eligible surveys. - */ - fun startPolling( - intervalMs: Long = 60000L, - onUpdate: (ActiveSurvey?) -> Unit, - ) { - stopPolling() - pollingJob = CoroutineScope(Dispatchers.IO).launch { - while (isActive) { - getActiveSurvey() - .onSuccess { survey -> onUpdate(survey) } - .onFailure { /* Silently ignore polling errors */ } - delay(intervalMs) - } - } - } - - /** - * Stop polling for surveys. - */ - fun stopPolling() { - pollingJob?.cancel() - pollingJob = null - } - - @Serializable - private data class SubmitAnswerRequest( - val questionId: String, - val answer: SurveyAnswer, - ) - - private fun buildRequest( - path: String, - method: String = "GET", - body: String? = null, - ): Request { - val url = "${config.baseUrl}$path" - val token = tokenProvider() ?: "" - - val builder = Request.Builder() - .url(url) - .header("Authorization", "Bearer $token") - .header("x-product-id", config.productId) - .header("x-platform", "android") - .header("x-app-version", config.appVersion) - .header("x-os-version", config.osVersion) - - if (body != null) { - builder.method(method, body.toRequestBody("application/json".toMediaType())) - } else if (method != "GET") { - builder.method(method, "".toRequestBody(null)) - } - - return builder.build() - } -} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLSyncEngine.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLSyncEngine.kt deleted file mode 100644 index 060cbaf..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLSyncEngine.kt +++ /dev/null @@ -1,111 +0,0 @@ -package com.bytelyst.platform - -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext - -/** - * Generic offline-first sync engine. - * - * Apps implement [BLSyncAdapter] for their specific data type, - * then [BLSyncEngine] handles the pull-merge-push cycle. - * - * Mirrors the Swift BLSyncEngine + BLSyncAdapter API. - */ -class BLSyncEngine( - private val adapter: BLSyncAdapter, - private val config: BLPlatformConfig, - tokenProvider: () -> String? = { null }, -) { - private val client = BLPlatformClient(config, tokenProvider) - - /** - * Run a full sync cycle: pull → merge → push. - * - * @return [SyncResult] with counts of pulled, pushed, and conflicted items. - */ - suspend fun sync(): SyncResult = withContext(Dispatchers.IO) { - var pulled = 0 - var pushed = 0 - var conflicts = 0 - - try { - // Step 1: Pull remote changes - val lastSync = adapter.getLastSyncTimestamp() - val pullPath = adapter.pullPath(lastSync) - val response = client.request("GET", pullPath) - val remoteItems = adapter.deserializePullResponse(response) - pulled = remoteItems.size - - // Step 2: Merge remote into local - for (item in remoteItems) { - val merged = adapter.merge(item) - if (!merged) conflicts++ - } - - // Step 3: Push local changes - val localChanges = adapter.getLocalChanges() - for (item in localChanges) { - try { - val body = adapter.serializeForPush(item) - val method = adapter.pushMethod(item) - val path = adapter.pushPath(item) - client.request(method, path, body) - adapter.markSynced(item) - pushed++ - } catch (_: Exception) { - // Individual push failure — will retry next sync - } - } - - // Step 4: Update last sync timestamp - adapter.setLastSyncTimestamp(System.currentTimeMillis()) - } catch (_: Exception) { - // Full sync failure — will retry next time - } - - SyncResult(pulled = pulled, pushed = pushed, conflicts = conflicts) - } - - data class SyncResult( - val pulled: Int = 0, - val pushed: Int = 0, - val conflicts: Int = 0, - ) -} - -/** - * Adapter interface for [BLSyncEngine]. - * - * Each app implements this for their domain model (timers, sessions, etc.). - */ -interface BLSyncAdapter { - /** Get the timestamp of the last successful sync (epoch millis), or null if never synced. */ - fun getLastSyncTimestamp(): Long? - - /** Set the last sync timestamp after a successful sync. */ - fun setLastSyncTimestamp(timestamp: Long) - - /** Build the pull endpoint path, optionally including a since parameter. */ - fun pullPath(since: Long?): String - - /** Deserialize the pull response JSON into a list of remote items. */ - fun deserializePullResponse(json: String): List - - /** Merge a remote item into local storage. Return true if merged cleanly, false if conflict. */ - fun merge(remoteItem: T): Boolean - - /** Get local items that have been modified since last sync. */ - fun getLocalChanges(): List - - /** Serialize a local item for push. */ - fun serializeForPush(item: T): String - - /** HTTP method for pushing an item (POST for new, PUT for update). */ - fun pushMethod(item: T): String - - /** Build the push endpoint path for an item. */ - fun pushPath(item: T): String - - /** Mark an item as synced (clear dirty flag). */ - fun markSynced(item: T) -} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLTelemetryClient.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLTelemetryClient.kt deleted file mode 100644 index 35a061d..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLTelemetryClient.kt +++ /dev/null @@ -1,195 +0,0 @@ -package com.bytelyst.platform - -import android.content.Context -import android.content.SharedPreferences -import android.os.Build -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import kotlinx.serialization.Serializable -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale -import java.util.TimeZone -import java.util.UUID - -/** - * Telemetry client for platform-service. - * - * Queues events locally and flushes in batches to POST /api/telemetry/events. - * Events persist in SharedPreferences to survive process death. - * Fire-and-forget: errors never surface to the user. - * - * Mirrors the Swift BLTelemetryClient API. - */ -class BLTelemetryClient( - private val config: BLPlatformConfig, - context: Context, - private val maxQueueSize: Int = 50, - private val flushIntervalMs: Long = 30_000L, -) { - // ── Event schema ───────────────────────────────────────── - - @Serializable - data class TelemetryEvent( - val id: String, - val productId: String, - val anonymousInstallId: String, - val sessionId: String, - val platform: String, - val channel: String, - val osFamily: String, - val osVersion: String, - val appVersion: String, - val buildNumber: String, - val releaseChannel: String, - val eventType: String, - val module: String, - val eventName: String, - val feature: String? = null, - val message: String? = null, - val errorCode: String? = null, - val errorDomain: String? = null, - val tags: Map? = null, - val metrics: Map? = null, - val occurredAt: String, - ) - - @Serializable - private data class EventBatch(val events: List) - - // ── State ──────────────────────────────────────────────── - - private val prefs: SharedPreferences = - context.getSharedPreferences("${config.productId}_telemetry", Context.MODE_PRIVATE) - private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } - private val queue = mutableListOf() - private var flushJob: Job? = null - private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - - private var sessionId = UUID.randomUUID().toString() - - private val client = BLPlatformClient(config) - - private val installId: String by lazy { - val key = "install_id" - prefs.getString(key, null) ?: UUID.randomUUID().toString().also { - prefs.edit().putString(key, it).apply() - } - } - - private val osVersion = "Android ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})" - private val appVersion: String - private val buildNumber: String - - init { - val pm = context.packageManager - val pi = try { pm.getPackageInfo(context.packageName, 0) } catch (_: Exception) { null } - appVersion = pi?.versionName ?: "0.0.0" - buildNumber = (pi?.longVersionCode ?: 0).toString() - } - - private val isoFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply { - timeZone = TimeZone.getTimeZone("UTC") - } - - /** Thread-safe ISO timestamp — SimpleDateFormat is NOT thread-safe. */ - @Synchronized - private fun isoNow(): String = isoFormat.format(Date()) - - // ── Lifecycle ──────────────────────────────────────────── - - fun start() { - if (flushJob != null) return - flushJob = scope.launch { - while (isActive) { - delay(flushIntervalMs) - flush() - } - } - } - - fun stop() { - flush() - flushJob?.cancel() - flushJob = null - } - - fun newSession() { - sessionId = UUID.randomUUID().toString() - } - - // ── Public API ─────────────────────────────────────────── - - fun trackEvent( - eventType: String, - module: String, - name: String, - feature: String? = null, - message: String? = null, - errorCode: String? = null, - errorDomain: String? = null, - tags: Map? = null, - metrics: Map? = null, - ) { - val event = TelemetryEvent( - id = UUID.randomUUID().toString(), - productId = config.productId, - anonymousInstallId = installId, - sessionId = sessionId, - platform = config.platform, - channel = config.channel, - osFamily = "android", - osVersion = osVersion, - appVersion = appVersion, - buildNumber = buildNumber, - releaseChannel = "beta", - eventType = eventType, - module = module, - eventName = name, - feature = feature, - message = message?.take(512), - errorCode = errorCode, - errorDomain = errorDomain, - tags = tags, - metrics = metrics, - occurredAt = isoNow(), - ) - - synchronized(queue) { - queue.add(event) - if (queue.size >= maxQueueSize) { - flush() - } - } - } - - fun trackScreen(screen: String) { - trackEvent("info", "navigation", "screen_view", tags = mapOf("screen" to screen)) - } - - // ── Flush ──────────────────────────────────────────────── - - fun flush() { - val batch: List - synchronized(queue) { - if (queue.isEmpty()) return - batch = queue.toList() - queue.clear() - } - - scope.launch { - val body = json.encodeToString(EventBatch(batch)) - client.fireAndForget( - "POST", "/api/telemetry/events", body, - extraHeaders = mapOf("X-Install-Token" to installId), - ) - } - } -} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/ByteLystPlatform.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/ByteLystPlatform.kt deleted file mode 100644 index 3fd4d4b..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/ByteLystPlatform.kt +++ /dev/null @@ -1,80 +0,0 @@ -package com.bytelyst.platform - -import android.content.Context - -/** - * Unified entry point for the ByteLyst platform SDK. - * - * Creates and wires all platform services from a single config + context. - * Mirrors the Swift `ByteLystPlatform` API. - * - * Usage: - * ```kotlin - * val platform = ByteLystPlatform( - * context = applicationContext, - * config = BLPlatformConfig( - * productId = "chronomind", - * baseUrl = "https://api.chronomind.app", - * applicationId = "com.chronomind.app" - * ) - * ) - * - * platform.start() - * platform.telemetry.trackScreen("home") - * val isNew = platform.flags.isEnabled("new_feature") - * platform.stop() - * ``` - */ -class ByteLystPlatform( - context: Context, - val config: BLPlatformConfig, -) { - /** Secure storage (EncryptedSharedPreferences). */ - val secureStore: BLSecureStore = BLSecureStore(context, config.applicationId) - - /** HTTP client shared by services that need a token provider. */ - val client: BLPlatformClient = BLPlatformClient(config) { - secureStore.read("access_token") - } - - /** Auth client (login, register, refresh, MFA, etc.). */ - val auth: BLAuthClient = BLAuthClient(config, secureStore) - - /** Telemetry event tracking + batch flush. */ - val telemetry: BLTelemetryClient = BLTelemetryClient(config, context) - - /** Feature flag polling. */ - val flags: BLFeatureFlagClient = BLFeatureFlagClient(config) - - /** Kill switch checker. */ - val killSwitch: BLKillSwitchClient = BLKillSwitchClient(config) - - /** Local rotating audit log. */ - val auditLog: BLAuditLogger = BLAuditLogger(context, config) - - /** Whether [start] has been called. */ - var isStarted: Boolean = false - private set - - /** - * Start all services: telemetry flush timer, feature flag polling. - * Call on app launch / Activity.onCreate. - */ - fun start(userId: String? = null) { - if (isStarted) return - isStarted = true - telemetry.start() - flags.init(userId) - } - - /** - * Stop all services: flush telemetry, stop flag polling. - * Call on app background / Activity.onDestroy. - */ - fun stop() { - if (!isStarted) return - isStarted = false - telemetry.stop() - flags.stop() - } -} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/DeepLinkRouter.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/DeepLinkRouter.kt deleted file mode 100644 index 7b5772b..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/DeepLinkRouter.kt +++ /dev/null @@ -1,172 +0,0 @@ -package com.bytelyst.platform - -import android.net.Uri -import android.util.Log - -/** - * Deep Link Route data class - */ -data class DeepLinkRoute( - val screen: String, - val params: Map = emptyMap() -) - -/** - * Deep link handler type alias - */ -typealias DeepLinkHandler = (DeepLinkRoute) -> Unit - -/** - * Deep Link Router class - * Handles routing from push notification deep links to app screens - */ -class DeepLinkRouter { - private val handlers = mutableMapOf() - private var fallbackHandler: DeepLinkHandler? = null - - companion object { - private const val TAG = "DeepLinkRouter" - } - - /** - * Register a handler for a specific screen - */ - fun register(screen: String, handler: DeepLinkHandler) { - handlers[screen] = handler - } - - /** - * Set a fallback handler for unregistered screens - */ - fun setFallback(handler: DeepLinkHandler) { - fallbackHandler = handler - } - - /** - * Parse a deep link URL and extract route - */ - fun parseDeepLink(urlString: String): DeepLinkRoute? { - return try { - val uri = Uri.parse(urlString) - - // Handle app-specific URLs: myapp://screen/params - if (uri.scheme != "http" && uri.scheme != "https") { - val pathSegments = uri.pathSegments - val screen = pathSegments.firstOrNull() ?: "home" - - val params = mutableMapOf() - uri.queryParameterNames.forEach { key -> - uri.getQueryParameter(key)?.let { value -> - params[key] = value - } - } - - DeepLinkRoute(screen, params) - } - // Handle web URLs with deep link params - else if (uri.getQueryParameter("dl") != null) { - parseDeepLink(uri.getQueryParameter("dl")!!) - } - // Handle path-based routing: /screen/params - else { - val pathSegments = uri.pathSegments - if (pathSegments.isNotEmpty()) { - val screen = pathSegments[0] - - val params = mutableMapOf() - uri.queryParameterNames.forEach { key -> - uri.getQueryParameter(key)?.let { value -> - params[key] = value - } - } - - DeepLinkRoute(screen, params) - } else { - null - } - } - } catch (e: Exception) { - Log.w(TAG, "Failed to parse deep link: $urlString", e) - null - } - } - - /** - * Handle a deep link route - */ - fun handle(route: DeepLinkRoute): Boolean { - val handler = handlers[route.screen] - - return if (handler != null) { - handler(route) - true - } else if (fallbackHandler != null) { - fallbackHandler?.invoke(route) - true - } else { - Log.w(TAG, "No handler for screen: ${route.screen}") - false - } - } - - /** - * Process a deep link URL end-to-end - */ - fun process(urlString: String): Boolean { - val route = parseDeepLink(urlString) - return if (route != null) { - handle(route) - } else { - Log.w(TAG, "Failed to parse deep link: $urlString") - false - } - } -} - -/** - * Create a broadcast deep link URL - */ -fun createBroadcastDeepLink( - baseUrl: String, - screen: String, - params: Map = emptyMap(), - broadcastId: String? = null -): String { - val uriBuilder = Uri.parse(baseUrl).buildUpon() - .path("/$screen") - - params.forEach { (key, value) -> - uriBuilder.appendQueryParameter(key, value) - } - - broadcastId?.let { - uriBuilder.appendQueryParameter("broadcastId", it) - } - - return uriBuilder.build().toString() -} - -/** - * Common deep link screens - */ -object DeepLinkScreens { - // Broadcasts - const val BROADCAST = "broadcast" - const val ANNOUNCEMENTS = "announcements" - - // Surveys - const val SURVEY = "survey" - const val SURVEY_LIST = "surveys" - - // Product-specific - const val SETTINGS = "settings" - const val PROFILE = "profile" - const val UPGRADE = "upgrade" - const val SUPPORT = "support" - - // Fallback - const val HOME = "home" -} - -// Singleton instance for app-wide use -val deepLinkRouter = DeepLinkRouter() diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/BreadcrumbTrail.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/BreadcrumbTrail.kt deleted file mode 100644 index c8286cd..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/BreadcrumbTrail.kt +++ /dev/null @@ -1,74 +0,0 @@ -package com.bytelyst.platform.diagnostics - -import java.text.SimpleDateFormat -import java.util.* - -/** - * Ring buffer for breadcrumbs with fixed max size - */ -class BreadcrumbTrail(private val maxSize: Int = 100) { - private val breadcrumbs = mutableListOf() - private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply { - timeZone = TimeZone.getTimeZone("UTC") - } - - /** - * Add a breadcrumb to the trail - */ - @Synchronized - fun add(category: String, message: String, data: Map? = null) { - val breadcrumb = DiagnosticsBreadcrumb( - timestamp = dateFormat.format(Date()), - category = category, - message = message, - data = data - ) - - breadcrumbs.add(breadcrumb) - - // Evict oldest if over limit - if (breadcrumbs.size > maxSize) { - breadcrumbs.removeAt(0) - } - } - - /** - * Get all breadcrumbs (oldest first) - */ - @Synchronized - fun getAll(): List { - return breadcrumbs.toList() - } - - /** - * Get last N breadcrumbs - */ - @Synchronized - fun getLast(n: Int): List { - return breadcrumbs.takeLast(n) - } - - /** - * Get most recent breadcrumb - */ - @Synchronized - fun getMostRecent(): DiagnosticsBreadcrumb? { - return breadcrumbs.lastOrNull() - } - - /** - * Clear all breadcrumbs - */ - @Synchronized - fun clear() { - breadcrumbs.clear() - } - - /** - * Get current size - */ - @Synchronized - fun size(): Int { - return breadcrumbs.size - } -} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/DeviceStateCollector.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/DeviceStateCollector.kt deleted file mode 100644 index 4da2ec8..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/DeviceStateCollector.kt +++ /dev/null @@ -1,114 +0,0 @@ -package com.bytelyst.platform.diagnostics - -import android.app.ActivityManager -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.net.ConnectivityManager -import android.net.NetworkCapabilities -import android.os.BatteryManager -import android.os.Build -import android.os.StatFs - -/** - * Device state collector for Android - */ -object DeviceStateCollector { - - /** - * Collect current device state - */ - fun collect(context: Context): DiagnosticsDeviceState { - return DiagnosticsDeviceState( - memoryMB = getMemoryUsage(context), - batteryLevel = getBatteryLevel(context), - isCharging = getIsCharging(context), - storageMB = getStorageUsage(context), - networkType = getNetworkType(context), - isOnline = getIsOnline(context), - thermalState = null // Android doesn't expose thermal state easily - ) - } - - private fun getMemoryUsage(context: Context): Int? { - val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager - ?: return null - - val runtime = Runtime.getRuntime() - val usedMemory = (runtime.totalMemory() - runtime.freeMemory()) / (1024 * 1024) - - return usedMemory.toInt() - } - - private fun getBatteryLevel(context: Context): Float? { - val batteryIntent = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED)) - ?: return null - - val level = batteryIntent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) - val scale = batteryIntent.getIntExtra(BatteryManager.EXTRA_SCALE, -1) - - if (level == -1 || scale == -1) return null - - return level / scale.toFloat() - } - - private fun getIsCharging(context: Context): Boolean? { - val batteryIntent = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED)) - ?: return null - - val status = batteryIntent.getIntExtra(BatteryManager.EXTRA_STATUS, -1) - return status == BatteryManager.BATTERY_STATUS_CHARGING || - status == BatteryManager.BATTERY_STATUS_FULL - } - - private fun getStorageUsage(context: Context): Int? { - val stat = StatFs(context.filesDir.path) - val blockSize = stat.blockSizeLong - val availableBlocks = stat.availableBlocksLong - val totalBlocks = stat.blockCountLong - - val usedBytes = (totalBlocks - availableBlocks) * blockSize - return (usedBytes / (1024 * 1024)).toInt() - } - - private fun getNetworkType(context: Context): String? { - val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager - ?: return null - - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - val network = connectivityManager.activeNetwork ?: return "offline" - val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return "offline" - - when { - capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "wifi" - capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "cellular" - capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> "ethernet" - else -> "unknown" - } - } else { - @Suppress("DEPRECATION") - val networkInfo = connectivityManager.activeNetworkInfo - when (networkInfo?.type) { - ConnectivityManager.TYPE_WIFI -> "wifi" - ConnectivityManager.TYPE_MOBILE -> "cellular" - ConnectivityManager.TYPE_ETHERNET -> "ethernet" - else -> if (networkInfo?.isConnected == true) "unknown" else "offline" - } - } - } - - private fun getIsOnline(context: Context): Boolean { - val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager - ?: return true // Assume online if can't determine - - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - val network = connectivityManager.activeNetwork - val capabilities = connectivityManager.getNetworkCapabilities(network) - capabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true - } else { - @Suppress("DEPRECATION") - val networkInfo = connectivityManager.activeNetworkInfo - networkInfo?.isConnected == true - } - } -} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/DiagnosticsClient.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/DiagnosticsClient.kt deleted file mode 100644 index 50c8b2d..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/DiagnosticsClient.kt +++ /dev/null @@ -1,535 +0,0 @@ -package com.bytelyst.platform.diagnostics - -import android.content.Context -import kotlinx.coroutines.* -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json -import okhttp3.* -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.RequestBody.Companion.toRequestBody -import java.io.IOException -import java.text.SimpleDateFormat -import java.util.* -import java.util.concurrent.TimeUnit - -/** - * Client state - */ -sealed class DiagnosticsClientState { - object Idle : DiagnosticsClientState() - data class Polling(val session: DiagnosticsSession?) : DiagnosticsClientState() - data class Active(val session: DiagnosticsSession) : DiagnosticsClientState() - data class Error(val exception: Throwable) : DiagnosticsClientState() -} - -/** - * Diagnostics client configuration - */ -data class DiagnosticsConfiguration( - val productId: String, - val userId: String? = null, - val anonymousInstallId: String, - val platform: String, - val channel: String, - val osFamily: String, - val appVersion: String, - val buildNumber: String, - val releaseChannel: String, - val serverUrl: String, - val pollIntervalMs: Long = 5000, - val maxBreadcrumbs: Int = 100, - val captureConsole: Boolean = true, - val captureErrors: Boolean = true, - val captureNetwork: Boolean = true, - val getAuthToken: (suspend () -> String)? = null -) - -/** - * Logger interface - */ -interface DiagnosticsLogger { - fun debug(message: String, metadata: Map? = null) - fun info(message: String, metadata: Map? = null) - fun warn(message: String, metadata: Map? = null) - fun error(message: String, metadata: Map? = null) -} - -/** - * No-op logger - */ -class NoOpDiagnosticsLogger : DiagnosticsLogger { - override fun debug(message: String, metadata: Map?) {} - override fun info(message: String, metadata: Map?) {} - override fun warn(message: String, metadata: Map?) {} - override fun error(message: String, metadata: Map?) {} -} - -/** - * Android Log-based logger - */ -class AndroidDiagnosticsLogger(private val tag: String = "ByteLystDiagnostics") : DiagnosticsLogger { - override fun debug(message: String, metadata: Map?) { - android.util.Log.d(tag, message) - } - override fun info(message: String, metadata: Map?) { - android.util.Log.i(tag, message) - } - override fun warn(message: String, metadata: Map?) { - android.util.Log.w(tag, message) - } - override fun error(message: String, metadata: Map?) { - android.util.Log.e(tag, message) - } -} - -/** - * Main diagnostics client - */ -class DiagnosticsClient private constructor( - private val context: Context, - private val config: DiagnosticsConfiguration, - private val logger: DiagnosticsLogger -) { - companion object { - @Volatile - private var instance: DiagnosticsClient? = null - - fun getInstance( - context: Context, - config: DiagnosticsConfiguration, - logger: DiagnosticsLogger = NoOpDiagnosticsLogger() - ): DiagnosticsClient { - return instance ?: synchronized(this) { - instance ?: DiagnosticsClient(context.applicationContext, config, logger).also { - instance = it - } - } - } - - fun reset() { - instance?.stop() - instance = null - } - } - - private val _state = MutableStateFlow(DiagnosticsClientState.Idle) - val state: StateFlow = _state.asStateFlow() - - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - private val breadcrumbs = BreadcrumbTrail(maxSize = config.maxBreadcrumbs) - private val logBuffer = mutableListOf() - private val traceBuffer = mutableListOf() - private val networkBuffer = mutableListOf() - - private var pollJob: Job? = null - private var flushJob: Job? = null - private var networkInterceptor: NetworkInterceptor? = null - private var lastEtag: String? = null - - private val httpClient = OkHttpClient.Builder() - .connectTimeout(30, TimeUnit.SECONDS) - .readTimeout(30, TimeUnit.SECONDS) - .build() - - private val json = Json { ignoreUnknownKeys = true } - private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply { - timeZone = TimeZone.getTimeZone("UTC") - } - - /** - * Start polling for active debug sessions - */ - fun start() { - if (_state.value != DiagnosticsClientState.Idle) { - logger.warn("[diagnostics] Already started") - return - } - - logger.info("[diagnostics] Starting diagnostics client") - _state.value = DiagnosticsClientState.Polling(null) - - // Initial poll - scope.launch { - pollForSession() - } - - // Start polling timer - pollJob = scope.launch { - while (isActive) { - delay(config.pollIntervalMs) - pollForSession() - } - } - - // Start auto-flush timer (every 30 seconds) - flushJob = scope.launch { - while (isActive) { - delay(30000) - flush() - } - } - - // Setup network capture if enabled - if (config.captureNetwork) { - setupNetworkCapture() - } - - breadcrumbs.add(category = "diagnostics", message = "Client started") - } - - /** - * Stop polling and cleanup - */ - fun stop() { - logger.info("[diagnostics] Stopping diagnostics client") - - pollJob?.cancel() - pollJob = null - - flushJob?.cancel() - flushJob = null - - networkInterceptor?.stop() - networkInterceptor = null - - // Final flush - scope.launch { - flush() - } - - _state.value = DiagnosticsClientState.Idle - breadcrumbs.add(category = "diagnostics", message = "Client stopped") - } - - /** - * Check if a debug session is currently active - */ - fun isSessionActive(): Boolean { - return _state.value is DiagnosticsClientState.Active - } - - /** - * Get current session if active - */ - fun getCurrentSession(): DiagnosticsSession? { - return when (val current = _state.value) { - is DiagnosticsClientState.Active -> current.session - is DiagnosticsClientState.Polling -> current.session - else -> null - } - } - - /** - * Record a log entry - */ - fun log( - level: DiagnosticsLogLevel, - message: String, - module: String = "unknown", - file: String? = null, - line: Int? = null, - function: String? = null, - context: Map = emptyMap(), - correlationId: String? = null - ) { - val entry = DiagnosticsLogEntry( - level = level, - message = message, - timestamp = dateFormat.format(Date()), - module = module, - file = file, - line = line, - function = function, - context = context, - correlationId = correlationId - ) - - synchronized(logBuffer) { - logBuffer.add(entry) - } - - breadcrumbs.add( - category = "log", - message = "[${level.name}] ${message.take(100)}", - data = mapOf("level" to level.name) - ) - - // Auto-flush on fatal - if (level == DiagnosticsLogLevel.FATAL) { - scope.launch { flush() } - } - } - - /** - * Record a trace span (auto-instrumented) - */ - suspend fun trace(name: String, operation: suspend () -> T): T { - val spanId = generateId() - val startTime = dateFormat.format(Date()) - - breadcrumbs.add( - category = "trace", - message = "Starting: $name", - data = mapOf("spanId" to spanId) - ) - - return try { - val result = operation() - val endTime = dateFormat.format(Date()) - val durationMs = calculateDuration(startTime, endTime) - - val span = DiagnosticsTraceSpan( - spanId = spanId, - name = name, - startTime = startTime, - endTime = endTime, - durationMs = durationMs, - status = DiagnosticsSpanStatus.OK - ) - - synchronized(traceBuffer) { - traceBuffer.add(span) - } - - breadcrumbs.add( - category = "trace", - message = "Completed: $name", - data = mapOf("spanId" to spanId, "durationMs" to durationMs.toString()) - ) - - result - } catch (e: Exception) { - val endTime = dateFormat.format(Date()) - val durationMs = calculateDuration(startTime, endTime) - - val span = DiagnosticsTraceSpan( - spanId = spanId, - name = name, - startTime = startTime, - endTime = endTime, - durationMs = durationMs, - status = DiagnosticsSpanStatus.ERROR, - statusMessage = e.message - ) - - synchronized(traceBuffer) { - traceBuffer.add(span) - } - - breadcrumbs.add( - category = "trace", - message = "Failed: $name", - data = mapOf("spanId" to spanId, "error" to (e.message ?: "Unknown")) - ) - - throw e - } - } - - /** - * Add a manual breadcrumb - */ - fun breadcrumb(category: String, message: String, data: Map? = null) { - breadcrumbs.add(category = category, message = message, data = data) - } - - /** - * Get all breadcrumbs - */ - fun getBreadcrumbs(): List { - return breadcrumbs.getAll() - } - - /** - * Collect and return device state - */ - fun collectDeviceState(): DiagnosticsDeviceState { - return DeviceStateCollector.collect(context) - } - - // Private methods - - private suspend fun pollForSession() { - try { - val url = "${config.serverUrl}/api/diagnostics/config" + - "?productId=${config.productId}" + - "&installId=${config.anonymousInstallId}" - - val requestBuilder = Request.Builder() - .url(url) - .header("Accept", "application/json") - - lastEtag?.let { etag -> - requestBuilder.header("If-None-Match", etag) - } - - config.getAuthToken?.let { getToken -> - try { - val token = getToken() - requestBuilder.header("Authorization", "Bearer $token") - } catch (e: Exception) { - logger.error("[diagnostics] Failed to get auth token", mapOf("error" to (e.message ?: "unknown"))) - } - } - - val request = requestBuilder.build() - - httpClient.newCall(request).execute().use { response -> - if (response.code == 304) { - // No change - return - } - - if (!response.isSuccessful) { - throw IOException("HTTP ${response.code}") - } - - // Store ETag - response.header("ETag")?.let { etag -> - lastEtag = etag - } - - val body = response.body?.string() - val session = body?.let { - try { - json.decodeFromString(it) - } catch (e: Exception) { - null - } - } - - // Update state - if (session != null && session.status == DiagnosticsSessionStatus.ACTIVE) { - if (_state.value !is DiagnosticsClientState.Active) { - logger.info("[diagnostics] Session activated", mapOf("sessionId" to session.id)) - breadcrumbs.add( - category = "diagnostics", - message = "Session activated", - data = mapOf("sessionId" to session.id) - ) - } - _state.value = DiagnosticsClientState.Active(session) - } else { - if (_state.value is DiagnosticsClientState.Active) { - logger.info("[diagnostics] Session ended") - breadcrumbs.add(category = "diagnostics", message = "Session ended") - } - _state.value = DiagnosticsClientState.Polling(null) - } - } - } catch (e: Exception) { - logger.error("[diagnostics] Failed to poll for session", mapOf("error" to (e.message ?: "unknown"))) - _state.value = DiagnosticsClientState.Error(e) - } - } - - private suspend fun flush() { - val session = getCurrentSession() - if (session == null) { - // No active session, clear buffers - synchronized(logBuffer) { logBuffer.clear() } - synchronized(traceBuffer) { traceBuffer.clear() } - synchronized(networkBuffer) { networkBuffer.clear() } - return - } - - // Build batch - val batch = DiagnosticsIngestBatch( - sessionId = session.id, - traces = synchronized(traceBuffer) { - if (traceBuffer.isEmpty()) null else traceBuffer.take(50).also { - repeat(it.size) { traceBuffer.removeAt(0) } - } - }, - logs = synchronized(logBuffer) { - if (logBuffer.isEmpty()) null else logBuffer.take(50).also { - repeat(it.size) { logBuffer.removeAt(0) } - } - }, - network = synchronized(networkBuffer) { - if (networkBuffer.isEmpty()) null else networkBuffer.take(50).also { - repeat(it.size) { networkBuffer.removeAt(0) } - } - }, - breadcrumbs = breadcrumbs.getAll().takeIf { it.isNotEmpty() }?.also { - breadcrumbs.clear() - } - ) - - // Skip if nothing to send - if (batch.traces == null && batch.logs == null && batch.network == null && batch.breadcrumbs == null) { - return - } - - try { - val url = "${config.serverUrl}/api/diagnostics/ingest" - - val requestBody = json.encodeToString(batch) - .toRequestBody("application/json".toMediaType()) - - val requestBuilder = Request.Builder() - .url(url) - .post(requestBody) - - config.getAuthToken?.let { getToken -> - try { - val token = getToken() - requestBuilder.header("Authorization", "Bearer $token") - } catch (e: Exception) { - logger.error("[diagnostics] Failed to get auth token for flush", mapOf("error" to (e.message ?: "unknown"))) - } - } - - val request = requestBuilder.build() - - httpClient.newCall(request).execute().use { response -> - if (!response.isSuccessful) { - throw IOException("HTTP ${response.code}") - } - - logger.debug( - "[diagnostics] Flushed batch", - mapOf( - "logs" to (batch.logs?.size ?: 0).toString(), - "traces" to (batch.traces?.size ?: 0).toString(), - "network" to (batch.network?.size ?: 0).toString() - ) - ) - } - } catch (e: Exception) { - logger.error("[diagnostics] Failed to flush batch", mapOf("error" to (e.message ?: "unknown"))) - - // Put items back in buffers for retry - synchronized(logBuffer) { batch.logs?.let { logBuffer.addAll(0, it) } } - synchronized(traceBuffer) { batch.traces?.let { traceBuffer.addAll(0, it) } } - synchronized(networkBuffer) { batch.network?.let { networkBuffer.addAll(0, it) } } - } - } - - private fun setupNetworkCapture() { - networkInterceptor = NetworkInterceptor { request -> - synchronized(networkBuffer) { - networkBuffer.add(request) - } - } - networkInterceptor?.start(httpClient) - breadcrumbs.add(category = "diagnostics", message = "Network capture enabled") - } - - private fun generateId(): String { - return "${System.currentTimeMillis()}_${UUID.randomUUID().toString().take(7)}" - } - - private fun calculateDuration(startTime: String, endTime: String): Double { - return try { - val start = dateFormat.parse(startTime)?.time ?: 0 - val end = dateFormat.parse(endTime)?.time ?: 0 - (end - start).toDouble() - } catch (e: Exception) { - 0.0 - } - } -} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/DiagnosticsTypes.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/DiagnosticsTypes.kt deleted file mode 100644 index 2e63fd1..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/DiagnosticsTypes.kt +++ /dev/null @@ -1,152 +0,0 @@ -package com.bytelyst.platform.diagnostics - -import kotlinx.serialization.Serializable - -/** - * Log severity levels (matches syslog/OpenTelemetry) - */ -enum class DiagnosticsLogLevel { - DEBUG, INFO, WARN, ERROR, FATAL -} - -/** - * Session status from the server - */ -enum class DiagnosticsSessionStatus { - PENDING, ACTIVE, PAUSED, COMPLETED, CANCELLED -} - -/** - * Collection level determines verbosity of captured data - */ -enum class DiagnosticsCollectionLevel { - STANDARD, DEBUG, TRACE -} - -/** - * Diagnostic session configuration from server - */ -@Serializable -data class DiagnosticsSession( - val id: String, - val productId: String, - val status: DiagnosticsSessionStatus, - val collectionLevel: DiagnosticsCollectionLevel, - val captureLogs: Boolean, - val captureNetwork: Boolean, - val captureScreenshots: Boolean, - val screenshotOnError: Boolean, - val maxDurationMinutes: Int, - val createdAt: String, - val expiresAt: String -) - -/** - * Span kind for OpenTelemetry compatibility - */ -enum class DiagnosticsSpanKind { - INTERNAL, SERVER, CLIENT, PRODUCER, CONSUMER -} - -/** - * Span status - */ -enum class DiagnosticsSpanStatus { - OK, ERROR, UNSET -} - -/** - * OpenTelemetry-compatible trace span - */ -@Serializable -data class DiagnosticsTraceSpan( - val spanId: String, - val parentId: String? = null, - val name: String, - val kind: DiagnosticsSpanKind? = null, - val startTime: String, - val endTime: String? = null, - val durationMs: Double? = null, - val attributes: Map = emptyMap(), - val status: DiagnosticsSpanStatus, - val statusMessage: String? = null -) - -/** - * Structured log entry - */ -@Serializable -data class DiagnosticsLogEntry( - val level: DiagnosticsLogLevel, - val message: String, - val timestamp: String, - val module: String, - val file: String? = null, - val line: Int? = null, - val function: String? = null, - val context: Map = emptyMap(), - val correlationId: String? = null -) - -/** - * Breadcrumb for timeline navigation - */ -@Serializable -data class DiagnosticsBreadcrumb( - val timestamp: String, - val category: String, - val message: String, - val data: Map? = null -) - -/** - * Network request/response capture - */ -@Serializable -data class DiagnosticsNetworkRequest( - val id: String, - val url: String, - val method: String, - val requestHeaders: Map = emptyMap(), - val requestBody: String? = null, - val status: Int? = null, - val responseHeaders: Map? = null, - val responseBody: String? = null, - val startTime: String, - val endTime: String? = null, - val durationMs: Double? = null, - val error: String? = null -) - -/** - * Device state snapshot - */ -@Serializable -data class DiagnosticsDeviceState( - val memoryMB: Int? = null, - val batteryLevel: Float? = null, - val isCharging: Boolean? = null, - val storageMB: Int? = null, - val networkType: String? = null, - val isOnline: Boolean, - val thermalState: DiagnosticsThermalState? = null -) - -/** - * Thermal state - */ -enum class DiagnosticsThermalState { - NOMINAL, FAIR, SERIOUS, CRITICAL -} - -/** - * Ingest batch for sending to server - */ -@Serializable -data class DiagnosticsIngestBatch( - val sessionId: String, - val traces: List? = null, - val logs: List? = null, - val breadcrumbs: List? = null, - val network: List? = null -) diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/NetworkInterceptor.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/NetworkInterceptor.kt deleted file mode 100644 index 3b88b30..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/NetworkInterceptor.kt +++ /dev/null @@ -1,124 +0,0 @@ -package com.bytelyst.platform.diagnostics - -import okhttp3.* -import okhttp3.Interceptor.Chain -import okio.Buffer -import java.io.IOException -import java.text.SimpleDateFormat -import java.util.* - -/** - * Network interceptor for OkHttp to capture HTTP requests/responses - */ -class NetworkInterceptor( - private val onRequest: (DiagnosticsNetworkRequest) -> Unit -) : Interceptor { - private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply { - timeZone = TimeZone.getTimeZone("UTC") - } - - private var isActive = false - private lateinit var httpClient: OkHttpClient - - fun start(client: OkHttpClient) { - this.httpClient = client - isActive = true - } - - fun stop() { - isActive = false - } - - override fun intercept(chain: Chain): Response { - if (!isActive) { - return chain.proceed(chain.request()) - } - - val request = chain.request() - val requestId = generateId() - val startTime = System.currentTimeMillis() - - // Capture request details - val requestHeaders = mutableMapOf() - for (i in 0 until request.headers.size) { - val name = request.headers.name(i) - val value = request.headers.value(i) - requestHeaders[name] = sanitizeHeader(value, name) - } - - val requestBody = request.body?.let { body -> - val buffer = Buffer() - try { - body.writeTo(buffer) - buffer.readUtf8() - } catch (e: Exception) { - null - } - } - - // Proceed with request - val response: Response - try { - response = chain.proceed(request) - } catch (e: Exception) { - // Capture failed request - val networkRequest = DiagnosticsNetworkRequest( - id = requestId, - url = request.url.toString().take(2048), - method = request.method, - requestHeaders = requestHeaders, - requestBody = requestBody?.take(100 * 1024), // Limit to 100KB - startTime = dateFormat.format(Date(startTime)), - endTime = dateFormat.format(Date()), - durationMs = (System.currentTimeMillis() - startTime).toDouble(), - error = e.message - ) - onRequest(networkRequest) - throw e - } - - // Capture response - val endTime = System.currentTimeMillis() - val responseHeaders = mutableMapOf() - for (i in 0 until response.headers.size) { - val name = response.headers.name(i) - val value = response.headers.value(i) - responseHeaders[name] = sanitizeHeader(value, name) - } - - val networkRequest = DiagnosticsNetworkRequest( - id = requestId, - url = request.url.toString().take(2048), - method = request.method, - requestHeaders = requestHeaders, - requestBody = requestBody?.take(100 * 1024), - status = response.code, - responseHeaders = responseHeaders, - responseBody = null, // Don't capture response body (too large) - startTime = dateFormat.format(Date(startTime)), - endTime = dateFormat.format(Date(endTime)), - durationMs = (endTime - startTime).toDouble(), - error = null - ) - - onRequest(networkRequest) - - return response - } - - private fun sanitizeHeader(value: String, key: String): String { - val sensitivePatterns = listOf("authorization", "cookie", "token", "api-key") - val lowerKey = key.lowercase() - - for (pattern in sensitivePatterns) { - if (lowerKey.contains(pattern)) { - return "[REDACTED]" - } - } - return value - } - - private fun generateId(): String { - return "${System.currentTimeMillis()}_${UUID.randomUUID().toString().take(7)}" - } -} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/ui/BLAuthUI.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/ui/BLAuthUI.kt deleted file mode 100644 index e9538a7..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/ui/BLAuthUI.kt +++ /dev/null @@ -1,664 +0,0 @@ -package com.bytelyst.platform.ui - -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.* -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import com.bytelyst.platform.BLAuthClient -import kotlinx.coroutines.launch - -// ── Auth UI Configuration ──────────────────────────────────── - -/** OAuth providers supported by BLAuthUI. */ -enum class BLAuthProvider { GOOGLE, MICROSOFT, APPLE } - -/** - * Configuration passed to BLAuthUI screens. - * Host product provides its theme colors and enabled providers. - */ -data class BLAuthUIConfig( - val productName: String = "ByteLyst", - val enabledProviders: List = listOf(BLAuthProvider.GOOGLE, BLAuthProvider.APPLE), -) - -// ── BLLoginScreen ──────────────────────────────────────────── - -/** - * Full login screen with email/password + social buttons + passkey option. - * Host product wraps this in its MaterialTheme for consistent theming. - * - * @param config UI configuration (product name, enabled providers). - * @param onLogin Called with (email, password) when user taps Sign In. - * @param onSocialLogin Called with provider when user taps a social button. - * @param onPasskeyLogin Called when user taps "Use Passkey" (null to hide). - * @param onForgotPassword Called when user taps "Forgot Password?" (null to hide). - * @param onCreateAccount Called when user taps "Create Account" (null to hide). - */ -@Composable -fun BLLoginScreen( - config: BLAuthUIConfig = BLAuthUIConfig(), - onLogin: suspend (String, String) -> Unit, - onSocialLogin: (BLAuthProvider) -> Unit, - onPasskeyLogin: (() -> Unit)? = null, - onForgotPassword: (() -> Unit)? = null, - onCreateAccount: (() -> Unit)? = null, -) { - var email by remember { mutableStateOf("") } - var password by remember { mutableStateOf("") } - var isLoading by remember { mutableStateOf(false) } - var errorMessage by remember { mutableStateOf(null) } - val scope = rememberCoroutineScope() - - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(horizontal = 24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Spacer(Modifier.height(48.dp)) - - // Header - Text( - text = "Sign in to ${config.productName}", - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onSurface, - ) - Spacer(Modifier.height(8.dp)) - Text( - text = "Welcome back", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.height(32.dp)) - - // Social Buttons - if (config.enabledProviders.isNotEmpty()) { - config.enabledProviders.forEach { provider -> - SocialButton(provider = provider, onClick = { onSocialLogin(provider) }) - Spacer(Modifier.height(12.dp)) - } - DividerRow() - Spacer(Modifier.height(16.dp)) - } - - // Email / Password - OutlinedTextField( - value = email, - onValueChange = { email = it }, - label = { Text("Email") }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - Spacer(Modifier.height(12.dp)) - - OutlinedTextField( - value = password, - onValueChange = { password = it }, - label = { Text("Password") }, - visualTransformation = PasswordVisualTransformation(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - Spacer(Modifier.height(16.dp)) - - // Error - errorMessage?.let { - Text( - text = it, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - textAlign = TextAlign.Center, - ) - Spacer(Modifier.height(8.dp)) - } - - // Sign In Button - Button( - onClick = { - scope.launch { - isLoading = true - errorMessage = null - try { - onLogin(email, password) - } catch (e: BLAuthClient.MfaRequiredException) { - // MFA required — handled by caller - } catch (e: Exception) { - errorMessage = e.message ?: "Login failed" - } - isLoading = false - } - }, - enabled = email.isNotBlank() && password.isNotBlank() && !isLoading, - modifier = Modifier.fillMaxWidth().height(52.dp), - shape = RoundedCornerShape(10.dp), - ) { - if (isLoading) { - CircularProgressIndicator( - modifier = Modifier.size(20.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onPrimary, - ) - Spacer(Modifier.width(8.dp)) - } - Text("Sign In") - } - Spacer(Modifier.height(12.dp)) - - // Passkey - if (onPasskeyLogin != null) { - OutlinedButton( - onClick = onPasskeyLogin, - modifier = Modifier.fillMaxWidth().height(52.dp), - shape = RoundedCornerShape(10.dp), - ) { - Icon(Icons.Default.Key, contentDescription = null, modifier = Modifier.size(20.dp)) - Spacer(Modifier.width(8.dp)) - Text("Sign in with Passkey") - } - Spacer(Modifier.height(16.dp)) - } - - // Forgot Password / Create Account - if (onForgotPassword != null) { - TextButton(onClick = onForgotPassword) { - Text("Forgot Password?") - } - } - - if (onCreateAccount != null) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - "Don't have an account?", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - TextButton(onClick = onCreateAccount) { - Text("Create Account") - } - } - } - - Spacer(Modifier.height(32.dp)) - } -} - -// ── BLMfaChallengeScreen ───────────────────────────────────── - -/** - * 6-digit TOTP code entry with recovery code fallback. - * - * @param challenge The MFA challenge from login response. - * @param onVerify Called with (challengeToken, code, method) on submit. - * @param onCancel Called when user cancels. - */ -@Composable -fun BLMfaChallengeScreen( - challenge: BLAuthClient.MfaChallenge, - onVerify: suspend (String, String, String) -> Unit, - onCancel: (() -> Unit)? = null, -) { - var code by remember { mutableStateOf("") } - var isLoading by remember { mutableStateOf(false) } - var errorMessage by remember { mutableStateOf(null) } - var useRecovery by remember { mutableStateOf(false) } - val scope = rememberCoroutineScope() - - Column( - modifier = Modifier - .fillMaxSize() - .padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Icon( - imageVector = Icons.Default.Lock, - contentDescription = null, - modifier = Modifier.size(56.dp), - tint = MaterialTheme.colorScheme.primary, - ) - Spacer(Modifier.height(16.dp)) - - Text( - text = "Two-Factor Authentication", - style = MaterialTheme.typography.titleMedium, - ) - Spacer(Modifier.height(8.dp)) - - Text( - text = if (useRecovery) "Enter a recovery code" - else "Enter the 6-digit code from your authenticator app", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - ) - Spacer(Modifier.height(24.dp)) - - OutlinedTextField( - value = code, - onValueChange = { code = it }, - label = { Text(if (useRecovery) "Recovery Code" else "Code") }, - keyboardOptions = KeyboardOptions( - keyboardType = if (useRecovery) KeyboardType.Text else KeyboardType.Number, - ), - singleLine = true, - textStyle = LocalTextStyle.current.copy( - fontFamily = FontFamily.Monospace, - textAlign = TextAlign.Center, - ), - modifier = Modifier.width(200.dp), - ) - Spacer(Modifier.height(16.dp)) - - errorMessage?.let { - Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) - Spacer(Modifier.height(8.dp)) - } - - Button( - onClick = { - scope.launch { - isLoading = true - errorMessage = null - val method = if (useRecovery) "recovery" else "totp" - try { - onVerify(challenge.mfaChallenge, code, method) - } catch (e: Exception) { - errorMessage = e.message ?: "Verification failed" - code = "" - } - isLoading = false - } - }, - enabled = code.isNotBlank() && !isLoading, - modifier = Modifier.fillMaxWidth().height(52.dp), - shape = RoundedCornerShape(10.dp), - ) { - if (isLoading) { - CircularProgressIndicator( - modifier = Modifier.size(20.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onPrimary, - ) - Spacer(Modifier.width(8.dp)) - } - Text("Verify") - } - Spacer(Modifier.height(16.dp)) - - TextButton(onClick = { - useRecovery = !useRecovery - code = "" - errorMessage = null - }) { - Text(if (useRecovery) "Use authenticator code" else "Use recovery code") - } - - if (onCancel != null) { - TextButton(onClick = onCancel) { - Text("Cancel", color = MaterialTheme.colorScheme.onSurfaceVariant) - } - } - } -} - -// ── BLPasskeyScreen ────────────────────────────────────────── - -/** - * Passkey prompt with biometric hint text. - * Triggers Android Credential Manager for passkey authentication. - * - * @param onAuthenticate Called when user taps Continue (triggers passkey flow). - * @param onCancel Called when user taps "Use another method". - */ -@Composable -fun BLPasskeyScreen( - onAuthenticate: suspend () -> Unit, - onCancel: (() -> Unit)? = null, -) { - var isLoading by remember { mutableStateOf(false) } - var errorMessage by remember { mutableStateOf(null) } - val scope = rememberCoroutineScope() - - Column( - modifier = Modifier - .fillMaxSize() - .padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Icon( - imageVector = Icons.Default.Key, - contentDescription = null, - modifier = Modifier.size(56.dp), - tint = MaterialTheme.colorScheme.primary, - ) - Spacer(Modifier.height(16.dp)) - - Text( - text = "Sign in with Passkey", - style = MaterialTheme.typography.titleMedium, - ) - Spacer(Modifier.height(8.dp)) - - Text( - text = "Use your fingerprint, face, or screen lock to sign in", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - ) - Spacer(Modifier.height(24.dp)) - - errorMessage?.let { - Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) - Spacer(Modifier.height(8.dp)) - } - - Button( - onClick = { - scope.launch { - isLoading = true - errorMessage = null - try { - onAuthenticate() - } catch (e: Exception) { - errorMessage = e.message ?: "Passkey authentication failed" - } - isLoading = false - } - }, - enabled = !isLoading, - modifier = Modifier.fillMaxWidth().height(52.dp), - shape = RoundedCornerShape(10.dp), - ) { - if (isLoading) { - CircularProgressIndicator( - modifier = Modifier.size(20.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onPrimary, - ) - Spacer(Modifier.width(8.dp)) - } - Icon(Icons.Default.Fingerprint, contentDescription = null, modifier = Modifier.size(20.dp)) - Spacer(Modifier.width(8.dp)) - Text("Continue") - } - Spacer(Modifier.height(16.dp)) - - if (onCancel != null) { - TextButton(onClick = onCancel) { - Text("Use another method") - } - } - } -} - -// ── BLDeviceListScreen ─────────────────────────────────────── - -/** - * Device management screen — list trusted/remembered devices, revoke trust. - * - * @param devices List of devices from BLAuthClient.listDevices(). - * @param onRevokeDevice Called with device ID when user revokes a device. - * @param onRevokeAll Called when user revokes all devices. - * @param isLoading Whether data is loading. - */ -@Composable -fun BLDeviceListScreen( - devices: List, - onRevokeDevice: (String) -> Unit, - onRevokeAll: (() -> Unit)? = null, - isLoading: Boolean = false, -) { - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(16.dp), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "Your Devices", - style = MaterialTheme.typography.titleMedium, - ) - if (onRevokeAll != null && devices.isNotEmpty()) { - TextButton(onClick = onRevokeAll) { - Text("Revoke All", color = MaterialTheme.colorScheme.error) - } - } - } - Spacer(Modifier.height(16.dp)) - - if (isLoading) { - Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { - CircularProgressIndicator() - } - } else if (devices.isEmpty()) { - Text( - text = "No devices found", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } else { - devices.forEach { device -> - DeviceCard(device = device, onRevoke = { onRevokeDevice(device.fingerprint) }) - Spacer(Modifier.height(8.dp)) - } - } - } -} - -@Composable -private fun DeviceCard( - device: BLAuthClient.Device, - onRevoke: () -> Unit, -) { - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), - ) { - Row( - modifier = Modifier.padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = when (device.platform) { - "ios" -> Icons.Default.PhoneIphone - "android" -> Icons.Default.PhoneAndroid - "macos", "windows", "linux" -> Icons.Default.Laptop - else -> Icons.Default.Devices - }, - contentDescription = device.platform, - modifier = Modifier.size(32.dp), - tint = when (device.trustLevel) { - "trusted" -> MaterialTheme.colorScheme.primary - "remembered" -> MaterialTheme.colorScheme.secondary - else -> MaterialTheme.colorScheme.onSurfaceVariant - }, - ) - Spacer(Modifier.width(12.dp)) - - Column(modifier = Modifier.weight(1f)) { - Text( - text = device.name, - style = MaterialTheme.typography.bodyLarge, - ) - Text( - text = "${device.trustLevel} · ${device.platform}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - if (device.trustLevel == "trusted" || device.trustLevel == "remembered") { - IconButton(onClick = onRevoke) { - Icon( - Icons.Default.Close, - contentDescription = "Revoke", - tint = MaterialTheme.colorScheme.error, - ) - } - } - } - } -} - -// ── BLStepUpDialog ─────────────────────────────────────────── - -/** - * Re-authentication dialog for sensitive operations. - * Supports password re-entry. - * - * @param reason Description of why re-auth is needed. - * @param onStepUp Called with (method, credential) — returns step-up token. - * @param onComplete Called with the step-up token on success. - * @param onDismiss Called when user dismisses the dialog. - */ -@Composable -fun BLStepUpDialog( - reason: String = "This action requires re-authentication", - onStepUp: suspend (String, String) -> String, - onComplete: (String) -> Unit, - onDismiss: () -> Unit, -) { - var password by remember { mutableStateOf("") } - var isLoading by remember { mutableStateOf(false) } - var errorMessage by remember { mutableStateOf(null) } - val scope = rememberCoroutineScope() - - AlertDialog( - onDismissRequest = onDismiss, - icon = { - Icon(Icons.Default.Lock, contentDescription = null, modifier = Modifier.size(32.dp)) - }, - title = { Text("Confirm Your Identity") }, - text = { - Column { - Text( - text = reason, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.height(16.dp)) - - OutlinedTextField( - value = password, - onValueChange = { password = it }, - label = { Text("Password") }, - visualTransformation = PasswordVisualTransformation(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - - errorMessage?.let { - Spacer(Modifier.height(8.dp)) - Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) - } - } - }, - confirmButton = { - Button( - onClick = { - scope.launch { - isLoading = true - errorMessage = null - try { - val token = onStepUp("password", password) - onComplete(token) - onDismiss() - } catch (e: Exception) { - errorMessage = e.message ?: "Verification failed" - } - isLoading = false - } - }, - enabled = password.isNotBlank() && !isLoading, - ) { - if (isLoading) { - CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onPrimary, - ) - Spacer(Modifier.width(8.dp)) - } - Text("Confirm") - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text("Cancel") - } - }, - ) -} - -// ── Shared Components ──────────────────────────────────────── - -@Composable -private fun SocialButton( - provider: BLAuthProvider, - onClick: () -> Unit, -) { - OutlinedButton( - onClick = onClick, - modifier = Modifier.fillMaxWidth().height(52.dp), - shape = RoundedCornerShape(10.dp), - ) { - Icon( - imageVector = when (provider) { - BLAuthProvider.GOOGLE -> Icons.Default.Public - BLAuthProvider.MICROSOFT -> Icons.Default.Business - BLAuthProvider.APPLE -> Icons.Default.Star - }, - contentDescription = null, - modifier = Modifier.size(20.dp), - ) - Spacer(Modifier.width(8.dp)) - Text( - text = "Continue with ${ - when (provider) { - BLAuthProvider.GOOGLE -> "Google" - BLAuthProvider.MICROSOFT -> "Microsoft" - BLAuthProvider.APPLE -> "Apple" - } - }", - ) - } -} - -@Composable -private fun DividerRow() { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), - ) { - HorizontalDivider(modifier = Modifier.weight(1f)) - Text( - text = "or", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 16.dp), - ) - HorizontalDivider(modifier = Modifier.weight(1f)) - } -} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/ui/BroadcastUI.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/ui/BroadcastUI.kt deleted file mode 100644 index a69ff9e..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/ui/BroadcastUI.kt +++ /dev/null @@ -1,330 +0,0 @@ -package com.bytelyst.platform.ui - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.OpenInNew -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import coil.compose.AsyncImage -import com.bytelyst.platform.* -import kotlinx.coroutines.launch - -/** - * In-App Message Banner — Jetpack Compose component for top/bottom banner display. - * Part of ByteLystPlatformSDK. - */ -@Composable -fun InAppMessageBanner( - client: BLBroadcastClient, - position: BannerPosition = BannerPosition.TOP, - modifier: Modifier = Modifier -) { - val scope = rememberCoroutineScope() - val context = LocalContext.current - var messages by remember { mutableStateOf>(emptyList()) } - var unreadCount by remember { mutableIntStateOf(0) } - - LaunchedEffect(client) { - // Initial load - val result = client.getMessages() - result.onSuccess { list -> - messages = list - unreadCount = messages.count { it.status == "UNREAD" } - } - - // Start polling - client.startPolling(60000L) { updatedMessages -> - messages = updatedMessages - unreadCount = updatedMessages.count { it.status == "UNREAD" } - } - } - - val bannerMessages = messages.filter { - it.status == "UNREAD" && (it.style == "BANNER" || it.style == "TOAST") - } - - if (bannerMessages.isEmpty()) return - - Column( - modifier = modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .padding( - top = if (position == BannerPosition.TOP) 16.dp else 0.dp, - bottom = if (position == BannerPosition.BOTTOM) 16.dp else 0.dp - ), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - bannerMessages.forEach { message -> - BannerCard( - message = message, - onDismiss = { - scope.launch { - client.markDismissed(message.id) - messages = messages.filter { it.id != message.id } - } - }, - onTap = { - scope.launch { - client.trackClick(message.id) - message.ctaUrl?.let { url -> - // Open URL - val intent = android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(url)) - context.startActivity(intent) - } - client.markRead(message.id) - messages = messages.map { - if (it.id == message.id) it.copy(status = "READ") - else it - } - } - } - ) - } - } -} - -enum class BannerPosition { - TOP, BOTTOM -} - -@Composable -private fun BannerCard( - message: InAppMessage, - onDismiss: () -> Unit, - onTap: () -> Unit -) { - val backgroundColor = when (message.priority.uppercase()) { - "URGENT" -> MaterialTheme.colorScheme.errorContainer - "HIGH" -> Color(0xFFFFF3E0) // Orange-ish - else -> MaterialTheme.colorScheme.surface - } - - Card( - modifier = Modifier - .fillMaxWidth() - .shadow(4.dp, RoundedCornerShape(12.dp)) - .clickable { onTap() }, - shape = RoundedCornerShape(12.dp), - colors = CardDefaults.cardColors(containerColor = backgroundColor) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.Top - ) { - if (message.imageUrl != null) { - AsyncImage( - model = message.imageUrl, - contentDescription = null, - modifier = Modifier - .size(48.dp) - .clip(RoundedCornerShape(8.dp)), - contentScale = ContentScale.Crop - ) - } - - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - Text( - text = message.title, - style = MaterialTheme.typography.titleMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - - message.body?.let { body -> - Text( - text = body, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - } - - if (message.ctaText != null) { - Text( - text = "Tap to open →", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary - ) - } - } - - if (message.dismissible) { - IconButton( - onClick = onDismiss, - modifier = Modifier.size(32.dp) - ) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = "Dismiss", - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - } -} - -/** - * Broadcast Modal — Jetpack Compose component for modal/fullscreen broadcast display. - */ -@Composable -fun BroadcastModal( - client: BLBroadcastClient, - modifier: Modifier = Modifier -) { - val scope = rememberCoroutineScope() - val context = LocalContext.current - var currentMessage by remember { mutableStateOf(null) } - var showDialog by remember { mutableStateOf(false) } - - LaunchedEffect(client) { - // Start polling for modal messages - client.startPolling(30000L) { messages -> - val modalMessages = messages.filter { - it.status == "UNREAD" && (it.style == "MODAL" || it.style == "FULLSCREEN") - } - if (modalMessages.isNotEmpty() && currentMessage == null) { - currentMessage = modalMessages.first() - showDialog = true - } - } - } - - if (showDialog && currentMessage != null) { - val message = currentMessage!! - - Dialog( - onDismissRequest = { - if (message.dismissible) { - scope.launch { - client.markDismissed(message.id) - } - showDialog = false - currentMessage = null - } - } - ) { - Surface( - modifier = modifier - .fillMaxWidth() - .padding(16.dp), - shape = RoundedCornerShape(16.dp), - color = MaterialTheme.colorScheme.surface - ) { - Column( - modifier = Modifier.padding(24.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - // Header with image - if (message.imageUrl != null) { - AsyncImage( - model = message.imageUrl, - contentDescription = null, - modifier = Modifier - .fillMaxWidth() - .height(160.dp) - .clip(RoundedCornerShape(12.dp)), - contentScale = ContentScale.Crop - ) - } - - Text( - text = message.title, - style = MaterialTheme.typography.headlineSmall, - modifier = Modifier.fillMaxWidth() - ) - - message.body?.let { body -> - Text( - text = body, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.fillMaxWidth() - ) - } - - // Action buttons - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically - ) { - if (message.dismissible) { - TextButton( - onClick = { - scope.launch { - client.markDismissed(message.id) - } - showDialog = false - currentMessage = null - } - ) { - Text("Dismiss") - } - } - - if (message.ctaText != null) { - Button( - onClick = { - scope.launch { - client.trackClick(message.id) - message.ctaUrl?.let { url -> - val intent = android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(url)) - context.startActivity(intent) - } - client.markRead(message.id) - } - showDialog = false - currentMessage = null - } - ) { - Text(message.ctaText) - Spacer(modifier = Modifier.width(4.dp)) - Icon( - imageVector = Icons.Default.OpenInNew, - contentDescription = null, - modifier = Modifier.size(16.dp) - ) - } - } else { - Button( - onClick = { - scope.launch { - client.markRead(message.id) - } - showDialog = false - currentMessage = null - } - ) { - Text("Got it") - } - } - } - } - } - } - } -} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/ui/SurveyUI.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/ui/SurveyUI.kt deleted file mode 100644 index 9fdacab..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/ui/SurveyUI.kt +++ /dev/null @@ -1,775 +0,0 @@ -package com.bytelyst.platform.ui - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowDownward -import androidx.compose.material.icons.filled.ArrowUpward -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.CheckCircle -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.RadioButtonUnchecked -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import com.bytelyst.platform.* -import com.bytelyst.platform.BLSurveyClient.ActiveSurvey -import com.bytelyst.platform.BLSurveyClient.Question -import com.bytelyst.platform.BLSurveyClient.QuestionOption -import com.bytelyst.platform.BLSurveyClient.SurveyAnswer -import com.bytelyst.platform.BLSurveyClient.SurveyIncentive -import com.bytelyst.platform.BLSurveyClient.QuestionType -import kotlinx.coroutines.launch -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive - -/** - * Survey Modal — Jetpack Compose component for displaying and completing surveys. - * Part of ByteLystPlatformSDK. - */ -@Composable -fun SurveyModal( - client: BLSurveyClient, - modifier: Modifier = Modifier -) { - val scope = rememberCoroutineScope() - var survey by remember { mutableStateOf(null) } - var currentQuestionIndex by remember { mutableIntStateOf(0) } - var answers by remember { mutableStateOf>(emptyMap()) } - var isComplete by remember { mutableStateOf(false) } - var showCompletion by remember { mutableStateOf(false) } - var showDialog by remember { mutableStateOf(false) } - - // Question state - var selectedOption by remember { mutableStateOf(null) } - var selectedOptions by remember { mutableStateOf>(emptySet()) } - var ratingValue by remember { mutableIntStateOf(0) } - var textAnswer by remember { mutableStateOf("") } - var rankingOrder by remember { mutableStateOf>(emptyList()) } - - LaunchedEffect(client) { - // Check for active survey - val result = client.getActiveSurvey() - result.onSuccess { response -> - response?.let { - survey = it - showDialog = true - } - } - - // Start polling - client.startPolling(60000L) { newSurvey -> - if (newSurvey != null && survey == null) { - survey = newSurvey - showDialog = true - } - } - } - - fun doResetSurvey() { - survey = null - currentQuestionIndex = 0 - answers = emptyMap() - isComplete = false - showCompletion = false - resetQuestionState( - onSelectedOption = { selectedOption = it }, - onSelectedOptions = { selectedOptions = it }, - onRating = { ratingValue = it }, - onText = { textAnswer = it }, - onRanking = { rankingOrder = it } - ) - } - - if (showDialog) { - when { - showCompletion -> { - CompletionDialog( - survey = survey, - onDismiss = { - scope.launch { - survey?.let { client.dismissSurvey(it.id) } - } - showDialog = false - doResetSurvey() - } - ) - } - survey != null && currentQuestionIndex < survey!!.questions.size -> { - val question = survey!!.questions[currentQuestionIndex] - QuestionDialog( - survey = survey!!, - question = question, - questionIndex = currentQuestionIndex, - totalQuestions = survey!!.questions.size, - selectedOption = selectedOption, - selectedOptions = selectedOptions, - ratingValue = ratingValue, - textAnswer = textAnswer, - rankingOrder = rankingOrder, - onSelectedOptionChange = { selectedOption = it }, - onSelectedOptionsChange = { selectedOptions = it }, - onRatingChange = { ratingValue = it }, - onTextChange = { textAnswer = it }, - onRankingChange = { rankingOrder = it }, - onSubmit = { - scope.launch { - submitAnswer( - client = client, - survey = survey!!, - question = question, - selectedOption = selectedOption, - selectedOptions = selectedOptions, - ratingValue = ratingValue, - textAnswer = textAnswer, - rankingOrder = rankingOrder, - onSuccess = { newIndex, newAnswers -> - currentQuestionIndex = newIndex - answers = newAnswers - resetQuestionState( - onSelectedOption = { selectedOption = it }, - onSelectedOptions = { selectedOptions = it }, - onRating = { ratingValue = it }, - onText = { textAnswer = it }, - onRanking = { rankingOrder = it } - ) - - if (currentQuestionIndex >= survey!!.questions.size) { - scope.launch { - completeSurvey(client, survey!!.id) { success, _ -> - if (success) { - isComplete = true - showCompletion = true - } - } - } - } - } - ) - } - }, - onSkip = { - if (!question.required) { - scope.launch { - skipQuestion(client, survey!!, question.id) { newIndex -> - currentQuestionIndex = newIndex - resetQuestionState( - onSelectedOption = { selectedOption = it }, - onSelectedOptions = { selectedOptions = it }, - onRating = { ratingValue = it }, - onText = { textAnswer = it }, - onRanking = { rankingOrder = it } - ) - - if (currentQuestionIndex >= survey!!.questions.size) { - scope.launch { - completeSurvey(client, survey!!.id) { success, _ -> - if (success) { - isComplete = true - showCompletion = true - } - } - } - } - } - } - } - }, - onDismiss = { - scope.launch { - survey?.let { client.dismissSurvey(it.id) } - } - showDialog = false - doResetSurvey() - } - ) - } - } - } -} - -private suspend fun submitAnswer( - client: BLSurveyClient, - survey: ActiveSurvey, - question: Question, - selectedOption: String?, - selectedOptions: Set, - ratingValue: Int, - textAnswer: String, - rankingOrder: List, - onSuccess: (Int, Map) -> Unit -) { - val qType = question.type.toQuestionType() - val answer: SurveyAnswer = when (qType) { - QuestionType.SINGLE_CHOICE, QuestionType.DROPDOWN -> { - selectedOption?.let { - SurveyAnswer(type = "single_choice", value = JsonObject(mapOf("value" to JsonPrimitive(it)))) - } ?: return - } - QuestionType.MULTIPLE_CHOICE -> { - SurveyAnswer(type = "multiple_choice", value = JsonObject(mapOf("values" to JsonArray(selectedOptions.map { JsonPrimitive(it) })))) - } - QuestionType.RATING, QuestionType.SCALE, QuestionType.NPS -> { - SurveyAnswer(type = "rating", value = JsonObject(mapOf("value" to JsonPrimitive(ratingValue)))) - } - QuestionType.TEXT_SHORT, QuestionType.TEXT_LONG -> { - SurveyAnswer(type = "text", value = JsonObject(mapOf("value" to JsonPrimitive(textAnswer)))) - } - QuestionType.RANKING -> { - SurveyAnswer(type = "ranking", value = JsonObject(mapOf("order" to JsonArray(rankingOrder.map { JsonPrimitive(it) })))) - } - null -> return - } - - val result = client.submitAnswer(survey.id, question.id, answer) - result.onSuccess { response -> - onSuccess(response.currentQuestionIndex, response.answers) - } -} - -private suspend fun skipQuestion( - client: BLSurveyClient, - survey: ActiveSurvey, - questionId: String, - onSuccess: (Int) -> Unit -) { - val answer = SurveyAnswer(type = "skipped", value = JsonObject(emptyMap())) - val result = client.submitAnswer(survey.id, questionId, answer) - result.onSuccess { response -> - onSuccess(response.currentQuestionIndex) - } -} - -private suspend fun completeSurvey( - client: BLSurveyClient, - surveyId: String, - onComplete: (Boolean, Boolean) -> Unit -) { - val result = client.completeSurvey(surveyId) - result.onSuccess { completion -> - onComplete(completion.success, completion.incentiveClaimed) - } -} - -private fun resetQuestionState( - onSelectedOption: (String?) -> Unit, - onSelectedOptions: (Set) -> Unit, - onRating: (Int) -> Unit, - onText: (String) -> Unit, - onRanking: (List) -> Unit -) { - onSelectedOption(null) - onSelectedOptions(emptySet()) - onRating(0) - onText("") - onRanking(emptyList()) -} - -@Composable -private fun QuestionDialog( - survey: ActiveSurvey, - question: Question, - questionIndex: Int, - totalQuestions: Int, - selectedOption: String?, - selectedOptions: Set, - ratingValue: Int, - textAnswer: String, - rankingOrder: List, - onSelectedOptionChange: (String?) -> Unit, - onSelectedOptionsChange: (Set) -> Unit, - onRatingChange: (Int) -> Unit, - onTextChange: (String) -> Unit, - onRankingChange: (List) -> Unit, - onSubmit: () -> Unit, - onSkip: () -> Unit, - onDismiss: () -> Unit -) { - Dialog(onDismissRequest = onDismiss) { - Surface( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - shape = RoundedCornerShape(16.dp), - color = MaterialTheme.colorScheme.surface - ) { - Column( - modifier = Modifier - .padding(24.dp) - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - // Progress - LinearProgressIndicator( - progress = { (questionIndex + 1) / totalQuestions.toFloat() }, - modifier = Modifier.fillMaxWidth() - ) - Text( - text = "Question ${questionIndex + 1} of $totalQuestions", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center - ) - - // Question text - Text( - text = question.text, - style = MaterialTheme.typography.titleMedium - ) - question.description?.let { - Text( - text = it, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - if (question.required) { - Text( - text = "Required", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.error - ) - } - - // Question input - val qType = question.type.toQuestionType() - when (qType) { - QuestionType.SINGLE_CHOICE, QuestionType.DROPDOWN -> { - SingleChoiceInput( - options = question.options ?: emptyList(), - selected = selectedOption, - onSelect = onSelectedOptionChange - ) - } - QuestionType.MULTIPLE_CHOICE -> { - MultipleChoiceInput( - options = question.options ?: emptyList(), - selected = selectedOptions, - onSelect = onSelectedOptionsChange - ) - } - QuestionType.RATING, QuestionType.SCALE, QuestionType.NPS -> { - RatingInput( - minValue = question.minValue ?: if (qType == QuestionType.NPS) 0 else 1, - maxValue = question.maxValue ?: if (qType == QuestionType.NPS) 10 else 5, - rating = ratingValue, - onRatingChange = onRatingChange - ) - } - QuestionType.TEXT_SHORT, QuestionType.TEXT_LONG -> { - TextAnswerInput( - text = textAnswer, - isLong = qType == QuestionType.TEXT_LONG, - maxLength = question.maxLength, - onTextChange = onTextChange - ) - } - QuestionType.RANKING -> { - RankingInput( - options = question.options ?: emptyList(), - order = rankingOrder, - onOrderChange = onRankingChange - ) - } - null -> { /* unknown type */ } - } - - // Buttons - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - if (!question.required) { - TextButton(onClick = onSkip) { - Text("Skip") - } - } else { - Spacer(modifier = Modifier.width(1.dp)) - } - - val canSubmit = when (qType) { - QuestionType.SINGLE_CHOICE, QuestionType.DROPDOWN -> selectedOption != null - QuestionType.MULTIPLE_CHOICE -> selectedOptions.isNotEmpty() - QuestionType.RATING, QuestionType.SCALE, QuestionType.NPS -> ratingValue > 0 - QuestionType.TEXT_SHORT, QuestionType.TEXT_LONG -> textAnswer.isNotBlank() - QuestionType.RANKING -> rankingOrder.size == (question.options?.size ?: 0) - null -> false - } - - val isLast = questionIndex == totalQuestions - 1 - Button( - onClick = onSubmit, - enabled = canSubmit - ) { - Text(if (isLast) "Complete" else "Next") - } - } - } - } - } -} - -@Composable -private fun SingleChoiceInput( - options: List, - selected: String?, - onSelect: (String?) -> Unit -) { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - options.forEach { option -> - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .background( - if (selected == option.id) MaterialTheme.colorScheme.primaryContainer - else MaterialTheme.colorScheme.surfaceVariant - ) - .clickable { onSelect(option.id) } - .padding(12.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text(text = option.emoji ?: "") - Text( - text = option.text, - modifier = Modifier.weight(1f) - ) - Icon( - imageVector = if (selected == option.id) Icons.Default.CheckCircle else Icons.Default.RadioButtonUnchecked, - contentDescription = null, - tint = if (selected == option.id) MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } -} - -@Composable -private fun MultipleChoiceInput( - options: List, - selected: Set, - onSelect: (Set) -> Unit -) { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - options.forEach { option -> - val isSelected = selected.contains(option.id) - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .background( - if (isSelected) MaterialTheme.colorScheme.primaryContainer - else MaterialTheme.colorScheme.surfaceVariant - ) - .clickable { - val newSet = if (isSelected) selected - option.id else selected + option.id - onSelect(newSet) - } - .padding(12.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text(text = option.emoji ?: "") - Text( - text = option.text, - modifier = Modifier.weight(1f) - ) - Checkbox( - checked = isSelected, - onCheckedChange = { - val newSet = if (it) selected + option.id else selected - option.id - onSelect(newSet) - } - ) - } - } - } -} - -@Composable -private fun RatingInput( - minValue: Int, - maxValue: Int, - rating: Int, - onRatingChange: (Int) -> Unit -) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - (minValue..maxValue).forEach { value -> - Button( - onClick = { onRatingChange(value) }, - shape = CircleShape, - colors = ButtonDefaults.buttonColors( - containerColor = if (rating == value) MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.surfaceVariant, - contentColor = if (rating == value) MaterialTheme.colorScheme.onPrimary - else MaterialTheme.colorScheme.onSurfaceVariant - ), - modifier = Modifier.size(44.dp) - ) { - Text("$value") - } - } - } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - text = if (maxValue == 10) "Not likely" else "Low", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Text( - text = if (maxValue == 10) "Very likely" else "High", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } -} - -@Composable -private fun TextAnswerInput( - text: String, - isLong: Boolean, - maxLength: Int?, - onTextChange: (String) -> Unit -) { - Column { - if (isLong) { - OutlinedTextField( - value = text, - onValueChange = { newText -> - maxLength?.let { - if (newText.length <= it) onTextChange(newText) - } ?: onTextChange(newText) - }, - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 120.dp), - maxLines = 6, - keyboardOptions = KeyboardOptions.Default - ) - } else { - OutlinedTextField( - value = text, - onValueChange = { newText -> - maxLength?.let { - if (newText.length <= it) onTextChange(newText) - } ?: onTextChange(newText) - }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - keyboardOptions = KeyboardOptions.Default - ) - } - maxLength?.let { - Text( - text = "${text.length}/$it", - style = MaterialTheme.typography.labelSmall, - color = if (text.length > it) MaterialTheme.colorScheme.error - else MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.align(Alignment.End) - ) - } - } -} - -@Composable -private fun RankingInput( - options: List, - order: List, - onOrderChange: (List) -> Unit -) { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - options.forEach { option -> - val rank = order.indexOf(option.id).let { if (it >= 0) it + 1 else null } - val isRanked = rank != null - - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .background(MaterialTheme.colorScheme.surfaceVariant) - .padding(12.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // Rank badge - Box( - modifier = Modifier - .size(28.dp) - .clip(CircleShape) - .background( - if (isRanked) MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.surface - ), - contentAlignment = Alignment.Center - ) { - Text( - text = rank?.toString() ?: "-", - style = MaterialTheme.typography.labelSmall, - color = if (isRanked) MaterialTheme.colorScheme.onPrimary - else MaterialTheme.colorScheme.onSurfaceVariant - ) - } - - Text( - text = option.text, - modifier = Modifier.weight(1f) - ) - - // Controls - Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { - IconButton( - onClick = { - val idx = order.indexOf(option.id) - if (idx > 0) { - val newOrder = order.toMutableList() - newOrder.swap(idx, idx - 1) - onOrderChange(newOrder) - } - }, - enabled = isRanked && rank!! > 1, - modifier = Modifier.size(32.dp) - ) { - Icon(Icons.Default.ArrowUpward, null, modifier = Modifier.size(16.dp)) - } - - IconButton( - onClick = { - val idx = order.indexOf(option.id) - if (idx < order.size - 1) { - val newOrder = order.toMutableList() - newOrder.swap(idx, idx + 1) - onOrderChange(newOrder) - } - }, - enabled = isRanked && rank!! < order.size, - modifier = Modifier.size(32.dp) - ) { - Icon(Icons.Default.ArrowDownward, null, modifier = Modifier.size(16.dp)) - } - - IconButton( - onClick = { - if (!isRanked) { - onOrderChange(order + option.id) - } - }, - enabled = !isRanked, - modifier = Modifier.size(32.dp) - ) { - Icon(Icons.Default.Check, null, modifier = Modifier.size(16.dp)) - } - } - } - } - } -} - -private fun String.toQuestionType(): QuestionType? = - QuestionType.entries.firstOrNull { it.name.equals(this, ignoreCase = true) } - -private fun MutableList.swap(i: Int, j: Int) { - val temp = this[i] - this[i] = this[j] - this[j] = temp -} - -@Composable -private fun CompletionDialog( - survey: ActiveSurvey?, - onDismiss: () -> Unit -) { - Dialog(onDismissRequest = onDismiss) { - Surface( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - shape = RoundedCornerShape(16.dp), - color = MaterialTheme.colorScheme.surface - ) { - Column( - modifier = Modifier.padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Icon( - imageVector = Icons.Default.CheckCircle, - contentDescription = null, - modifier = Modifier.size(64.dp), - tint = Color(0xFF4CAF50) - ) - - Text( - text = "Thank You!", - style = MaterialTheme.typography.headlineSmall - ) - - Text( - text = "Your feedback helps us improve.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center - ) - - survey?.incentive?.let { incentive: SurveyIncentive -> - Card( - colors = CardDefaults.cardColors( - containerColor = Color(0xFFE8F5E9) - ) - ) { - Row( - modifier = Modifier.padding(16.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - tint = Color(0xFF4CAF50) - ) - Text( - text = "You've earned ${incentive.amount} ${if (incentive.type == "pro_days") "Pro Days" else "Credits"}!", - style = MaterialTheme.typography.titleSmall, - color = Color(0xFF2E7D32) - ) - } - } - } - - Button( - onClick = onDismiss, - modifier = Modifier.fillMaxWidth() - ) { - Text("Close") - } - } - } - } -} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLAuthClientSmartAuthTest.kt b/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLAuthClientSmartAuthTest.kt deleted file mode 100644 index 82c1722..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLAuthClientSmartAuthTest.kt +++ /dev/null @@ -1,178 +0,0 @@ -package com.bytelyst.platform - -import kotlinx.coroutines.test.runTest -import kotlinx.serialization.builtins.MapSerializer -import kotlinx.serialization.builtins.serializer -import kotlinx.serialization.json.Json -import okhttp3.mockwebserver.MockResponse -import okhttp3.mockwebserver.MockWebServer -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test - -/** - * SmartAuth v2 tests for Kotlin BLAuthClient. - * Uses MockWebServer to verify social login, MFA, and type serialization. - */ -class BLAuthClientSmartAuthTest { - - private lateinit var server: MockWebServer - private lateinit var config: BLPlatformConfig - private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } - - @BeforeEach - fun setUp() { - server = MockWebServer() - server.start() - config = BLPlatformConfig( - productId = "testapp", - baseUrl = server.url("/api").toString().trimEnd('/'), - platform = "android", - channel = "test", - applicationId = "com.test.smartauth", - ) - } - - @AfterEach - fun tearDown() { - server.shutdown() - } - - // ── Test: Social Login (Google) calls correct endpoint ─── - - @Test - fun `loginWithGoogle sends idToken to correct endpoint`() = runTest { - // Arrange: mock token response - val tokenResponse = """ - { - "accessToken": "at_google_123", - "refreshToken": "rt_google_456", - "user": { - "id": "usr_g1", - "email": "google@test.com", - "displayName": "Google User", - "plan": "free", - "role": "user" - } - } - """.trimIndent() - server.enqueue(MockResponse().setBody(tokenResponse).setResponseCode(200)) - - // Act - val client = BLPlatformClient(config) { null } - // We can't easily use BLAuthClient directly (needs BLSecureStore with Context), - // so we test the HTTP layer directly via BLPlatformClient - val body = json.encodeToString( - MapSerializer(String.serializer(), String.serializer()), - mapOf("idToken" to "mock_google_id_token"), - ) - val response = client.request("POST", "/api/auth/oauth/google", body, skipAuth = true) - - // Assert: correct endpoint called - val request = server.takeRequest() - assertEquals("POST", request.method) - assertTrue(request.path!!.contains("/api/auth/oauth/google")) - - // Assert: request body contains idToken - val requestBody = request.body.readUtf8() - assertTrue(requestBody.contains("mock_google_id_token")) - - // Assert: response parses correctly - val result = json.decodeFromString(response) - assertEquals("at_google_123", result.accessToken) - assertEquals("usr_g1", result.user.id) - assertEquals("google@test.com", result.user.email) - } - - // ── Test: MFA challenge detection ──────────────────────── - - @Test - fun `social login detects MFA challenge response`() = runTest { - // Arrange: mock MFA challenge response - val mfaResponse = """ - { - "mfaRequired": true, - "mfaChallenge": "ch_abc123", - "methods": ["totp", "recovery"] - } - """.trimIndent() - server.enqueue(MockResponse().setBody(mfaResponse).setResponseCode(200)) - - // Act: call the endpoint - val client = BLPlatformClient(config) { null } - val body = json.encodeToString( - MapSerializer(String.serializer(), String.serializer()), - mapOf("idToken" to "mock_token"), - ) - val response = client.request("POST", "/api/auth/oauth/google", body, skipAuth = true) - - // Assert: response is parseable as MfaChallenge - val challenge = json.decodeFromString(response) - assertTrue(challenge.mfaRequired) - assertEquals("ch_abc123", challenge.mfaChallenge) - assertEquals(listOf("totp", "recovery"), challenge.methods) - } - - // ── Test: SmartAuth types serialize/deserialize ────────── - - @Test - fun `SmartAuth types are correctly serializable`() { - // MfaStatus - val statusJson = """{"mfaEnabled":true,"methods":["totp"],"recoveryCodesRemaining":6}""" - val status = json.decodeFromString(statusJson) - assertEquals(true, status.mfaEnabled) - assertEquals(6, status.recoveryCodesRemaining) - - // AuthProvider - val providerJson = """{"provider":"google","email":"test@test.com","linkedAt":"2026-01-01","lastUsedAt":null}""" - val provider = json.decodeFromString(providerJson) - assertEquals("google", provider.provider) - assertNull(provider.lastUsedAt) - - // Device - val deviceJson = """{"fingerprint":"fp_d1","trustLevel":"trusted","trustExpiresAt":"2026-06-01","createdAt":"2026-01-01","lastSeenAt":"2026-03-01","isTrusted":true}""" - val device = json.decodeFromString(deviceJson) - assertEquals("trusted", device.trustLevel) - - // LoginEvent - val eventJson = """{"id":"e1","eventType":"login_success","method":"google","ip":"1.2.3.4","geo":{"country":"US","city":"SF"},"riskScore":15,"createdAt":"2026-03-01"}""" - val event = json.decodeFromString(eventJson) - assertEquals(15, event.riskScore) - assertEquals("SF", event.geo?.city) - - // TotpSetup - val totpJson = """{"otpauthUri":"otpauth://totp/test","qrCode":"data:image/png;base64,abc","recoveryCodes":["code1","code2"]}""" - val totp = json.decodeFromString(totpJson) - assertEquals(2, totp.recoveryCodes.size) - - // StepUpResponse - val stepUpJson = """{"stepUpToken":"su_abc123"}""" - val stepUp = json.decodeFromString(stepUpJson) - assertEquals("su_abc123", stepUp.stepUpToken) - } - - @Test - fun `MfaRequiredException contains challenge data`() { - val challenge = BLAuthClient.MfaChallenge( - mfaRequired = true, - mfaChallenge = "ch_test", - methods = listOf("totp"), - ) - val exception = BLAuthClient.MfaRequiredException(challenge) - assertEquals("MFA required", exception.message) - assertEquals("ch_test", exception.challenge.mfaChallenge) - } - - @Test - fun `AuthState MfaRequired holds challenge`() { - val challenge = BLAuthClient.MfaChallenge( - mfaRequired = true, - mfaChallenge = "ch_xyz", - methods = listOf("totp", "recovery"), - ) - val state = BLAuthClient.AuthState.MfaRequired(challenge) - assertTrue(state is BLAuthClient.AuthState.MfaRequired) - assertEquals("ch_xyz", (state as BLAuthClient.AuthState.MfaRequired).challenge.mfaChallenge) - } -} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLFeatureFlagClientTest.kt b/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLFeatureFlagClientTest.kt deleted file mode 100644 index 050a2ff..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLFeatureFlagClientTest.kt +++ /dev/null @@ -1,134 +0,0 @@ -package com.bytelyst.platform - -import kotlinx.coroutines.test.runTest -import okhttp3.mockwebserver.MockResponse -import okhttp3.mockwebserver.MockWebServer -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test - -class BLFeatureFlagClientTest { - - private lateinit var server: MockWebServer - private lateinit var config: BLPlatformConfig - - @BeforeEach - fun setup() { - server = MockWebServer() - server.start() - config = BLPlatformConfig( - productId = "testapp", - baseUrl = server.url("/api").toString().trimEnd('/'), - applicationId = "com.test.app", - ) - } - - @AfterEach - fun teardown() { - server.shutdown() - } - - @Test - fun `isEnabled returns false before init`() { - val client = BLFeatureFlagClient(config) - assertFalse(client.isEnabled("any_flag")) - } - - @Test - fun `getAllFlags returns empty map before init`() { - val client = BLFeatureFlagClient(config) - assertTrue(client.getAllFlags().isEmpty()) - } - - @Test - fun `refresh fetches flags from server`() = runTest { - server.enqueue( - MockResponse() - .setBody("""{"flags":{"dark_mode":true,"beta_feature":false}}""") - .setResponseCode(200) - ) - - val client = BLFeatureFlagClient(config) - client.refresh() - - assertTrue(client.isEnabled("dark_mode")) - assertFalse(client.isEnabled("beta_feature")) - assertFalse(client.isEnabled("unknown_flag")) - } - - @Test - fun `getAllFlags returns current flags after refresh`() = runTest { - server.enqueue( - MockResponse() - .setBody("""{"flags":{"a":true,"b":false}}""") - .setResponseCode(200) - ) - - val client = BLFeatureFlagClient(config) - client.refresh() - - val flags = client.getAllFlags() - assertEquals(2, flags.size) - assertTrue(flags["a"] == true) - assertTrue(flags["b"] == false) - } - - @Test - fun `keeps existing flags on server error`() = runTest { - // First: successful fetch - server.enqueue( - MockResponse() - .setBody("""{"flags":{"feature_x":true}}""") - .setResponseCode(200) - ) - // Second: server error - server.enqueue(MockResponse().setResponseCode(500)) - - val client = BLFeatureFlagClient(config) - client.refresh() - assertTrue(client.isEnabled("feature_x")) - - client.refresh() - // Should still have the old flags - assertTrue(client.isEnabled("feature_x")) - } - - @Test - fun `sends platform query parameter`() = runTest { - server.enqueue( - MockResponse() - .setBody("""{"flags":{}}""") - .setResponseCode(200) - ) - - val client = BLFeatureFlagClient(config) - client.refresh() - - val recorded = server.takeRequest() - assertTrue(recorded.path!!.contains("platform=android")) - } - - @Test - fun `sends X-Product-Id header`() = runTest { - server.enqueue( - MockResponse() - .setBody("""{"flags":{}}""") - .setResponseCode(200) - ) - - val client = BLFeatureFlagClient(config) - client.refresh() - - val recorded = server.takeRequest() - assertEquals("testapp", recorded.getHeader("X-Product-Id")) - } - - @Test - fun `stop cancels polling`() { - val client = BLFeatureFlagClient(config, pollIntervalMs = 100_000L) - client.init() - client.stop() - // No assertions needed — verifies stop() doesn't throw - } -} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLFieldEncryptTest.kt b/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLFieldEncryptTest.kt deleted file mode 100644 index 969cc1a..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLFieldEncryptTest.kt +++ /dev/null @@ -1,206 +0,0 @@ -package com.bytelyst.platform - -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.assertThrows -import javax.crypto.AEADBadTagException -import javax.crypto.SecretKey - -class BLFieldEncryptTest { - - private lateinit var key: SecretKey - private val dekId = "dek_user1_test" - - @BeforeEach - fun setUp() { - key = BLFieldEncrypt.generateKey() - } - - // ── Encrypt / Decrypt Roundtrip ───────────────────────── - - @Test - fun `encrypt decrypt roundtrip`() { - val plaintext = "Hello, World!" - val encrypted = BLFieldEncrypt.encrypt(plaintext, key, dekId) - val decrypted = BLFieldEncrypt.decrypt(encrypted, key) - assertEquals(plaintext, decrypted) - } - - @Test - fun `encrypt decrypt empty string`() { - val plaintext = "" - val encrypted = BLFieldEncrypt.encrypt(plaintext, key, dekId) - val decrypted = BLFieldEncrypt.decrypt(encrypted, key) - assertEquals(plaintext, decrypted) - } - - @Test - fun `encrypt decrypt unicode`() { - val plaintext = "こんにちは世界 \uD83C\uDF0D مرحبا Ñoño" - val encrypted = BLFieldEncrypt.encrypt(plaintext, key, dekId) - val decrypted = BLFieldEncrypt.decrypt(encrypted, key) - assertEquals(plaintext, decrypted) - } - - @Test - fun `encrypt decrypt large payload`() { - val plaintext = "A".repeat(100_000) - val encrypted = BLFieldEncrypt.encrypt(plaintext, key, dekId) - val decrypted = BLFieldEncrypt.decrypt(encrypted, key) - assertEquals(plaintext, decrypted) - } - - // ── EncryptedField Structure ──────────────────────────── - - @Test - fun `encrypted field has correct sentinel`() { - val encrypted = BLFieldEncrypt.encrypt("test", key, dekId) - assertTrue(encrypted.__encrypted) - assertEquals(1, encrypted.v) - assertEquals("aes-256-gcm", encrypted.alg) - assertEquals(dekId, encrypted.dekId) - } - - @Test - fun `encrypted field has correct hex lengths`() { - val encrypted = BLFieldEncrypt.encrypt("test", key, dekId) - // IV: 12 bytes = 24 hex chars - assertEquals(24, encrypted.iv.length) - // Tag: 16 bytes = 32 hex chars - assertEquals(32, encrypted.tag.length) - // ct should be non-empty - assertTrue(encrypted.ct.isNotEmpty()) - } - - @Test - fun `each encryption produces unique IV`() { - val a = BLFieldEncrypt.encrypt("same", key, dekId) - val b = BLFieldEncrypt.encrypt("same", key, dekId) - assertNotEquals(a.iv, b.iv, "Each encryption should use a unique IV") - assertNotEquals(a.ct, b.ct, "Ciphertext should differ with different IVs") - } - - // ── AAD (Additional Authenticated Data) ───────────────── - - @Test - fun `encrypt decrypt with AAD`() { - val plaintext = "secret data" - val aad = "user123:notes" - val encrypted = BLFieldEncrypt.encrypt(plaintext, key, dekId, aad) - val decrypted = BLFieldEncrypt.decrypt(encrypted, key, aad) - assertEquals(plaintext, decrypted) - } - - @Test - fun `decrypt with wrong AAD fails`() { - val encrypted = BLFieldEncrypt.encrypt("secret", key, dekId, "correct") - assertThrows { - BLFieldEncrypt.decrypt(encrypted, key, "wrong") - } - } - - @Test - fun `decrypt with missing AAD fails`() { - val encrypted = BLFieldEncrypt.encrypt("secret", key, dekId, "some-aad") - assertThrows { - BLFieldEncrypt.decrypt(encrypted, key) - } - } - - // ── Wrong Key ─────────────────────────────────────────── - - @Test - fun `decrypt with wrong key fails`() { - val encrypted = BLFieldEncrypt.encrypt("secret", key, dekId) - val wrongKey = BLFieldEncrypt.generateKey() - assertThrows { - BLFieldEncrypt.decrypt(encrypted, wrongKey) - } - } - - // ── Key Size Validation ───────────────────────────────── - - @Test - fun `encrypt rejects short key`() { - val shortKeyBytes = ByteArray(16) // 128-bit instead of 256-bit - val shortKey = javax.crypto.spec.SecretKeySpec(shortKeyBytes, "AES") - assertThrows { - BLFieldEncrypt.encrypt("test", shortKey, dekId) - } - } - - // ── Key from Hex ──────────────────────────────────────── - - @Test - fun `key from hex`() { - val hex = "ab".repeat(32) // 64 hex chars = 32 bytes - val hexKey = BLFieldEncrypt.keyFromHex(hex) - val encrypted = BLFieldEncrypt.encrypt("test", hexKey, dekId) - val decrypted = BLFieldEncrypt.decrypt(encrypted, hexKey) - assertEquals("test", decrypted) - } - - @Test - fun `key from hex rejects invalid length`() { - assertThrows { - BLFieldEncrypt.keyFromHex("aabb") - } - } - - // ── isEncrypted Type Guard ────────────────────────────── - - @Test - fun `isEncrypted with valid field`() { - val encrypted = BLFieldEncrypt.encrypt("test", key, dekId) - assertTrue(BLFieldEncrypt.isEncrypted(encrypted)) - } - - @Test - fun `isEncrypted with null field`() { - assertFalse(BLFieldEncrypt.isEncrypted(null as BLEncryptedField?)) - } - - @Test - fun `isEncrypted with map`() { - val map = mapOf( - "__encrypted" to true, - "v" to 1, - "alg" to "aes-256-gcm", - "ct" to "abcd", - "iv" to "1234", - "tag" to "5678", - "dekId" to "dek_test", - ) - assertTrue(BLFieldEncrypt.isEncrypted(map)) - } - - @Test - fun `isEncrypted with incomplete map`() { - val map = mapOf("__encrypted" to true, "v" to 1) - assertFalse(BLFieldEncrypt.isEncrypted(map)) - } - - @Test - fun `isEncrypted with null map`() { - assertFalse(BLFieldEncrypt.isEncrypted(null as Map?)) - } - - // ── Hex Helpers ───────────────────────────────────────── - - @Test - fun `byte array hex roundtrip`() { - val original = byteArrayOf(0x00, 0x0f, 0xff.toByte(), 0xab.toByte(), 0xcd.toByte()) - val hex = original.toHexString() - assertEquals("000fffabcd", hex) - val restored = hex.hexToByteArray() - assertArrayEquals(original, restored) - } - - @Test - fun `hex to byte array rejects odd length`() { - assertThrows { - "a".hexToByteArray() - } - } -} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLKillSwitchClientTest.kt b/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLKillSwitchClientTest.kt deleted file mode 100644 index 0f2438d..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLKillSwitchClientTest.kt +++ /dev/null @@ -1,113 +0,0 @@ -package com.bytelyst.platform - -import kotlinx.coroutines.test.runTest -import okhttp3.mockwebserver.MockResponse -import okhttp3.mockwebserver.MockWebServer -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test - -class BLKillSwitchClientTest { - - private lateinit var server: MockWebServer - private lateinit var config: BLPlatformConfig - - @BeforeEach - fun setup() { - server = MockWebServer() - server.start() - config = BLPlatformConfig( - productId = "testapp", - baseUrl = server.url("/api").toString().trimEnd('/'), - applicationId = "com.test.app", - ) - } - - @AfterEach - fun teardown() { - server.shutdown() - } - - @Test - fun `returns ok when not disabled`() = runTest { - server.enqueue( - MockResponse() - .setBody("""{"disabled":false}""") - .setResponseCode(200) - ) - - val client = BLKillSwitchClient(config) - val result = client.check() - - assertFalse(result.disabled) - assertNull(result.message) - } - - @Test - fun `returns disabled with message`() = runTest { - server.enqueue( - MockResponse() - .setBody("""{"disabled":true,"message":"Maintenance in progress"}""") - .setResponseCode(200) - ) - - val client = BLKillSwitchClient(config) - val result = client.check() - - assertTrue(result.disabled) - assertEquals("Maintenance in progress", result.message) - } - - @Test - fun `fail-open on server error`() = runTest { - server.enqueue(MockResponse().setResponseCode(500)) - - val client = BLKillSwitchClient(config) - val result = client.check() - - assertFalse(result.disabled) - assertNull(result.message) - } - - @Test - fun `fail-open on network error`() = runTest { - server.shutdown() // Force connection failure - - val client = BLKillSwitchClient(config) - val result = client.check() - - assertFalse(result.disabled) - } - - @Test - fun `sends correct query parameters`() = runTest { - server.enqueue( - MockResponse() - .setBody("""{"disabled":false}""") - .setResponseCode(200) - ) - - val client = BLKillSwitchClient(config) - client.check() - - val recorded = server.takeRequest() - assertTrue(recorded.path!!.contains("platform=android")) - assertEquals("GET", recorded.method) - } - - @Test - fun `sends X-Product-Id header`() = runTest { - server.enqueue( - MockResponse() - .setBody("""{"disabled":false}""") - .setResponseCode(200) - ) - - val client = BLKillSwitchClient(config) - client.check() - - val recorded = server.takeRequest() - assertEquals("testapp", recorded.getHeader("X-Product-Id")) - } -} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLKillSwitchResultTest.kt b/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLKillSwitchResultTest.kt deleted file mode 100644 index bf1825c..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLKillSwitchResultTest.kt +++ /dev/null @@ -1,39 +0,0 @@ -package com.bytelyst.platform - -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.Test - -class BLKillSwitchResultTest { - - @Test - fun `ok() should return not-disabled result`() { - val result = BLKillSwitchClient.KillSwitchResult.ok() - assertFalse(result.disabled) - assertNull(result.message) - } - - @Test - fun `disabled result should have message`() { - val result = BLKillSwitchClient.KillSwitchResult( - disabled = true, - message = "Under maintenance" - ) - assertTrue(result.disabled) - assertEquals("Under maintenance", result.message) - } - - @Test - fun `default result should not be disabled`() { - val result = BLKillSwitchClient.KillSwitchResult() - assertFalse(result.disabled) - assertNull(result.message) - } - - @Test - fun `data class copy should work`() { - val original = BLKillSwitchClient.KillSwitchResult.ok() - val modified = original.copy(disabled = true, message = "Updating") - assertTrue(modified.disabled) - assertEquals("Updating", modified.message) - } -} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLLicenseClientTest.kt b/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLLicenseClientTest.kt deleted file mode 100644 index 8108801..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLLicenseClientTest.kt +++ /dev/null @@ -1,137 +0,0 @@ -package com.bytelyst.platform - -import kotlinx.coroutines.test.runTest -import okhttp3.mockwebserver.MockResponse -import okhttp3.mockwebserver.MockWebServer -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test - -class BLLicenseClientTest { - - private lateinit var server: MockWebServer - private lateinit var config: BLPlatformConfig - - @BeforeEach - fun setup() { - server = MockWebServer() - server.start() - config = BLPlatformConfig( - productId = "testapp", - baseUrl = server.url("/api").toString().trimEnd('/'), - applicationId = "com.test.app", - ) - } - - @AfterEach - fun teardown() { - server.shutdown() - } - - @Test - fun `activate returns success result`() = runTest { - server.enqueue( - MockResponse() - .setBody("""{"success":true,"plan":"pro","message":"Activated"}""") - .setResponseCode(200) - ) - - val client = BLLicenseClient(config) - val result = client.activate("LYSNR-ABCD-1234") - - assertTrue(result.success) - assertEquals("pro", result.plan) - assertEquals("Activated", result.message) - } - - @Test - fun `activate URL-encodes the license key`() = runTest { - server.enqueue( - MockResponse() - .setBody("""{"success":true,"plan":"pro"}""") - .setResponseCode(200) - ) - - val client = BLLicenseClient(config) - client.activate("KEY+WITH SPACE") - - val recorded = server.takeRequest() - assertTrue(recorded.path!!.contains("KEY%2BWITH+SPACE") || recorded.path!!.contains("KEY%2BWITH%20SPACE")) - } - - @Test - fun `activate sends productId in body`() = runTest { - server.enqueue( - MockResponse() - .setBody("""{"success":true,"plan":"free"}""") - .setResponseCode(200) - ) - - val client = BLLicenseClient(config) - client.activate("TEST-KEY") - - val recorded = server.takeRequest() - assertTrue(recorded.body.readUtf8().contains("testapp")) - } - - @Test - fun `activate returns failure on error`() = runTest { - server.enqueue(MockResponse().setResponseCode(400).setBody("""{"error":"invalid"}""")) - - val client = BLLicenseClient(config) - val result = client.activate("BAD-KEY") - - assertFalse(result.success) - assertNotNull(result.message) - } - - @Test - fun `checkStatus returns valid license`() = runTest { - server.enqueue( - MockResponse() - .setBody("""{"valid":true,"plan":"pro","expiresAt":"2027-01-01"}""") - .setResponseCode(200) - ) - - val client = BLLicenseClient(config) - val result = client.checkStatus("LYSNR-ABCD-1234") - - assertTrue(result.valid) - assertEquals("pro", result.plan) - assertEquals("2027-01-01", result.expiresAt) - } - - @Test - fun `checkStatus returns invalid on error`() = runTest { - server.enqueue(MockResponse().setResponseCode(404).setBody("""{"error":"not found"}""")) - - val client = BLLicenseClient(config) - val result = client.checkStatus("UNKNOWN") - - assertFalse(result.valid) - } - - @Test - fun `deactivate returns true on success`() = runTest { - server.enqueue(MockResponse().setBody("{}").setResponseCode(200)) - - val client = BLLicenseClient(config) - val result = client.deactivate("LYSNR-ABCD-1234") - - assertTrue(result) - val recorded = server.takeRequest() - assertEquals("POST", recorded.method) - assertTrue(recorded.path!!.contains("deactivate")) - } - - @Test - fun `deactivate returns false on error`() = runTest { - server.enqueue(MockResponse().setResponseCode(500)) - - val client = BLLicenseClient(config) - val result = client.deactivate("LYSNR-ABCD-1234") - - assertFalse(result) - } -} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLPlatformClientTest.kt b/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLPlatformClientTest.kt deleted file mode 100644 index c7e801f..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLPlatformClientTest.kt +++ /dev/null @@ -1,175 +0,0 @@ -package com.bytelyst.platform - -import kotlinx.coroutines.test.runTest -import okhttp3.mockwebserver.MockResponse -import okhttp3.mockwebserver.MockWebServer -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test - -class BLPlatformClientTest { - - private lateinit var server: MockWebServer - private lateinit var config: BLPlatformConfig - - @BeforeEach - fun setup() { - server = MockWebServer() - server.start() - config = BLPlatformConfig( - productId = "testapp", - baseUrl = server.url("/api").toString().trimEnd('/'), - applicationId = "com.test.app", - timeoutMs = 5_000L, - ) - } - - @AfterEach - fun teardown() { - server.shutdown() - } - - @Test - fun `GET request returns response body`() = runTest { - server.enqueue(MockResponse().setBody("""{"ok":true}""").setResponseCode(200)) - - val client = BLPlatformClient(config) - val result = client.request("GET", "/health") - - assertEquals("""{"ok":true}""", result) - val recorded = server.takeRequest() - assertEquals("GET", recorded.method) - assertTrue(recorded.path!!.endsWith("/api/health")) - } - - @Test - fun `POST request sends body`() = runTest { - server.enqueue(MockResponse().setBody("""{"id":"1"}""").setResponseCode(201)) - - val client = BLPlatformClient(config) - val result = client.request("POST", "/items", body = """{"name":"test"}""") - - assertEquals("""{"id":"1"}""", result) - val recorded = server.takeRequest() - assertEquals("POST", recorded.method) - assertEquals("""{"name":"test"}""", recorded.body.readUtf8()) - } - - @Test - fun `injects X-Product-Id header`() = runTest { - server.enqueue(MockResponse().setBody("{}").setResponseCode(200)) - - val client = BLPlatformClient(config) - client.request("GET", "/test") - - val recorded = server.takeRequest() - assertEquals("testapp", recorded.getHeader("X-Product-Id")) - } - - @Test - fun `injects X-Request-Id header`() = runTest { - server.enqueue(MockResponse().setBody("{}").setResponseCode(200)) - - val client = BLPlatformClient(config) - client.request("GET", "/test") - - val recorded = server.takeRequest() - val requestId = recorded.getHeader("X-Request-Id") - assertNotNull(requestId) - assertTrue(requestId!!.isNotBlank()) - } - - @Test - fun `injects Authorization header when token provided`() = runTest { - server.enqueue(MockResponse().setBody("{}").setResponseCode(200)) - - val client = BLPlatformClient(config) { "my-token-123" } - client.request("GET", "/test") - - val recorded = server.takeRequest() - assertEquals("Bearer my-token-123", recorded.getHeader("Authorization")) - } - - @Test - fun `omits Authorization header when no token`() = runTest { - server.enqueue(MockResponse().setBody("{}").setResponseCode(200)) - - val client = BLPlatformClient(config) { null } - client.request("GET", "/test") - - val recorded = server.takeRequest() - assertNull(recorded.getHeader("Authorization")) - } - - @Test - fun `skips auth when skipAuth is true`() = runTest { - server.enqueue(MockResponse().setBody("{}").setResponseCode(200)) - - val client = BLPlatformClient(config) { "should-not-appear" } - client.request("GET", "/test", skipAuth = true) - - val recorded = server.takeRequest() - assertNull(recorded.getHeader("Authorization")) - } - - @Test - fun `throws BLApiException on non-2xx`() = runTest { - server.enqueue(MockResponse().setBody("""{"error":"not found"}""").setResponseCode(404)) - - val client = BLPlatformClient(config) - val ex = assertThrows(BLApiException::class.java) { - kotlinx.coroutines.runBlocking { - client.request("GET", "/missing") - } - } - assertEquals(404, ex.statusCode) - assertTrue(ex.responseBody.contains("not found")) - } - - @Test - fun `fireAndForget swallows errors`() = runTest { - server.enqueue(MockResponse().setResponseCode(500)) - - val client = BLPlatformClient(config) - // Should not throw - client.fireAndForget("POST", "/telemetry", body = """{"event":"test"}""") - - assertEquals(1, server.requestCount) - } - - @Test - fun `PUT request works`() = runTest { - server.enqueue(MockResponse().setBody("""{"updated":true}""").setResponseCode(200)) - - val client = BLPlatformClient(config) - val result = client.request("PUT", "/items/1", body = """{"name":"updated"}""") - - assertEquals("""{"updated":true}""", result) - val recorded = server.takeRequest() - assertEquals("PUT", recorded.method) - } - - @Test - fun `DELETE request works`() = runTest { - server.enqueue(MockResponse().setBody("").setResponseCode(204)) - - val client = BLPlatformClient(config) - val result = client.request("DELETE", "/items/1") - - assertEquals("", result) - val recorded = server.takeRequest() - assertEquals("DELETE", recorded.method) - } - - @Test - fun `extra headers are sent`() = runTest { - server.enqueue(MockResponse().setBody("{}").setResponseCode(200)) - - val client = BLPlatformClient(config) - client.request("GET", "/test", extraHeaders = mapOf("X-Custom" to "value")) - - val recorded = server.takeRequest() - assertEquals("value", recorded.getHeader("X-Custom")) - } -} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLPlatformConfigTest.kt b/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLPlatformConfigTest.kt deleted file mode 100644 index b40ba6e..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLPlatformConfigTest.kt +++ /dev/null @@ -1,62 +0,0 @@ -package com.bytelyst.platform - -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.Test - -class BLPlatformConfigTest { - - @Test - fun `should create config with required fields`() { - val config = BLPlatformConfig( - productId = "testapp", - baseUrl = "https://api.test.com/api", - applicationId = "com.test.app", - ) - assertEquals("testapp", config.productId) - assertEquals("https://api.test.com/api", config.baseUrl) - assertEquals("com.test.app", config.applicationId) - } - - @Test - fun `should have sensible defaults`() { - val config = BLPlatformConfig( - productId = "testapp", - baseUrl = "https://api.test.com", - applicationId = "com.test.app", - ) - assertEquals("android", config.platform) - assertEquals("native", config.channel) - assertEquals(15_000L, config.timeoutMs) - } - - @Test - fun `should allow overriding defaults`() { - val config = BLPlatformConfig( - productId = "testapp", - baseUrl = "https://api.test.com", - platform = "wear_os", - channel = "keyboard", - applicationId = "com.test.app", - timeoutMs = 5_000L, - ) - assertEquals("wear_os", config.platform) - assertEquals("keyboard", config.channel) - assertEquals(5_000L, config.timeoutMs) - } - - @Test - fun `data class equality should work`() { - val a = BLPlatformConfig(productId = "x", baseUrl = "http://a", applicationId = "com.x") - val b = BLPlatformConfig(productId = "x", baseUrl = "http://a", applicationId = "com.x") - assertEquals(a, b) - assertEquals(a.hashCode(), b.hashCode()) - } - - @Test - fun `copy should create modified config`() { - val original = BLPlatformConfig(productId = "x", baseUrl = "http://a", applicationId = "com.x") - val modified = original.copy(productId = "y") - assertEquals("y", modified.productId) - assertEquals(original.baseUrl, modified.baseUrl) - } -} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLTelemetryEventTest.kt b/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLTelemetryEventTest.kt deleted file mode 100644 index 6d4de1a..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLTelemetryEventTest.kt +++ /dev/null @@ -1,92 +0,0 @@ -package com.bytelyst.platform - -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.Test - -class BLTelemetryEventTest { - - private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } - - @Test - fun `event should serialize to JSON`() { - val event = BLTelemetryClient.TelemetryEvent( - id = "evt-1", - productId = "testapp", - anonymousInstallId = "inst-1", - sessionId = "sess-1", - platform = "android", - channel = "native", - osFamily = "android", - osVersion = "14", - appVersion = "1.0.0", - buildNumber = "42", - releaseChannel = "beta", - eventType = "info", - module = "test", - eventName = "unit_test", - occurredAt = "2026-01-01T00:00:00Z", - ) - val jsonStr = json.encodeToString(event) - assertTrue(jsonStr.contains("\"productId\":\"testapp\"")) - assertTrue(jsonStr.contains("\"eventName\":\"unit_test\"")) - } - - @Test - fun `event should deserialize from JSON`() { - val jsonStr = """ - { - "id": "evt-1", - "productId": "testapp", - "anonymousInstallId": "inst-1", - "sessionId": "sess-1", - "platform": "android", - "channel": "native", - "osFamily": "android", - "osVersion": "14", - "appVersion": "1.0.0", - "buildNumber": "42", - "releaseChannel": "beta", - "eventType": "info", - "module": "test", - "eventName": "unit_test", - "occurredAt": "2026-01-01T00:00:00Z" - } - """.trimIndent() - val event = json.decodeFromString(jsonStr) - assertEquals("testapp", event.productId) - assertEquals("unit_test", event.eventName) - assertNull(event.feature) - assertNull(event.tags) - } - - @Test - fun `event with optional fields should serialize correctly`() { - val event = BLTelemetryClient.TelemetryEvent( - id = "evt-2", - productId = "testapp", - anonymousInstallId = "inst-1", - sessionId = "sess-1", - platform = "android", - channel = "native", - osFamily = "android", - osVersion = "14", - appVersion = "1.0.0", - buildNumber = "42", - releaseChannel = "beta", - eventType = "error", - module = "auth", - eventName = "login_failed", - feature = "social_login", - message = "Token expired", - tags = mapOf("provider" to "google"), - metrics = mapOf("retryCount" to 3.0), - occurredAt = "2026-01-01T00:00:00Z", - ) - val jsonStr = json.encodeToString(event) - assertTrue(jsonStr.contains("\"feature\":\"social_login\"")) - assertTrue(jsonStr.contains("\"provider\":\"google\"")) - assertTrue(jsonStr.contains("\"retryCount\":3.0")) - } -} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/diagnostics/DiagnosticsTypesTest.kt b/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/diagnostics/DiagnosticsTypesTest.kt deleted file mode 100644 index 5b1309b..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/diagnostics/DiagnosticsTypesTest.kt +++ /dev/null @@ -1,216 +0,0 @@ -package com.bytelyst.platform.diagnostics - -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.BeforeEach - -class DiagnosticsTypesTest { - - @Test - fun `test DiagnosticsSession creation`() { - val session = DiagnosticsSession( - id = "ds_test123", - productId = "test-app", - status = DiagnosticsSessionStatus.ACTIVE, - collectionLevel = DiagnosticsCollectionLevel.DEBUG, - captureLogs = true, - captureNetwork = true, - captureScreenshots = false, - screenshotOnError = true, - maxDurationMinutes = 60, - createdAt = "2026-03-03T12:00:00Z", - expiresAt = "2026-03-03T13:00:00Z" - ) - - assertEquals("ds_test123", session.id) - assertEquals(DiagnosticsSessionStatus.ACTIVE, session.status) - assertEquals(DiagnosticsCollectionLevel.DEBUG, session.collectionLevel) - assertTrue(session.captureLogs) - } - - @Test - fun `test DiagnosticsLogEntry creation`() { - val entry = DiagnosticsLogEntry( - level = DiagnosticsLogLevel.ERROR, - message = "Something went wrong", - timestamp = "2026-03-03T12:00:00Z", - module = "TestModule", - file = "Test.kt", - line = 42, - function = "testFunction", - context = mapOf("key" to "value"), - correlationId = "corr-123" - ) - - assertEquals(DiagnosticsLogLevel.ERROR, entry.level) - assertEquals("Something went wrong", entry.message) - assertEquals(42, entry.line) - } - - @Test - fun `test DiagnosticsTraceSpan creation`() { - val span = DiagnosticsTraceSpan( - spanId = "span-123", - parentId = "parent-456", - name = "test-span", - kind = DiagnosticsSpanKind.INTERNAL, - startTime = "2026-03-03T12:00:00Z", - endTime = "2026-03-03T12:00:01Z", - durationMs = 1000.0, - attributes = mapOf("key" to "value"), - status = DiagnosticsSpanStatus.OK - ) - - assertEquals("span-123", span.spanId) - assertEquals("parent-456", span.parentId) - assertEquals(DiagnosticsSpanStatus.OK, span.status) - } - - @Test - fun `test DiagnosticsBreadcrumb creation`() { - val breadcrumb = DiagnosticsBreadcrumb( - timestamp = "2026-03-03T12:00:00Z", - category = "navigation", - message = "User tapped button", - data = mapOf("buttonId" to "submit") - ) - - assertEquals("navigation", breadcrumb.category) - assertEquals("User tapped button", breadcrumb.message) - } - - @Test - fun `test DiagnosticsNetworkRequest creation`() { - val request = DiagnosticsNetworkRequest( - id = "req-123", - url = "https://api.example.com/test", - method = "POST", - requestHeaders = mapOf("Content-Type" to "application/json"), - requestBody = "{}", - status = 200, - startTime = "2026-03-03T12:00:00Z", - endTime = "2026-03-03T12:00:01Z", - durationMs = 100.0 - ) - - assertEquals("req-123", request.id) - assertEquals(200, request.status) - assertEquals(100.0, request.durationMs!!, 0.0) - } - - @Test - fun `test DiagnosticsDeviceState creation`() { - val state = DiagnosticsDeviceState( - memoryMB = 1024, - batteryLevel = 0.75f, - isCharging = true, - storageMB = 512, - networkType = "wifi", - isOnline = true, - thermalState = DiagnosticsThermalState.NOMINAL - ) - - assertEquals(1024, state.memoryMB) - assertEquals(0.75f, state.batteryLevel) - assertTrue(state.isCharging == true) - assertEquals(DiagnosticsThermalState.NOMINAL, state.thermalState) - } -} - -class BreadcrumbTrailTest { - - private lateinit var trail: BreadcrumbTrail - - @BeforeEach - fun setup() { - trail = BreadcrumbTrail(maxSize = 3) - } - - @Test - fun `test add breadcrumb`() { - trail.add(category = "test", message = "test message") - assertEquals(1, trail.size()) - } - - @Test - fun `test evict oldest when over limit`() { - trail.add(category = "a", message = "1") - trail.add(category = "b", message = "2") - trail.add(category = "c", message = "3") - trail.add(category = "d", message = "4") - - assertEquals(3, trail.size()) - val all = trail.getAll() - assertEquals("b", all[0].category) // First one evicted - } - - @Test - fun `test get last n breadcrumbs`() { - trail.add(category = "a", message = "1") - trail.add(category = "b", message = "2") - trail.add(category = "c", message = "3") - - val last2 = trail.getLast(2) - assertEquals(2, last2.size) - assertEquals("b", last2[0].category) - } - - @Test - fun `test get most recent`() { - trail.add(category = "a", message = "1") - trail.add(category = "b", message = "2") - - val recent = trail.getMostRecent() - assertEquals("b", recent?.category) - } - - @Test - fun `test clear`() { - trail.add(category = "a", message = "1") - trail.clear() - assertEquals(0, trail.size()) - } -} - -class DiagnosticsConfigurationTest { - - @Test - fun `test configuration creation`() { - val config = DiagnosticsConfiguration( - productId = "test-app", - anonymousInstallId = "install-123", - platform = "android", - channel = "android_app", - osFamily = "android", - appVersion = "1.0.0", - buildNumber = "100", - releaseChannel = "beta", - serverUrl = "https://api.test.com" - ) - - assertEquals("test-app", config.productId) - assertEquals("install-123", config.anonymousInstallId) - assertEquals(5000, config.pollIntervalMs) // Default - assertEquals(100, config.maxBreadcrumbs) // Default - } - - @Test - fun `test configuration with custom values`() { - val config = DiagnosticsConfiguration( - productId = "test-app", - anonymousInstallId = "install-123", - platform = "android", - channel = "android_app", - osFamily = "android", - appVersion = "1.0.0", - buildNumber = "100", - releaseChannel = "beta", - serverUrl = "https://api.test.com", - pollIntervalMs = 10000, - maxBreadcrumbs = 50 - ) - - assertEquals(10000, config.pollIntervalMs) - assertEquals(50, config.maxBreadcrumbs) - } -} diff --git a/vendor/bytelyst/llm-router/README.md b/vendor/bytelyst/llm-router/README.md deleted file mode 100644 index b15c376..0000000 --- a/vendor/bytelyst/llm-router/README.md +++ /dev/null @@ -1,134 +0,0 @@ -# @bytelyst/llm-router - -Pure-code LLM router for free-tier API providers. No LLM-in-the-loop — deterministic routing with automatic fallback, health tracking, and round-robin load distribution. - -## Features - -- **4 free providers** out of the box: Groq, OpenRouter, Together AI, Cerebras -- **Prompt classification** — regex-based detection of code/math/reasoning/creative prompts -- **Smart selection** — routes to the best model for each prompt category -- **Round-robin** — distributes load across providers to maximize free-tier usage -- **Auto-fallback** — retries on 429/5xx with next-best provider -- **Health tracking** — sliding-window stats (latency, error rate, rate-limit rate) -- **Telemetry hook** — log every routing decision for analysis -- **OpenAI-compatible** — same request/response format as OpenAI chat completions -- **Zero dependencies** — pure TypeScript, uses native `fetch` - -## Quick Start - -```bash -# Set at least one API key -export GROQ_API_KEY=gsk_... -export OPENROUTER_API_KEY=sk-or-... -export TOGETHER_API_KEY=... -export CEREBRAS_API_KEY=... -``` - -```typescript -import { LlmRouter } from '@bytelyst/llm-router'; - -const router = new LlmRouter(); - -// Automatic routing — classifier picks best provider+model -const result = await router.chat({ - messages: [{ role: 'user', content: 'Write a quicksort in TypeScript' }], -}); - -console.log(result.response.choices[0].message.content); -console.log(`Served by: ${result.provider}/${result.model} in ${result.totalLatencyMs}ms`); -``` - -## Explicit Provider Routing - -```typescript -// Force a specific provider:model -const result = await router.chat({ - messages: [{ role: 'user', content: 'Hello' }], - model: 'groq:llama-3.3-70b-versatile', -}); -``` - -## Telemetry - -```typescript -const router = new LlmRouter({ - onTelemetry: entry => { - // entry: { event, provider, model, attempt, latencyMs, category, tokens?, error? } - console.log(`[${entry.event}] ${entry.provider}/${entry.model} — ${entry.latencyMs}ms`); - }, -}); -``` - -## Health Monitoring - -```typescript -const snapshots = router.getHealth(); -// Returns: HealthSnapshot[] with per-provider stats -// { provider, model, totalRequests, successes, rateLimits, errors, avgLatencyMs, p95LatencyMs, healthy } -``` - -## Configuration - -```typescript -const router = new LlmRouter({ - // Override default providers - providers: [...], - // Health window (default: 60s) - healthWindowMs: 120_000, - // Error rate to mark unhealthy (default: 50%) - errorThreshold: 0.4, - // Rate-limit rate to mark unhealthy (default: 30%) - rateLimitThreshold: 0.2, - // Request timeout (default: 30s) - timeoutMs: 15_000, - // Max retry attempts (default: 3) - maxRetries: 4, -}); -``` - -## Provider Selection Logic - -1. **Classify** prompt → code, math, reasoning, creative, or general -2. **Score** each available model based on category match, speed tier, context window, and model size -3. **Filter** unhealthy models (based on sliding-window error/rate-limit rates) -4. **Round-robin** across top-scoring providers to spread rate-limit load -5. **Fallback** on 429/5xx → exclude failed model, pick next best - -## Default Provider Registry - -| Provider | Models | Speed | Strengths | -| -------------- | ---------------------------------------- | ---------- | ------------------------ | -| **Groq** | Llama 3.3 70B, Llama 3.1 8B, Gemma 2 9B | ⚡ Fastest | General, reasoning, code | -| **OpenRouter** | DeepSeek R1, Llama 3.3 70B, Gemma 2 9B | Medium | Reasoning, code, math | -| **Together** | Llama 3.3 70B Turbo, DeepSeek R1 Distill | Medium | General, reasoning, code | -| **Cerebras** | Llama 3.3 70B | ⚡ Fastest | General, reasoning, code | - -## Adding Custom Providers - -Any OpenAI-compatible endpoint works: - -```typescript -import { LlmRouter, DEFAULT_PROVIDERS } from '@bytelyst/llm-router'; - -const router = new LlmRouter({ - providers: [ - ...DEFAULT_PROVIDERS, - { - name: 'my-provider', - baseUrl: 'https://my-api.example.com/v1', - apiKeyEnv: 'MY_PROVIDER_KEY', - rpmLimit: 60, - tpmLimit: 100_000, - models: [ - { - id: 'my-model', - label: 'My Model', - contextWindow: 32_000, - strengths: ['general', 'code'], - speedTier: 2, - }, - ], - }, - ], -}); -``` diff --git a/vendor/bytelyst/llm-router/package.json b/vendor/bytelyst/llm-router/package.json deleted file mode 100644 index 873e1f9..0000000 --- a/vendor/bytelyst/llm-router/package.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "name": "@bytelyst/llm-router", - "version": "0.1.5", - "description": "Pure-code LLM router for free-tier API providers with round-robin, fallback, and health tracking", - "type": "module", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks", - "typecheck": "tsc --noEmit" - }, - "devDependencies": { - "vitest": "^3.0.0", - "typescript": "^5.7.0" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/llm-router/src/__tests__/classifier.test.ts b/vendor/bytelyst/llm-router/src/__tests__/classifier.test.ts deleted file mode 100644 index 440bfbe..0000000 --- a/vendor/bytelyst/llm-router/src/__tests__/classifier.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { classifyPrompt } from '../classifier.js'; - -describe('classifyPrompt', () => { - it('classifies code prompts', () => { - const result = classifyPrompt([ - { role: 'user', content: 'Write a typescript function to sort an array' }, - ]); - expect(result.category).toBe('code'); - expect(result.estimatedTokens).toBeGreaterThan(0); - }); - - it('classifies code with keywords like refactor and debug', () => { - const result = classifyPrompt([ - { role: 'user', content: 'Debug this error in my React component and refactor the handler' }, - ]); - expect(result.category).toBe('code'); - }); - - it('classifies math prompts', () => { - const result = classifyPrompt([ - { role: 'user', content: 'Calculate the integral of x^2 from 0 to 5' }, - ]); - expect(result.category).toBe('math'); - }); - - it('classifies reasoning prompts', () => { - const result = classifyPrompt([ - { - role: 'user', - content: - 'Explain step by step why this approach has trade-offs and analyze the implications', - }, - ]); - expect(result.category).toBe('reasoning'); - }); - - it('classifies creative prompts', () => { - const result = classifyPrompt([ - { role: 'user', content: 'Write a short story about a robot who learns to paint' }, - ]); - expect(result.category).toBe('creative'); - }); - - it('defaults to general for ambiguous prompts', () => { - const result = classifyPrompt([{ role: 'user', content: 'Hello, how are you?' }]); - expect(result.category).toBe('general'); - }); - - it('estimates tokens roughly correctly', () => { - const text = 'a'.repeat(400); // ~100 tokens - const result = classifyPrompt([{ role: 'user', content: text }]); - expect(result.estimatedTokens).toBe(100); - }); - - it('handles multi-message conversations', () => { - const result = classifyPrompt([ - { role: 'system', content: 'You are a coding assistant' }, - { role: 'user', content: 'Fix the bug in my python function' }, - ]); - expect(result.category).toBe('code'); - }); - - it('detects code blocks in backticks', () => { - const result = classifyPrompt([ - { - role: 'user', - content: 'What is wrong with this?\n```\nconst x = 1;\nconsole.log(x);\n```', - }, - ]); - expect(result.category).toBe('code'); - }); -}); diff --git a/vendor/bytelyst/llm-router/src/__tests__/health.test.ts b/vendor/bytelyst/llm-router/src/__tests__/health.test.ts deleted file mode 100644 index 31f864d..0000000 --- a/vendor/bytelyst/llm-router/src/__tests__/health.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { HealthTracker } from '../health.js'; - -describe('HealthTracker', () => { - let tracker: HealthTracker; - - beforeEach(() => { - tracker = new HealthTracker({ windowMs: 10_000, errorThreshold: 0.5, rateLimitThreshold: 0.3 }); - }); - - it('reports healthy with no data', () => { - expect(tracker.isHealthy('groq', 'llama-3.3-70b')).toBe(true); - }); - - it('reports healthy with all successes', () => { - for (let i = 0; i < 5; i++) { - tracker.record('groq', 'llama-3.3-70b', { - timestamp: Date.now(), - latencyMs: 200, - status: 'success', - }); - } - expect(tracker.isHealthy('groq', 'llama-3.3-70b')).toBe(true); - }); - - it('marks unhealthy when error rate exceeds threshold', () => { - for (let i = 0; i < 5; i++) { - tracker.record('groq', 'llama-3.3-70b', { - timestamp: Date.now(), - latencyMs: 200, - status: 'error', - }); - } - expect(tracker.isHealthy('groq', 'llama-3.3-70b')).toBe(false); - }); - - it('marks unhealthy when rate-limit rate exceeds threshold', () => { - // 2 successes + 3 rate limits = 60% rate limit rate > 30% threshold - for (let i = 0; i < 2; i++) { - tracker.record('openrouter', 'model-a', { - timestamp: Date.now(), - latencyMs: 100, - status: 'success', - }); - } - for (let i = 0; i < 3; i++) { - tracker.record('openrouter', 'model-a', { - timestamp: Date.now(), - latencyMs: 50, - status: 'rate_limit', - }); - } - expect(tracker.isHealthy('openrouter', 'model-a')).toBe(false); - }); - - it('assumes healthy with fewer than 3 records', () => { - tracker.record('groq', 'llama-3.3-70b', { - timestamp: Date.now(), - latencyMs: 200, - status: 'error', - }); - tracker.record('groq', 'llama-3.3-70b', { - timestamp: Date.now(), - latencyMs: 200, - status: 'error', - }); - // Only 2 records — not enough data, should still be healthy - expect(tracker.isHealthy('groq', 'llama-3.3-70b')).toBe(true); - }); - - it('computes avg and p95 latency', () => { - const latencies = [100, 200, 300, 400, 500]; - for (const latencyMs of latencies) { - tracker.record('groq', 'model-a', { - timestamp: Date.now(), - latencyMs, - status: 'success', - }); - } - const snap = tracker.snapshot('groq', 'model-a'); - expect(snap.avgLatencyMs).toBe(300); - expect(snap.p95LatencyMs).toBe(500); - expect(snap.successes).toBe(5); - }); - - it('tracks different providers independently', () => { - tracker.record('groq', 'model-a', { - timestamp: Date.now(), - latencyMs: 100, - status: 'success', - }); - tracker.record('openrouter', 'model-b', { - timestamp: Date.now(), - latencyMs: 500, - status: 'error', - }); - - const snapA = tracker.snapshot('groq', 'model-a'); - const snapB = tracker.snapshot('openrouter', 'model-b'); - expect(snapA.successes).toBe(1); - expect(snapB.errors).toBe(1); - }); - - it('returns all snapshots', () => { - tracker.record('groq', 'model-a', { timestamp: Date.now(), latencyMs: 100, status: 'success' }); - tracker.record('together', 'model-b', { - timestamp: Date.now(), - latencyMs: 200, - status: 'success', - }); - - const all = tracker.allSnapshots(); - expect(all).toHaveLength(2); - }); - - it('resets all data', () => { - tracker.record('groq', 'model-a', { timestamp: Date.now(), latencyMs: 100, status: 'success' }); - tracker.reset(); - expect(tracker.allSnapshots()).toHaveLength(0); - }); -}); diff --git a/vendor/bytelyst/llm-router/src/__tests__/registry.test.ts b/vendor/bytelyst/llm-router/src/__tests__/registry.test.ts deleted file mode 100644 index 793ab86..0000000 --- a/vendor/bytelyst/llm-router/src/__tests__/registry.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { - createLocalOllamaProvider, - getAvailableProviders, - DEFAULT_PROVIDERS, -} from '../registry.js'; -import type { ProviderConfig } from '../types.js'; - -describe('getAvailableProviders', () => { - const saved: Record = {}; - - beforeEach(() => { - // Save and clear all default provider env vars - for (const p of DEFAULT_PROVIDERS) { - const key = p.apiKeyEnv!; - saved[key] = process.env[key]; - delete process.env[key]; - } - }); - - afterEach(() => { - // Restore original env - for (const [key, val] of Object.entries(saved)) { - if (val === undefined) { - delete process.env[key]; - } else { - process.env[key] = val; - } - } - }); - - it('returns empty array when no API keys are set', () => { - expect(getAvailableProviders()).toEqual([]); - }); - - it('returns only providers with API keys set', () => { - process.env.GROQ_API_KEY = 'gsk_test'; - const result = getAvailableProviders(); - expect(result).toHaveLength(1); - expect(result[0]!.name).toBe('groq'); - }); - - it('returns multiple providers when multiple keys are set', () => { - process.env.GROQ_API_KEY = 'gsk_test'; - process.env.CEREBRAS_API_KEY = 'csk_test'; - const result = getAvailableProviders(); - expect(result).toHaveLength(2); - const names = result.map(p => p.name); - expect(names).toContain('groq'); - expect(names).toContain('cerebras'); - }); - - it('excludes providers with empty string API key', () => { - process.env.GROQ_API_KEY = ''; - expect(getAvailableProviders()).toEqual([]); - }); - - it('works with custom provider list', () => { - const custom: ProviderConfig[] = [ - { - name: 'custom', - baseUrl: 'https://example.com/v1', - apiKeyEnv: 'CUSTOM_TEST_KEY', - rpmLimit: 10, - tpmLimit: 0, - models: [], - }, - ]; - expect(getAvailableProviders(custom)).toEqual([]); - - process.env.CUSTOM_TEST_KEY = 'test'; - expect(getAvailableProviders(custom)).toHaveLength(1); - delete process.env.CUSTOM_TEST_KEY; - }); - - it('includes local providers that do not require API keys', () => { - const local = createLocalOllamaProvider(['qwen2.5-coder:7b']); - const result = getAvailableProviders([local]); - expect(result).toHaveLength(1); - expect(result[0]!.name).toBe('local-ollama'); - }); - - it('infers local ollama model metadata for routing', () => { - const local = createLocalOllamaProvider(['qwen2.5-coder:7b', 'llama3.1:70b']); - - expect(local.baseUrl).toBe('http://localhost:11434/v1'); - expect(local.models).toEqual([ - expect.objectContaining({ - id: 'qwen2.5-coder:7b', - contextWindow: 32_768, - speedTier: 1, - strengths: expect.arrayContaining(['code']), - }), - expect.objectContaining({ - id: 'llama3.1:70b', - contextWindow: 8_192, - speedTier: 3, - strengths: expect.arrayContaining(['general']), - }), - ]); - }); - - it('DEFAULT_PROVIDERS includes all 4 providers', () => { - expect(DEFAULT_PROVIDERS).toHaveLength(4); - const names = DEFAULT_PROVIDERS.map(p => p.name); - expect(names).toEqual(['groq', 'openrouter', 'together', 'cerebras']); - }); - - it('OpenRouter provider has recommended extra headers', () => { - const openrouter = DEFAULT_PROVIDERS.find(p => p.name === 'openrouter'); - expect(openrouter?.extraHeaders).toBeDefined(); - expect(openrouter?.extraHeaders?.['HTTP-Referer']).toBeDefined(); - expect(openrouter?.extraHeaders?.['X-Title']).toBeDefined(); - }); -}); diff --git a/vendor/bytelyst/llm-router/src/__tests__/router.test.ts b/vendor/bytelyst/llm-router/src/__tests__/router.test.ts deleted file mode 100644 index cf90082..0000000 --- a/vendor/bytelyst/llm-router/src/__tests__/router.test.ts +++ /dev/null @@ -1,320 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { LlmRouter } from '../router.js'; -import { createLocalOllamaProvider } from '../registry.js'; -import type { ProviderConfig, ChatCompletionResponse } from '../types.js'; -import * as client from '../client.js'; - -// Mock the HTTP client -vi.mock('../client.js', () => ({ - sendChatCompletion: vi.fn(), -})); - -const MOCK_RESPONSE: ChatCompletionResponse = { - id: 'chatcmpl-test', - object: 'chat.completion', - created: Date.now(), - model: 'test-model', - choices: [ - { - index: 0, - message: { role: 'assistant', content: 'Hello!' }, - finish_reason: 'stop', - }, - ], - usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, -}; - -const TEST_PROVIDERS: ProviderConfig[] = [ - { - name: 'test-fast', - baseUrl: 'https://fast.test/v1', - apiKeyEnv: 'TEST_FAST_KEY', - rpmLimit: 30, - tpmLimit: 10_000, - models: [ - { - id: 'fast-model', - label: 'Fast', - contextWindow: 8_192, - strengths: ['general'], - speedTier: 1, - }, - ], - }, - { - name: 'test-quality', - baseUrl: 'https://quality.test/v1', - apiKeyEnv: 'TEST_QUALITY_KEY', - rpmLimit: 20, - tpmLimit: 0, - models: [ - { - id: 'quality-model', - label: 'Quality', - contextWindow: 128_000, - strengths: ['code', 'reasoning'], - speedTier: 2, - }, - ], - }, -]; - -describe('LlmRouter', () => { - beforeEach(() => { - vi.resetAllMocks(); - // Set fake API keys - process.env.TEST_FAST_KEY = 'test-key-fast'; - process.env.TEST_QUALITY_KEY = 'test-key-quality'; - }); - - afterEach(() => { - delete process.env.TEST_FAST_KEY; - delete process.env.TEST_QUALITY_KEY; - }); - - it('throws if no providers have API keys', () => { - delete process.env.TEST_FAST_KEY; - delete process.env.TEST_QUALITY_KEY; - expect(() => new LlmRouter({ providers: TEST_PROVIDERS })).toThrow('No providers available'); - }); - - it('routes a simple prompt to a provider', async () => { - vi.mocked(client.sendChatCompletion).mockResolvedValueOnce({ - response: MOCK_RESPONSE, - latencyMs: 150, - status: 200, - }); - - const router = new LlmRouter({ providers: TEST_PROVIDERS }); - const result = await router.chat({ - messages: [{ role: 'user', content: 'Hello' }], - }); - - expect(result.response.choices[0]!.message.content).toBe('Hello!'); - expect(result.attempts).toBe(1); - expect(result.provider).toBe('test-fast'); - expect(result.model).toBe('fast-model'); - }); - - it('retries on 429 with fallback provider', async () => { - // First call: rate limited - vi.mocked(client.sendChatCompletion).mockResolvedValueOnce({ - response: null as unknown as ChatCompletionResponse, - latencyMs: 50, - status: 429, - }); - // Second call: success - vi.mocked(client.sendChatCompletion).mockResolvedValueOnce({ - response: MOCK_RESPONSE, - latencyMs: 200, - status: 200, - }); - - const router = new LlmRouter({ providers: TEST_PROVIDERS }); - const result = await router.chat({ - messages: [{ role: 'user', content: 'Hello' }], - }); - - expect(result.attempts).toBe(2); - expect(result.response.choices[0]!.message.content).toBe('Hello!'); - }); - - it('retries on error with fallback provider', async () => { - // First call: error - vi.mocked(client.sendChatCompletion).mockRejectedValueOnce(new Error('Network error')); - // Second call: success - vi.mocked(client.sendChatCompletion).mockResolvedValueOnce({ - response: MOCK_RESPONSE, - latencyMs: 200, - status: 200, - }); - - const router = new LlmRouter({ providers: TEST_PROVIDERS }); - const result = await router.chat({ - messages: [{ role: 'user', content: 'Hello' }], - }); - - expect(result.attempts).toBe(2); - }); - - it('throws after exhausting all retries', async () => { - vi.mocked(client.sendChatCompletion).mockRejectedValue(new Error('All down')); - - const router = new LlmRouter({ providers: TEST_PROVIDERS, maxRetries: 2 }); - await expect(router.chat({ messages: [{ role: 'user', content: 'Hello' }] })).rejects.toThrow( - 'All providers exhausted' - ); - }); - - it('routes code prompts to code-capable models', async () => { - vi.mocked(client.sendChatCompletion).mockResolvedValueOnce({ - response: MOCK_RESPONSE, - latencyMs: 200, - status: 200, - }); - - const router = new LlmRouter({ providers: TEST_PROVIDERS }); - await router.chat({ - messages: [{ role: 'user', content: 'Write a typescript function to sort an array' }], - }); - - // Should have been called with quality-model (has 'code' strength) - const callArgs = vi.mocked(client.sendChatCompletion).mock.calls[0]!; - expect(callArgs[1]).toBe('quality-model'); - }); - - it('fires telemetry callback on success', async () => { - vi.mocked(client.sendChatCompletion).mockResolvedValueOnce({ - response: MOCK_RESPONSE, - latencyMs: 150, - status: 200, - }); - - const telemetry = vi.fn(); - const router = new LlmRouter({ providers: TEST_PROVIDERS, onTelemetry: telemetry }); - await router.chat({ messages: [{ role: 'user', content: 'Hello' }] }); - - expect(telemetry).toHaveBeenCalledWith( - expect.objectContaining({ event: 'success', attempt: 1 }) - ); - }); - - it('fires telemetry callback on rate limit', async () => { - vi.mocked(client.sendChatCompletion).mockResolvedValueOnce({ - response: null as unknown as ChatCompletionResponse, - latencyMs: 50, - status: 429, - }); - vi.mocked(client.sendChatCompletion).mockResolvedValueOnce({ - response: MOCK_RESPONSE, - latencyMs: 200, - status: 200, - }); - - const telemetry = vi.fn(); - const router = new LlmRouter({ providers: TEST_PROVIDERS, onTelemetry: telemetry }); - await router.chat({ messages: [{ role: 'user', content: 'Hello' }] }); - - expect(telemetry).toHaveBeenCalledWith(expect.objectContaining({ event: 'rate_limit' })); - }); - - it('handles explicit provider:model routing', async () => { - vi.mocked(client.sendChatCompletion).mockResolvedValueOnce({ - response: MOCK_RESPONSE, - latencyMs: 100, - status: 200, - }); - - const router = new LlmRouter({ providers: TEST_PROVIDERS }); - const result = await router.chat({ - messages: [{ role: 'user', content: 'Hello' }], - model: 'test-fast:fast-model', - }); - - expect(result.provider).toBe('test-fast'); - expect(result.model).toBe('fast-model'); - }); - - it('throws for unknown explicit provider', async () => { - const router = new LlmRouter({ providers: TEST_PROVIDERS }); - await expect( - router.chat({ messages: [{ role: 'user', content: 'Hello' }], model: 'unknown:model' }) - ).rejects.toThrow('Provider "unknown" not found'); - }); - - it('returns health snapshots', async () => { - vi.mocked(client.sendChatCompletion).mockResolvedValueOnce({ - response: MOCK_RESPONSE, - latencyMs: 150, - status: 200, - }); - - const router = new LlmRouter({ providers: TEST_PROVIDERS }); - await router.chat({ messages: [{ role: 'user', content: 'Hello' }] }); - - const health = router.getHealth(); - expect(health).toContainEqual( - expect.objectContaining({ - provider: 'test-fast', - model: 'fast-model', - successes: 1, - }) - ); - }); - - it('lists available providers', () => { - const router = new LlmRouter({ providers: TEST_PROVIDERS }); - expect(router.getProviders()).toEqual(['test-fast', 'test-quality']); - }); - - it('plans the best provider/model without executing a request', () => { - const router = new LlmRouter({ providers: TEST_PROVIDERS }); - const plan = router.plan({ - messages: [{ role: 'user', content: 'Write a TypeScript endpoint with validation' }], - }); - - expect(plan.provider.name).toBe('test-quality'); - expect(plan.model.id).toBe('quality-model'); - expect(plan.category).toBe('code'); - expect(plan.explicit).toBe(false); - }); - - it('plans local ollama models without requiring an API key', () => { - const router = new LlmRouter({ - providers: [createLocalOllamaProvider(['qwen2.5-coder:7b', 'llama3.1:8b'])], - }); - const plan = router.plan({ - messages: [{ role: 'user', content: 'Refactor this TypeScript function' }], - }); - - expect(plan.provider.name).toBe('local-ollama'); - expect(plan.model.id).toBe('qwen2.5-coder:7b'); - }); - - it('fires telemetry for explicit model routing', async () => { - vi.mocked(client.sendChatCompletion).mockResolvedValueOnce({ - response: MOCK_RESPONSE, - latencyMs: 100, - status: 200, - }); - - const telemetry = vi.fn(); - const router = new LlmRouter({ providers: TEST_PROVIDERS, onTelemetry: telemetry }); - await router.chat({ - messages: [{ role: 'user', content: 'Hello' }], - model: 'test-fast:fast-model', - }); - - expect(telemetry).toHaveBeenCalledWith( - expect.objectContaining({ - event: 'success', - provider: 'test-fast', - model: 'fast-model', - category: 'explicit', - }) - ); - }); - - it('records health on explicit model 429', async () => { - vi.mocked(client.sendChatCompletion).mockResolvedValueOnce({ - response: null as unknown as ChatCompletionResponse, - latencyMs: 50, - status: 429, - }); - - const telemetry = vi.fn(); - const router = new LlmRouter({ providers: TEST_PROVIDERS, onTelemetry: telemetry }); - await expect( - router.chat({ messages: [{ role: 'user', content: 'Hello' }], model: 'test-fast:fast-model' }) - ).rejects.toThrow('Rate limited'); - - expect(telemetry).toHaveBeenCalledWith( - expect.objectContaining({ event: 'rate_limit', provider: 'test-fast' }) - ); - - // Health should have recorded the rate limit - const health = router.getHealth(); - expect(health).toHaveLength(1); - expect(health[0]!.rateLimits).toBe(1); - }); -}); diff --git a/vendor/bytelyst/llm-router/src/__tests__/selector.test.ts b/vendor/bytelyst/llm-router/src/__tests__/selector.test.ts deleted file mode 100644 index 429d629..0000000 --- a/vendor/bytelyst/llm-router/src/__tests__/selector.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { - selectCandidates, - pickNext, - excludeCandidate, - createRoundRobinState, -} from '../selector.js'; -import { HealthTracker } from '../health.js'; -import type { ProviderConfig } from '../types.js'; - -const MOCK_PROVIDERS: ProviderConfig[] = [ - { - name: 'fast-provider', - baseUrl: 'https://fast.example.com/v1', - apiKeyEnv: 'FAST_KEY', - rpmLimit: 30, - tpmLimit: 10_000, - models: [ - { - id: 'small-8b', - label: 'Small 8B', - contextWindow: 8_192, - strengths: ['general'], - speedTier: 1, - }, - { - id: 'large-70b', - label: 'Large 70B', - contextWindow: 128_000, - strengths: ['code', 'reasoning'], - speedTier: 1, - }, - ], - }, - { - name: 'quality-provider', - baseUrl: 'https://quality.example.com/v1', - apiKeyEnv: 'QUALITY_KEY', - rpmLimit: 20, - tpmLimit: 0, - models: [ - { - id: 'deepseek-r1', - label: 'DeepSeek R1', - contextWindow: 64_000, - strengths: ['reasoning', 'code', 'math'], - speedTier: 3, - }, - ], - }, -]; - -describe('selectCandidates', () => { - let health: HealthTracker; - - beforeEach(() => { - health = new HealthTracker(); - }); - - it('returns candidates sorted by score for code', () => { - const candidates = selectCandidates(MOCK_PROVIDERS, 'code', health); - expect(candidates.length).toBeGreaterThan(0); - // large-70b and deepseek-r1 should score high for code - const names = candidates.map(c => c.model.id); - expect(names[0]).toBe('large-70b'); // speed 1 + code strength + 70b bonus - }); - - it('returns candidates sorted by score for general', () => { - const candidates = selectCandidates(MOCK_PROVIDERS, 'general', health); - // small-8b has 'general' strength + speed tier 1 - expect(candidates[0]!.model.id).toBe('small-8b'); - }); - - it('filters out unhealthy providers', () => { - // Make fast-provider/large-70b unhealthy - for (let i = 0; i < 5; i++) { - health.record('fast-provider', 'large-70b', { - timestamp: Date.now(), - latencyMs: 100, - status: 'error', - }); - } - const candidates = selectCandidates(MOCK_PROVIDERS, 'code', health); - const ids = candidates.map(c => `${c.provider.name}::${c.model.id}`); - expect(ids).not.toContain('fast-provider::large-70b'); - }); -}); - -describe('pickNext', () => { - it('returns null for empty candidates', () => { - const state = createRoundRobinState(); - expect(pickNext([], state)).toBeNull(); - }); - - it('returns the only candidate when there is one', () => { - const state = createRoundRobinState(); - const candidate = { provider: MOCK_PROVIDERS[0]!, model: MOCK_PROVIDERS[0]!.models[0]! }; - expect(pickNext([candidate], state)).toBe(candidate); - }); - - it('round-robins across providers', () => { - const state = createRoundRobinState(); - const candidates = [ - { provider: MOCK_PROVIDERS[0]!, model: MOCK_PROVIDERS[0]!.models[0]! }, - { provider: MOCK_PROVIDERS[1]!, model: MOCK_PROVIDERS[1]!.models[0]! }, - ]; - - const first = pickNext(candidates, state); - const second = pickNext(candidates, state); - expect(first!.provider.name).not.toBe(second!.provider.name); - }); - - it('uses independent state per instance', () => { - const stateA = createRoundRobinState(); - const stateB = createRoundRobinState(); - const candidates = [ - { provider: MOCK_PROVIDERS[0]!, model: MOCK_PROVIDERS[0]!.models[0]! }, - { provider: MOCK_PROVIDERS[1]!, model: MOCK_PROVIDERS[1]!.models[0]! }, - ]; - - const fromA = pickNext(candidates, stateA); - const fromB = pickNext(candidates, stateB); - // Both start at same position since states are independent - expect(fromA!.provider.name).toBe(fromB!.provider.name); - }); -}); - -describe('excludeCandidate', () => { - it('removes the specified candidate', () => { - const candidates = [ - { provider: MOCK_PROVIDERS[0]!, model: MOCK_PROVIDERS[0]!.models[0]! }, - { provider: MOCK_PROVIDERS[1]!, model: MOCK_PROVIDERS[1]!.models[0]! }, - ]; - const remaining = excludeCandidate(candidates, 'fast-provider', 'small-8b'); - expect(remaining).toHaveLength(1); - expect(remaining[0]!.provider.name).toBe('quality-provider'); - }); -}); diff --git a/vendor/bytelyst/llm-router/src/classifier.ts b/vendor/bytelyst/llm-router/src/classifier.ts deleted file mode 100644 index b8b5c95..0000000 --- a/vendor/bytelyst/llm-router/src/classifier.ts +++ /dev/null @@ -1,115 +0,0 @@ -import type { ClassificationResult, PromptCategory } from './types.js'; - -// ── Keyword patterns for classification ──────────────────────── - -const CODE_PATTERNS = [ - /\b(function|const |let |var |class |import |export |return |async |await )\b/, - /\b(def |print\(|if __name__|lambda )\b/, - /[{}();]=>/, - /```[\s\S]*```/, - /\b(typescript|javascript|python|rust|golang|java|kotlin|swift|sql|html|css|react|node)\b/i, - /\b(debug|refactor|compile|build|deploy|lint|test|api|endpoint|route|middleware)\b/i, - /\b(fix|bug|error|exception|stack trace|undefined|null|NaN)\b/i, -]; - -const MATH_PATTERNS = [ - /\b(calculate|compute|solve|equation|formula|integral|derivative|matrix)\b/i, - /\b(probability|statistics|regression|correlation|variance|median|mean)\b/i, - /\b(algebra|geometry|calculus|theorem|proof|hypothesis)\b/i, - /[+\-*/^=]{2,}/, - /\d+\s*[+\-*/^]\s*\d+/, -]; - -const REASONING_PATTERNS = [ - /\b(explain|analyze|compare|evaluate|reason|logic|argument|conclusion)\b/i, - /\b(why|how does|what if|pros and cons|trade-?offs|implications)\b/i, - /\b(step[- ]by[- ]step|chain of thought|think through|break down)\b/i, - /\b(strategy|approach|methodology|framework|architecture|design)\b/i, -]; - -const CREATIVE_PATTERNS = [ - /\b(write|compose|draft|create|generate|story|poem|essay|blog|article)\b/i, - /\b(creative|imaginative|brainstorm|ideas|fiction|narrative|dialogue)\b/i, - /\b(rewrite|rephrase|summarize|translate|tone|style|voice)\b/i, -]; - -// ── Token estimation ─────────────────────────────────────────── - -/** - * Rough token estimate: ~4 chars per token for English text. - * Good enough for routing decisions. - */ -function estimateTokens(text: string): number { - return Math.ceil(text.length / 4); -} - -// ── Classifier ───────────────────────────────────────────────── - -function countMatches(text: string, patterns: RegExp[]): number { - let count = 0; - for (const pattern of patterns) { - if (pattern.test(text)) count++; - } - return count; -} - -/** - * Check if messages contain image content parts (vision request). - * Handles both string content and multipart content arrays. - */ -function hasImageContent(messages: { role: string; content: string | unknown[] }[]): boolean { - for (const msg of messages) { - if (Array.isArray(msg.content)) { - for (const part of msg.content) { - if ( - typeof part === 'object' && - part !== null && - 'type' in part && - (part as { type: string }).type === 'image_url' - ) { - return true; - } - } - } - } - return false; -} - -/** - * Classify a prompt into a category based on keyword matching. - * No LLM needed — pure regex heuristics. - * Detects vision (image) content and returns 'vision' category when present. - */ -export function classifyPrompt( - messages: { role: string; content: string | unknown[] }[] -): ClassificationResult { - // Check for vision content first — image inputs always classify as 'vision' - if (hasImageContent(messages)) { - const fullText = messages.map(m => (typeof m.content === 'string' ? m.content : '')).join('\n'); - return { category: 'vision', estimatedTokens: estimateTokens(fullText) + 1000 }; - } - - const fullText = messages.map(m => (typeof m.content === 'string' ? m.content : '')).join('\n'); - const estimatedTokens = estimateTokens(fullText); - - const scores: Record = { - code: countMatches(fullText, CODE_PATTERNS), - math: countMatches(fullText, MATH_PATTERNS), - reasoning: countMatches(fullText, REASONING_PATTERNS), - creative: countMatches(fullText, CREATIVE_PATTERNS), - general: 1, // baseline - vision: 0, - }; - - // Pick highest scoring category - let best: PromptCategory = 'general'; - let bestScore = 0; - for (const [cat, score] of Object.entries(scores) as [PromptCategory, number][]) { - if (score > bestScore) { - bestScore = score; - best = cat; - } - } - - return { category: best, estimatedTokens }; -} diff --git a/vendor/bytelyst/llm-router/src/client.ts b/vendor/bytelyst/llm-router/src/client.ts deleted file mode 100644 index fe57769..0000000 --- a/vendor/bytelyst/llm-router/src/client.ts +++ /dev/null @@ -1,68 +0,0 @@ -import type { ChatCompletionRequest, ChatCompletionResponse, ProviderConfig } from './types.js'; - -/** - * Send an OpenAI-compatible chat completion request to a provider. - * Returns the parsed response or throws on HTTP/network errors. - */ -export async function sendChatCompletion( - provider: ProviderConfig, - modelId: string, - request: ChatCompletionRequest, - timeoutMs: number = 30_000 -): Promise<{ response: ChatCompletionResponse; latencyMs: number; status: number }> { - const apiKey = provider.apiKeyEnv ? process.env[provider.apiKeyEnv] : null; - if (provider.apiKeyEnv && !apiKey) { - throw new Error(`Missing API key: env var ${provider.apiKeyEnv} is not set`); - } - - const url = `${provider.baseUrl}/chat/completions`; - const headers: Record = { - 'Content-Type': 'application/json', - ...provider.extraHeaders, - }; - if (apiKey) { - headers.Authorization = `Bearer ${apiKey}`; - } - - const body = JSON.stringify({ - model: modelId, - messages: request.messages, - ...(request.temperature !== undefined && { temperature: request.temperature }), - ...(request.max_tokens !== undefined && { max_tokens: request.max_tokens }), - ...(request.top_p !== undefined && { top_p: request.top_p }), - stream: false, - }); - - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - const start = Date.now(); - - try { - const res = await fetch(url, { - method: 'POST', - headers, - body, - signal: controller.signal, - }); - - const latencyMs = Date.now() - start; - - if (res.status === 429) { - return { - response: null as unknown as ChatCompletionResponse, - latencyMs, - status: 429, - }; - } - - if (!res.ok) { - const text = await res.text().catch(() => ''); - throw new Error(`${provider.name} returned ${res.status}: ${text.slice(0, 200)}`); - } - - const data = (await res.json()) as ChatCompletionResponse; - return { response: data, latencyMs, status: res.status }; - } finally { - clearTimeout(timer); - } -} diff --git a/vendor/bytelyst/llm-router/src/health.ts b/vendor/bytelyst/llm-router/src/health.ts deleted file mode 100644 index 7e1fc9c..0000000 --- a/vendor/bytelyst/llm-router/src/health.ts +++ /dev/null @@ -1,103 +0,0 @@ -import type { HealthSnapshot, RequestRecord } from './types.js'; - -/** - * Sliding-window health tracker for provider+model pairs. - * Tracks latency, error rates, and rate-limit hits. - */ -export class HealthTracker { - private records = new Map(); - private readonly windowMs: number; - private readonly errorThreshold: number; - private readonly rateLimitThreshold: number; - - constructor(opts?: { windowMs?: number; errorThreshold?: number; rateLimitThreshold?: number }) { - this.windowMs = opts?.windowMs ?? 60_000; - this.errorThreshold = opts?.errorThreshold ?? 0.5; - this.rateLimitThreshold = opts?.rateLimitThreshold ?? 0.3; - } - - private key(provider: string, model: string): string { - return `${provider}::${model}`; - } - - private prune(records: RequestRecord[]): RequestRecord[] { - const cutoff = Date.now() - this.windowMs; - return records.filter(r => r.timestamp >= cutoff); - } - - /** Record a completed request (success, rate_limit, or error). */ - record(provider: string, model: string, entry: RequestRecord): void { - const k = this.key(provider, model); - const existing = this.records.get(k) ?? []; - existing.push(entry); - this.records.set(k, this.prune(existing)); - } - - /** Get health snapshot for a provider+model pair. */ - snapshot(provider: string, model: string): HealthSnapshot { - const k = this.key(provider, model); - const raw = this.records.get(k) ?? []; - const records = this.prune(raw); - this.records.set(k, records); - - const total = records.length; - const successes = records.filter(r => r.status === 'success').length; - const rateLimits = records.filter(r => r.status === 'rate_limit').length; - const errors = records.filter(r => r.status === 'error').length; - - const successLatencies = records - .filter(r => r.status === 'success') - .map(r => r.latencyMs) - .sort((a, b) => a - b); - - const avgLatencyMs = - successLatencies.length > 0 - ? successLatencies.reduce((a, b) => a + b, 0) / successLatencies.length - : 0; - - const p95LatencyMs = - successLatencies.length > 0 - ? (successLatencies[Math.floor(successLatencies.length * 0.95)] ?? - successLatencies[successLatencies.length - 1]!) - : 0; - - // Healthy = not too many errors or rate limits - const errorRate = total > 0 ? errors / total : 0; - const rateLimitRate = total > 0 ? rateLimits / total : 0; - const healthy = - total < 3 || // not enough data → assume healthy - (errorRate < this.errorThreshold && rateLimitRate < this.rateLimitThreshold); - - return { - provider, - model, - totalRequests: total, - successes, - rateLimits, - errors, - avgLatencyMs: Math.round(avgLatencyMs), - p95LatencyMs: Math.round(p95LatencyMs), - healthy, - }; - } - - /** Check if a specific provider+model is currently healthy. */ - isHealthy(provider: string, model: string): boolean { - return this.snapshot(provider, model).healthy; - } - - /** Get all tracked snapshots. */ - allSnapshots(): HealthSnapshot[] { - const snapshots: HealthSnapshot[] = []; - for (const k of this.records.keys()) { - const [provider, model] = k.split('::') as [string, string]; - snapshots.push(this.snapshot(provider, model)); - } - return snapshots; - } - - /** Clear all tracking data. */ - reset(): void { - this.records.clear(); - } -} diff --git a/vendor/bytelyst/llm-router/src/index.ts b/vendor/bytelyst/llm-router/src/index.ts deleted file mode 100644 index 7660254..0000000 --- a/vendor/bytelyst/llm-router/src/index.ts +++ /dev/null @@ -1,31 +0,0 @@ -export { LlmRouter } from './router.js'; -export type { TelemetryEntry } from './router.js'; - -export { - DEFAULT_PROVIDERS, - PAID_PROVIDERS, - createLocalOllamaProvider, - getAvailableProviders, -} from './registry.js'; -export { classifyPrompt } from './classifier.js'; -export { HealthTracker } from './health.js'; -export { selectCandidates, pickNext, excludeCandidate, createRoundRobinState } from './selector.js'; -export type { SelectionCandidate } from './selector.js'; -export { sendChatCompletion } from './client.js'; - -export type { - ModelConfig, - ProviderConfig, - PromptCategory, - ClassificationResult, - HealthSnapshot, - RequestRecord, - RouterConfig, - ChatMessage, - ChatCompletionRequest, - ChatCompletionChoice, - ChatCompletionUsage, - ChatCompletionResponse, - RouteResult, - RoutePlan, -} from './types.js'; diff --git a/vendor/bytelyst/llm-router/src/registry.ts b/vendor/bytelyst/llm-router/src/registry.ts deleted file mode 100644 index 5c2c7d6..0000000 --- a/vendor/bytelyst/llm-router/src/registry.ts +++ /dev/null @@ -1,244 +0,0 @@ -import type { ModelConfig, PromptCategory, ProviderConfig } from './types.js'; - -/** - * Paid provider configurations (opt-in via API key env vars). - * Add to your RouterConfig.providers to include alongside free-tier providers. - */ -export const PAID_PROVIDERS: ProviderConfig[] = [ - // ── OpenAI ─────────────────────────────────────────────────── - { - name: 'openai', - baseUrl: 'https://api.openai.com/v1', - apiKeyEnv: 'OPENAI_API_KEY', - rpmLimit: 500, - tpmLimit: 150_000, - models: [ - { - id: 'gpt-4o-mini', - label: 'GPT-4o Mini', - contextWindow: 128_000, - strengths: ['general', 'reasoning', 'code'], - speedTier: 1, - }, - { - id: 'gpt-4o', - label: 'GPT-4o', - contextWindow: 128_000, - strengths: ['general', 'reasoning', 'code', 'creative', 'vision'], - speedTier: 2, - supportsVision: true, - }, - ], - }, - - // ── Perplexity ─────────────────────────────────────────────── - // Real-time web search grounding — OpenAI-compatible endpoint - { - name: 'perplexity', - baseUrl: 'https://api.perplexity.ai', - apiKeyEnv: 'PERPLEXITY_API_KEY', - rpmLimit: 50, - tpmLimit: 0, - models: [ - { - id: 'sonar', - label: 'Sonar (web search)', - contextWindow: 127_072, - strengths: ['general', 'reasoning'], - speedTier: 2, - }, - { - id: 'sonar-pro', - label: 'Sonar Pro (web search)', - contextWindow: 200_000, - strengths: ['general', 'reasoning'], - speedTier: 3, - }, - ], - }, -]; - -/** - * Default free-tier provider configurations. - * All use OpenAI-compatible /v1/chat/completions endpoints. - */ -export const DEFAULT_PROVIDERS: ProviderConfig[] = [ - // ── Groq ───────────────────────────────────────────────────── - // Free tier: 30 RPM, 14.4K TPM (large), 30K TPM (small) - { - name: 'groq', - baseUrl: 'https://api.groq.com/openai/v1', - apiKeyEnv: 'GROQ_API_KEY', - rpmLimit: 30, - tpmLimit: 14_400, - models: [ - { - id: 'llama-3.3-70b-versatile', - label: 'Llama 3.3 70B', - contextWindow: 128_000, - strengths: ['general', 'reasoning', 'code'], - speedTier: 1, - }, - { - id: 'llama-3.1-8b-instant', - label: 'Llama 3.1 8B Instant', - contextWindow: 128_000, - strengths: ['general'], - speedTier: 1, - }, - { - id: 'gemma2-9b-it', - label: 'Gemma 2 9B', - contextWindow: 8_192, - strengths: ['general', 'creative'], - speedTier: 1, - }, - ], - }, - - // ── OpenRouter ─────────────────────────────────────────────── - // Free models available (rate-limited per model) - { - name: 'openrouter', - baseUrl: 'https://openrouter.ai/api/v1', - apiKeyEnv: 'OPENROUTER_API_KEY', - extraHeaders: { - 'HTTP-Referer': 'https://bytelyst.com', - 'X-Title': 'ByteLyst LLM Router', - }, - rpmLimit: 20, - tpmLimit: 0, - models: [ - { - id: 'deepseek/deepseek-r1:free', - label: 'DeepSeek R1 (Free)', - contextWindow: 64_000, - strengths: ['reasoning', 'code', 'math'], - speedTier: 3, - }, - { - id: 'meta-llama/llama-3.3-70b-instruct:free', - label: 'Llama 3.3 70B (Free)', - contextWindow: 128_000, - strengths: ['general', 'reasoning', 'code'], - speedTier: 2, - }, - { - id: 'google/gemma-2-9b-it:free', - label: 'Gemma 2 9B (Free)', - contextWindow: 8_192, - strengths: ['general', 'creative'], - speedTier: 2, - }, - ], - }, - - // ── Together AI ────────────────────────────────────────────── - // Free tier: limited RPM, several open models - { - name: 'together', - baseUrl: 'https://api.together.xyz/v1', - apiKeyEnv: 'TOGETHER_API_KEY', - rpmLimit: 20, - tpmLimit: 0, - models: [ - { - id: 'meta-llama/Llama-3.3-70B-Instruct-Turbo', - label: 'Llama 3.3 70B Turbo', - contextWindow: 128_000, - strengths: ['general', 'reasoning', 'code'], - speedTier: 2, - }, - { - id: 'deepseek-ai/DeepSeek-R1-Distill-Llama-70B', - label: 'DeepSeek R1 Distill 70B', - contextWindow: 128_000, - strengths: ['reasoning', 'math', 'code'], - speedTier: 2, - }, - ], - }, - - // ── Cerebras ───────────────────────────────────────────────── - // Free inference tier — extremely fast - { - name: 'cerebras', - baseUrl: 'https://api.cerebras.ai/v1', - apiKeyEnv: 'CEREBRAS_API_KEY', - rpmLimit: 30, - tpmLimit: 60_000, - models: [ - { - id: 'llama-3.3-70b', - label: 'Llama 3.3 70B (Cerebras)', - contextWindow: 128_000, - strengths: ['general', 'reasoning', 'code'], - speedTier: 1, - }, - ], - }, -]; - -function inferStrengths(modelId: string): PromptCategory[] { - const lower = modelId.toLowerCase(); - const strengths = new Set(['general']); - - if (/coder|code|codestral|starcoder|deepseek/.test(lower)) strengths.add('code'); - if (/r1|reason|think|math/.test(lower)) { - strengths.add('reasoning'); - strengths.add('math'); - } - if (/qwen|llama|mistral|chat/.test(lower)) strengths.add('creative'); - - return [...strengths]; -} - -function inferContextWindow(modelId: string): number { - const lower = modelId.toLowerCase(); - if (/128k|131072/.test(lower)) return 128_000; - if (/64k|65536/.test(lower)) return 64_000; - if (/32k|32768|qwen2\.5/.test(lower)) return 32_768; - if (/16k|16384/.test(lower)) return 16_384; - return 8_192; -} - -function inferSpeedTier(modelId: string): 1 | 2 | 3 { - const lower = modelId.toLowerCase(); - if (/0\.5b|1b|3b|7b|mini|tiny/.test(lower)) return 1; - if (/14b|15b|16b|20b|22b|30b|32b/.test(lower)) return 2; - return 3; -} - -export function createLocalOllamaProvider( - modelIds: string[], - baseUrl: string = 'http://localhost:11434/v1' -): ProviderConfig { - const models: ModelConfig[] = modelIds.map(modelId => ({ - id: modelId, - label: modelId, - contextWindow: inferContextWindow(modelId), - strengths: inferStrengths(modelId), - speedTier: inferSpeedTier(modelId), - })); - - return { - name: 'local-ollama', - baseUrl, - models, - rpmLimit: 0, - tpmLimit: 0, - }; -} - -/** - * Filter providers to only those with API keys present in env. - */ -export function getAvailableProviders( - providers: ProviderConfig[] = DEFAULT_PROVIDERS -): ProviderConfig[] { - return providers.filter(p => { - if (!p.apiKeyEnv) return true; - const key = process.env[p.apiKeyEnv]; - return key !== undefined && key !== ''; - }); -} diff --git a/vendor/bytelyst/llm-router/src/router.ts b/vendor/bytelyst/llm-router/src/router.ts deleted file mode 100644 index f46951d..0000000 --- a/vendor/bytelyst/llm-router/src/router.ts +++ /dev/null @@ -1,362 +0,0 @@ -import type { - ChatCompletionRequest, - PromptCategory, - RouterConfig, - ProviderConfig, - RouteResult, - RoutePlan, - HealthSnapshot, -} from './types.js'; -import { DEFAULT_PROVIDERS, getAvailableProviders } from './registry.js'; -import { classifyPrompt } from './classifier.js'; -import { HealthTracker } from './health.js'; -import { selectCandidates, pickNext, excludeCandidate, createRoundRobinState } from './selector.js'; -import { sendChatCompletion } from './client.js'; - -export class LlmRouter { - private readonly providers: ProviderConfig[]; - private readonly health: HealthTracker; - private readonly timeoutMs: number; - private readonly maxRetries: number; - private readonly log: (entry: TelemetryEntry) => void; - private readonly roundRobinState: Map; - - constructor(config?: RouterConfig & { onTelemetry?: (entry: TelemetryEntry) => void }) { - const allProviders = config?.providers ?? DEFAULT_PROVIDERS; - this.providers = getAvailableProviders(allProviders); - - if (this.providers.length === 0) { - throw new Error( - 'No providers available. Set at least one API key env var: ' + - allProviders.map(p => p.apiKeyEnv).join(', ') - ); - } - - this.health = new HealthTracker({ - windowMs: config?.healthWindowMs, - errorThreshold: config?.errorThreshold, - rateLimitThreshold: config?.rateLimitThreshold, - }); - - this.timeoutMs = config?.timeoutMs ?? 30_000; - this.maxRetries = config?.maxRetries ?? 3; - this.log = config?.onTelemetry ?? (() => {}); - this.roundRobinState = createRoundRobinState(); - } - - /** - * Route a chat completion request to the best available provider. - * Automatically retries on 429/5xx with fallback to other providers. - */ - async chat(request: ChatCompletionRequest): Promise { - const startTime = Date.now(); - - const plan = this.planInternal(request, false); - - if (plan.explicit) { - return this.chatWithExplicitModel(request, startTime, plan); - } - - const category = plan.category as PromptCategory; - let candidates = selectCandidates(this.providers, category, this.health); - - let lastError: Error | null = null; - - for (let attempt = 1; attempt <= this.maxRetries; attempt++) { - const pick = pickNext(candidates, this.roundRobinState); - if (!pick) break; - - const { provider, model } = pick; - const attemptStart = Date.now(); - - try { - const result = await sendChatCompletion(provider, model.id, request, this.timeoutMs); - - if (result.status === 429) { - // Rate limited — record and try next provider - this.health.record(provider.name, model.id, { - timestamp: Date.now(), - latencyMs: result.latencyMs, - status: 'rate_limit', - }); - - this.log({ - event: 'rate_limit', - provider: provider.name, - model: model.id, - attempt, - latencyMs: result.latencyMs, - category, - }); - - candidates = excludeCandidate(candidates, provider.name, model.id); - continue; - } - - // Success - this.health.record(provider.name, model.id, { - timestamp: Date.now(), - latencyMs: result.latencyMs, - status: 'success', - }); - - this.log({ - event: 'success', - provider: provider.name, - model: model.id, - attempt, - latencyMs: result.latencyMs, - category, - tokens: result.response.usage?.total_tokens, - }); - - return { - response: result.response, - provider: provider.name, - model: model.id, - totalLatencyMs: Date.now() - startTime, - attempts: attempt, - }; - } catch (err) { - lastError = err instanceof Error ? err : new Error(String(err)); - const attemptLatency = Date.now() - attemptStart; - - this.health.record(provider.name, model.id, { - timestamp: Date.now(), - latencyMs: attemptLatency, - status: 'error', - }); - - this.log({ - event: 'error', - provider: provider.name, - model: model.id, - attempt, - latencyMs: attemptLatency, - category, - error: lastError.message, - }); - - candidates = excludeCandidate(candidates, provider.name, model.id); - } - } - - throw new Error( - `All providers exhausted after ${this.maxRetries} attempts. Last error: ${lastError?.message ?? 'unknown'}` - ); - } - - /** - * Handle explicit provider:model routing (bypass classifier). - */ - private async chatWithExplicitModel( - request: ChatCompletionRequest, - startTime: number, - plan?: RoutePlan - ): Promise { - const resolved = plan ?? this.plan(request); - const provider = resolved.provider; - const modelId = resolved.model.id; - - try { - const result = await sendChatCompletion(provider, modelId, request, this.timeoutMs); - - if (result.status === 429) { - this.health.record(provider.name, modelId, { - timestamp: Date.now(), - latencyMs: result.latencyMs, - status: 'rate_limit', - }); - - this.log({ - event: 'rate_limit', - provider: provider.name, - model: modelId, - attempt: 1, - latencyMs: result.latencyMs, - category: 'explicit', - }); - - throw new Error(`Rate limited by ${provider.name} for model ${modelId}`); - } - - this.health.record(provider.name, modelId, { - timestamp: Date.now(), - latencyMs: result.latencyMs, - status: 'success', - }); - - this.log({ - event: 'success', - provider: provider.name, - model: modelId, - attempt: 1, - latencyMs: result.latencyMs, - category: 'explicit', - tokens: result.response.usage?.total_tokens, - }); - - return { - response: result.response, - provider: provider.name, - model: modelId, - totalLatencyMs: Date.now() - startTime, - attempts: 1, - }; - } catch (err) { - // Re-throw rate-limit errors (already logged above) - if (err instanceof Error && err.message.startsWith('Rate limited by')) { - throw err; - } - - const latency = Date.now() - startTime; - this.health.record(provider.name, modelId, { - timestamp: Date.now(), - latencyMs: latency, - status: 'error', - }); - - this.log({ - event: 'error', - provider: provider.name, - model: modelId, - attempt: 1, - latencyMs: latency, - category: 'explicit', - error: err instanceof Error ? err.message : String(err), - }); - - throw err; - } - } - - plan(request: ChatCompletionRequest): RoutePlan { - return this.planInternal(request, true); - } - - private planInternal(request: ChatCompletionRequest, advanceRoundRobin: boolean): RoutePlan { - const explicit = this.resolveExplicitModel(request.model); - if (explicit) { - return { - provider: explicit.provider, - model: explicit.model, - category: 'explicit', - explicit: true, - }; - } - - const classification = classifyPrompt(request.messages); - const candidates = selectCandidates(this.providers, classification.category, this.health); - - if (candidates.length === 0) { - throw new Error('No healthy providers available for routing'); - } - - const pick = advanceRoundRobin - ? pickNext(candidates, this.roundRobinState) - : (candidates[0] ?? null); - if (!pick) { - throw new Error('No provider available for routing'); - } - - return { - provider: pick.provider, - model: pick.model, - category: classification.category, - explicit: false, - }; - } - - private resolveExplicitModel( - model?: string - ): { provider: ProviderConfig; model: RoutePlan['model'] } | null { - if (!model) return null; - - if (model.includes(':') || model.includes('/')) { - const { providerName, modelId } = parseExplicitModel(model); - const provider = this.providers.find(p => p.name === providerName); - if (!provider) { - throw new Error( - `Provider "${providerName}" not found. Available: ${this.providers.map(p => p.name).join(', ')}` - ); - } - - const matchedModel = provider.models.find(candidate => candidate.id === modelId); - if (!matchedModel) { - throw new Error( - `Model "${modelId}" not found for provider "${providerName}". Available: ${provider.models - .map(candidate => candidate.id) - .join(', ')}` - ); - } - - return { provider, model: matchedModel }; - } - - const matches = this.providers.flatMap(provider => - provider.models - .filter(candidate => candidate.id === model) - .map(candidate => ({ provider, model: candidate })) - ); - - if (matches.length === 1) { - return matches[0]!; - } - - if (matches.length > 1) { - throw new Error( - `Model "${model}" is available on multiple providers. Use provider:model format instead.` - ); - } - - return null; - } - - /** Get health snapshots for all tracked provider+model pairs. */ - getHealth(): HealthSnapshot[] { - return this.health.allSnapshots(); - } - - /** Get list of available (configured) providers. */ - getProviders(): string[] { - return this.providers.map(p => p.name); - } - - /** Reset health tracking data. */ - resetHealth(): void { - this.health.reset(); - } -} - -function parseExplicitModel(raw: string): { providerName: string; modelId: string } { - const colonIdx = raw.indexOf(':'); - const slashIdx = raw.indexOf('/'); - let sepIdx: number; - if (colonIdx === -1 && slashIdx === -1) { - sepIdx = -1; - } else if (colonIdx === -1) { - sepIdx = slashIdx; - } else if (slashIdx === -1) { - sepIdx = colonIdx; - } else { - sepIdx = Math.min(colonIdx, slashIdx); - } - - return { - providerName: sepIdx === -1 ? raw : raw.slice(0, sepIdx), - modelId: sepIdx === -1 ? '' : raw.slice(sepIdx + 1), - }; -} - -// ── Telemetry types ──────────────────────────────────────────── - -export interface TelemetryEntry { - event: 'success' | 'rate_limit' | 'error'; - provider: string; - model: string; - attempt: number; - latencyMs: number; - category: string; - tokens?: number; - error?: string; -} diff --git a/vendor/bytelyst/llm-router/src/selector.ts b/vendor/bytelyst/llm-router/src/selector.ts deleted file mode 100644 index 48c3821..0000000 --- a/vendor/bytelyst/llm-router/src/selector.ts +++ /dev/null @@ -1,108 +0,0 @@ -import type { ModelConfig, PromptCategory, ProviderConfig } from './types.js'; -import type { HealthTracker } from './health.js'; - -export interface SelectionCandidate { - provider: ProviderConfig; - model: ModelConfig; -} - -/** Create a fresh round-robin state map (one per router instance). */ -export function createRoundRobinState(): Map { - return new Map(); -} - -/** - * Score a model for a given prompt category. - * Higher = better fit. - */ -function scoreModel(model: ModelConfig, category: PromptCategory): number { - let score = 0; - - // Vision requests require vision-capable models - if (category === 'vision') { - if (!model.supportsVision) return -1; // Exclude non-vision models - score += 15; // Strong boost for vision capability - } - - // Direct strength match is the strongest signal - if (model.strengths.includes(category)) { - score += 10; - } - - // Speed bonus (lower tier = faster = better for simple tasks) - score += (4 - model.speedTier) * 2; - - // Context window bonus for reasoning/creative (often longer) - if ((category === 'reasoning' || category === 'creative') && model.contextWindow >= 64_000) { - score += 3; - } - - // Prefer larger models for code/math/reasoning - if (['code', 'math', 'reasoning'].includes(category)) { - if (model.id.includes('70b') || model.id.includes('70B')) score += 5; - if (model.id.includes('r1') || model.id.includes('R1')) score += 4; - } - - return score; -} - -/** - * Select the best provider+model candidates for a prompt category. - * Returns candidates sorted by score (best first), filtered by health. - */ -export function selectCandidates( - providers: ProviderConfig[], - category: PromptCategory, - health: HealthTracker -): SelectionCandidate[] { - const candidates: (SelectionCandidate & { score: number })[] = []; - - for (const provider of providers) { - for (const model of provider.models) { - if (!health.isHealthy(provider.name, model.id)) continue; - - const score = scoreModel(model, category); - if (score < 0) continue; // Skip incompatible models (e.g. non-vision for vision requests) - candidates.push({ provider, model, score }); - } - } - - // Sort by score descending - candidates.sort((a, b) => b.score - a.score); - - return candidates; -} - -/** - * Pick the next candidate using round-robin within the top tier. - * Groups candidates by provider, rotates between them to spread rate-limit load. - */ -export function pickNext( - candidates: SelectionCandidate[], - state: Map -): SelectionCandidate | null { - if (candidates.length === 0) return null; - if (candidates.length === 1) return candidates[0]!; - - // Group by provider name for round-robin - const providerNames = [...new Set(candidates.map(c => c.provider.name))]; - const key = providerNames.join(','); - - const idx = state.get(key) ?? 0; - const targetProvider = providerNames[idx % providerNames.length]!; - state.set(key, idx + 1); - - // Pick the best model from the selected provider - return candidates.find(c => c.provider.name === targetProvider) ?? candidates[0]!; -} - -/** - * Remove a candidate from the list (after failure) and return remaining. - */ -export function excludeCandidate( - candidates: SelectionCandidate[], - provider: string, - model: string -): SelectionCandidate[] { - return candidates.filter(c => !(c.provider.name === provider && c.model.id === model)); -} diff --git a/vendor/bytelyst/llm-router/src/types.ts b/vendor/bytelyst/llm-router/src/types.ts deleted file mode 100644 index 4831291..0000000 --- a/vendor/bytelyst/llm-router/src/types.ts +++ /dev/null @@ -1,147 +0,0 @@ -// ── Provider & Model Types ───────────────────────────────────── - -export interface ModelConfig { - /** Model identifier as the provider expects it */ - id: string; - /** Human-readable label */ - label: string; - /** Max context window tokens */ - contextWindow: number; - /** What this model is good at */ - strengths: PromptCategory[]; - /** Relative speed tier: 1 = fastest, 3 = slowest */ - speedTier: 1 | 2 | 3; - /** Whether the model supports vision (image) inputs */ - supportsVision?: boolean; - /** Whether the model supports text embedding generation */ - supportsEmbedding?: boolean; -} - -export interface ProviderConfig { - /** Unique provider name */ - name: string; - /** OpenAI-compatible base URL (e.g. https://api.groq.com/openai/v1) */ - baseUrl: string; - /** Environment variable name that holds the API key (omit for local/no-auth providers) */ - apiKeyEnv?: string; - /** Available models on this provider */ - models: ModelConfig[]; - /** Extra headers to send with every request */ - extraHeaders?: Record; - /** Free-tier rate limit: requests per minute (0 = unknown) */ - rpmLimit: number; - /** Free-tier rate limit: tokens per minute (0 = unknown) */ - tpmLimit: number; -} - -// ── Prompt Classification ────────────────────────────────────── - -export type PromptCategory = 'code' | 'math' | 'reasoning' | 'creative' | 'general' | 'vision'; - -export interface ClassificationResult { - category: PromptCategory; - estimatedTokens: number; -} - -// ── Health Tracking ──────────────────────────────────────────── - -export interface HealthSnapshot { - provider: string; - model: string; - /** Total requests in the window */ - totalRequests: number; - /** Successful requests */ - successes: number; - /** 429 rate-limit hits */ - rateLimits: number; - /** 5xx / network errors */ - errors: number; - /** Average latency in ms (successes only) */ - avgLatencyMs: number; - /** p95 latency in ms */ - p95LatencyMs: number; - /** Whether this provider is currently considered healthy */ - healthy: boolean; -} - -export interface RequestRecord { - timestamp: number; - latencyMs: number; - status: 'success' | 'rate_limit' | 'error'; -} - -// ── Router Config ────────────────────────────────────────────── - -export interface RouterConfig { - /** Provider configurations (use DEFAULT_PROVIDERS if omitted) */ - providers?: ProviderConfig[]; - /** Health window in ms (default: 60_000 = 1 minute) */ - healthWindowMs?: number; - /** Error rate threshold to mark unhealthy (default: 0.5 = 50%) */ - errorThreshold?: number; - /** Rate-limit rate threshold to mark unhealthy (default: 0.3 = 30%) */ - rateLimitThreshold?: number; - /** Request timeout in ms (default: 30_000) */ - timeoutMs?: number; - /** Max retry attempts across providers (default: 3) */ - maxRetries?: number; -} - -// ── OpenAI-Compatible Request/Response ───────────────────────── - -export interface ChatMessage { - role: 'system' | 'user' | 'assistant'; - content: string; -} - -export interface ChatCompletionRequest { - messages: ChatMessage[]; - /** Optional: force a specific model (provider:model format or just model id) */ - model?: string; - temperature?: number; - max_tokens?: number; - top_p?: number; - stream?: boolean; -} - -export interface ChatCompletionChoice { - index: number; - message: ChatMessage; - finish_reason: string | null; -} - -export interface ChatCompletionUsage { - prompt_tokens: number; - completion_tokens: number; - total_tokens: number; -} - -export interface ChatCompletionResponse { - id: string; - object: 'chat.completion'; - created: number; - model: string; - choices: ChatCompletionChoice[]; - usage?: ChatCompletionUsage; -} - -// ── Router Result (wraps response + metadata) ────────────────── - -export interface RouteResult { - response: ChatCompletionResponse; - /** Which provider served this request */ - provider: string; - /** Which model was used */ - model: string; - /** Total latency in ms (including retries) */ - totalLatencyMs: number; - /** How many attempts were made */ - attempts: number; -} - -export interface RoutePlan { - provider: ProviderConfig; - model: ModelConfig; - category: PromptCategory | 'explicit'; - explicit: boolean; -} diff --git a/vendor/bytelyst/llm-router/tsconfig.json b/vendor/bytelyst/llm-router/tsconfig.json deleted file mode 100644 index 8635ab2..0000000 --- a/vendor/bytelyst/llm-router/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src/**/*.ts"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/vendor/bytelyst/llm-router/vitest.config.ts b/vendor/bytelyst/llm-router/vitest.config.ts deleted file mode 100644 index 811c18a..0000000 --- a/vendor/bytelyst/llm-router/vitest.config.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - test: { - globals: true, - passWithNoTests: true, - pool: 'forks', - }, -}); diff --git a/vendor/bytelyst/llm/package.json b/vendor/bytelyst/llm/package.json deleted file mode 100644 index 215c0c1..0000000 --- a/vendor/bytelyst/llm/package.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "@bytelyst/llm", - "version": "0.1.5", - "type": "module", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - }, - "./testing": { - "import": "./dist/testing.js", - "types": "./dist/testing.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks" - }, - "devDependencies": { - "vitest": "^3.0.0" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/llm/src/__tests__/fallback.test.ts b/vendor/bytelyst/llm/src/__tests__/fallback.test.ts deleted file mode 100644 index 89d4152..0000000 --- a/vendor/bytelyst/llm/src/__tests__/fallback.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -/** - * Tests for createFallbackChain. - */ - -import { describe, it, expect } from 'vitest'; -import { createFallbackChain } from '../fallback.js'; -import { MockLLMProvider } from '../providers/mock.js'; -import type { ChatCompletionResponse } from '../types.js'; - -const makeResponse = (content: string): ChatCompletionResponse => ({ - content, - model: 'mock', - finishReason: 'stop', - usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 }, -}); - -describe('createFallbackChain', () => { - it('isConfigured returns true when at least one provider is configured', () => { - const a = new MockLLMProvider(); - const chain = createFallbackChain([a]); - expect(chain.isConfigured()).toBe(true); - }); - - it('isConfigured returns false when no providers are configured', () => { - const unconfigured = { - isConfigured: () => false, - chatCompletion: async () => { - throw new Error('not configured'); - }, - }; - const chain = createFallbackChain([unconfigured]); - expect(chain.isConfigured()).toBe(false); - }); - - it('returns response from first configured provider', async () => { - const a = new MockLLMProvider([makeResponse('from-a')]); - const b = new MockLLMProvider([makeResponse('from-b')]); - const chain = createFallbackChain([a, b]); - - const result = await chain.chatCompletion({ messages: [{ role: 'user', content: 'hi' }] }); - expect(result.content).toBe('from-a'); - expect(b.calls).toHaveLength(0); - }); - - it('falls back to second provider when first throws', async () => { - const a = { - isConfigured: () => true, - chatCompletion: async (): Promise => { - throw new Error('a failed'); - }, - }; - const b = new MockLLMProvider([makeResponse('from-b')]); - const chain = createFallbackChain([a, b]); - - const result = await chain.chatCompletion({ messages: [{ role: 'user', content: 'hi' }] }); - expect(result.content).toBe('from-b'); - }); - - it('skips unconfigured providers', async () => { - const unconfigured = { - isConfigured: () => false, - chatCompletion: async (): Promise => { - throw new Error('should not be called'); - }, - }; - const b = new MockLLMProvider([makeResponse('from-b')]); - const chain = createFallbackChain([unconfigured, b]); - - const result = await chain.chatCompletion({ messages: [{ role: 'user', content: 'hi' }] }); - expect(result.content).toBe('from-b'); - }); - - it('throws with all error messages when every provider fails', async () => { - const a = { - isConfigured: () => true, - chatCompletion: async (): Promise => { - throw new Error('a failed'); - }, - }; - const b = { - isConfigured: () => true, - chatCompletion: async (): Promise => { - throw new Error('b failed'); - }, - }; - const chain = createFallbackChain([a, b]); - - await expect( - chain.chatCompletion({ messages: [{ role: 'user', content: 'hi' }] }) - ).rejects.toThrow('All providers failed: a failed | b failed'); - }); - - it('throws "No providers configured" when list is empty', async () => { - const chain = createFallbackChain([]); - await expect( - chain.chatCompletion({ messages: [{ role: 'user', content: 'hi' }] }) - ).rejects.toThrow('No providers configured'); - }); -}); diff --git a/vendor/bytelyst/llm/src/__tests__/llm.test.ts b/vendor/bytelyst/llm/src/__tests__/llm.test.ts deleted file mode 100644 index 8db6b8d..0000000 --- a/vendor/bytelyst/llm/src/__tests__/llm.test.ts +++ /dev/null @@ -1,299 +0,0 @@ -/** - * Tests for LLM providers, factory, types, and helpers. - */ - -import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; -import { MockLLMProvider } from '../providers/mock.js'; -import { createLLMProvider, _resetLLM } from '../factory.js'; -import { isVisionMessage, hasVisionContent, buildVisionMessage, getMessageText } from '../types.js'; -import type { ChatMessage, ChatCompletionRequest, EmbeddingResponse } from '../types.js'; - -// ── Helper function tests ───────────────────────────────────────── - -describe('isVisionMessage', () => { - it('returns false for string content', () => { - const msg: ChatMessage = { role: 'user', content: 'hello' }; - expect(isVisionMessage(msg)).toBe(false); - }); - - it('returns false for text-only multipart', () => { - const msg: ChatMessage = { - role: 'user', - content: [{ type: 'text', text: 'hello' }], - }; - expect(isVisionMessage(msg)).toBe(false); - }); - - it('returns true when message contains image_url part', () => { - const msg: ChatMessage = { - role: 'user', - content: [ - { type: 'text', text: 'describe this' }, - { type: 'image_url', image_url: { url: 'https://example.com/img.png' } }, - ], - }; - expect(isVisionMessage(msg)).toBe(true); - }); -}); - -describe('hasVisionContent', () => { - it('returns false for text-only request', () => { - const req: ChatCompletionRequest = { - messages: [ - { role: 'system', content: 'You are helpful' }, - { role: 'user', content: 'hello' }, - ], - }; - expect(hasVisionContent(req)).toBe(false); - }); - - it('returns true when any message has image content', () => { - const req: ChatCompletionRequest = { - messages: [ - { role: 'system', content: 'You are helpful' }, - { - role: 'user', - content: [ - { type: 'text', text: 'what is this?' }, - { type: 'image_url', image_url: { url: 'data:image/png;base64,abc' } }, - ], - }, - ], - }; - expect(hasVisionContent(req)).toBe(true); - }); -}); - -describe('buildVisionMessage', () => { - it('builds a multipart user message with text and image', () => { - const msg = buildVisionMessage('Describe this', 'https://img.com/a.png'); - expect(msg.role).toBe('user'); - expect(Array.isArray(msg.content)).toBe(true); - const parts = msg.content as Array<{ type: string }>; - expect(parts).toHaveLength(2); - expect(parts[0]).toEqual({ type: 'text', text: 'Describe this' }); - expect(parts[1]).toEqual({ - type: 'image_url', - image_url: { url: 'https://img.com/a.png', detail: 'auto' }, - }); - }); - - it('respects detail parameter', () => { - const msg = buildVisionMessage('hi', 'https://img.com/b.png', 'high'); - const parts = msg.content as Array<{ type: string; image_url?: { detail: string } }>; - expect(parts[1]?.image_url?.detail).toBe('high'); - }); -}); - -describe('getMessageText', () => { - it('returns string content directly', () => { - expect(getMessageText({ role: 'user', content: 'hello' })).toBe('hello'); - }); - - it('extracts text from multipart content', () => { - const msg: ChatMessage = { - role: 'user', - content: [ - { type: 'text', text: 'line one' }, - { type: 'image_url', image_url: { url: 'https://img.com/x.png' } }, - { type: 'text', text: 'line two' }, - ], - }; - expect(getMessageText(msg)).toBe('line one\nline two'); - }); - - it('returns empty for image-only multipart', () => { - const msg: ChatMessage = { - role: 'user', - content: [{ type: 'image_url', image_url: { url: 'https://img.com/x.png' } }], - }; - expect(getMessageText(msg)).toBe(''); - }); -}); - -// ── MockLLMProvider tests ───────────────────────────────────────── - -describe('MockLLMProvider', () => { - let provider: MockLLMProvider; - - beforeEach(() => { - provider = new MockLLMProvider(); - }); - - it('isConfigured returns true', () => { - expect(provider.isConfigured()).toBe(true); - }); - - it('returns default echo response', async () => { - const result = await provider.chatCompletion({ - messages: [{ role: 'user', content: 'Hello' }], - }); - expect(result.content).toContain('Hello'); - expect(result.finishReason).toBe('stop'); - }); - - it('returns queued responses', async () => { - provider.addResponse({ - content: 'Custom response', - model: 'test-model', - finishReason: 'stop', - usage: { promptTokens: 5, completionTokens: 5, totalTokens: 10 }, - }); - - const result = await provider.chatCompletion({ - messages: [{ role: 'user', content: 'Hello' }], - }); - expect(result.content).toBe('Custom response'); - }); - - it('tracks calls', async () => { - const req = { messages: [{ role: 'user' as const, content: 'Test' }] }; - await provider.chatCompletion(req); - expect(provider.calls).toHaveLength(1); - expect(provider.calls[0]).toEqual(req); - }); - - it('reset clears calls and responses', async () => { - provider.addResponse({ - content: 'x', - model: 'm', - finishReason: 'stop', - usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 }, - }); - await provider.chatCompletion({ messages: [{ role: 'user', content: 'Test' }] }); - provider.reset(); - expect(provider.calls).toHaveLength(0); - }); - - it('handles multipart (vision) content in echo response', async () => { - const result = await provider.chatCompletion({ - messages: [ - { - role: 'user', - content: [ - { type: 'text', text: 'Describe this image' }, - { type: 'image_url', image_url: { url: 'https://example.com/img.png' } }, - ], - }, - ], - }); - expect(result.content).toContain('Describe this image'); - }); - - // ── Streaming tests ────────────────────────────────── - - it('streams default echo response word by word', async () => { - const stream = provider.chatCompletionStream!({ - messages: [{ role: 'user', content: 'Hi' }], - }); - const chunks: string[] = []; - for await (const chunk of stream) { - chunks.push(chunk); - } - expect(chunks.length).toBeGreaterThan(0); - const full = chunks.join(''); - expect(full).toContain('Hi'); - }); - - it('streams queued response word by word', async () => { - provider.addResponse({ - content: 'Hello World', - model: 'test', - finishReason: 'stop', - usage: { promptTokens: 1, completionTokens: 2, totalTokens: 3 }, - }); - const stream = provider.chatCompletionStream!({ - messages: [{ role: 'user', content: 'x' }], - }); - const chunks: string[] = []; - for await (const chunk of stream) { - chunks.push(chunk); - } - expect(chunks).toEqual(['Hello ', 'World ']); - }); - - it('streaming tracks calls', async () => { - const req = { messages: [{ role: 'user' as const, content: 'stream test' }] }; - const stream = provider.chatCompletionStream!(req); - const drained: string[] = []; - for await (const chunk of stream) { - drained.push(chunk); - } - expect(drained.length).toBeGreaterThan(0); - expect(provider.calls).toHaveLength(1); - }); - - // ── Embedding tests ────────────────────────────────── - - it('returns deterministic embeddings for single input', async () => { - const result = await provider.embed!({ input: 'hello world' }); - expect(result.embeddings).toHaveLength(1); - expect(result.embeddings[0].length).toBe(8); - expect(result.model).toBe('mock-embedding-model'); - // Verify normalized (magnitude ≈ 1) - const mag = Math.sqrt(result.embeddings[0].reduce((s, v) => s + v * v, 0)); - expect(mag).toBeCloseTo(1.0, 3); - }); - - it('returns multiple embeddings for array input', async () => { - const result = await provider.embed!({ input: ['hello', 'world'] }); - expect(result.embeddings).toHaveLength(2); - expect(result.embeddings[0]).not.toEqual(result.embeddings[1]); - }); - - it('returns queued embedding response', async () => { - const custom: EmbeddingResponse = { - embeddings: [[0.1, 0.2, 0.3]], - model: 'custom-embed', - usage: { promptTokens: 1, completionTokens: 0, totalTokens: 1 }, - }; - provider.addEmbeddingResponse(custom); - const result = await provider.embed!({ input: 'test' }); - expect(result).toEqual(custom); - }); - - it('tracks embed calls', async () => { - await provider.embed!({ input: 'track me' }); - expect(provider.embedCalls).toHaveLength(1); - expect(provider.embedCalls[0].input).toBe('track me'); - }); - - it('deterministic: same input produces same embedding', async () => { - const r1 = await provider.embed!({ input: 'identical text' }); - const r2 = await provider.embed!({ input: 'identical text' }); - expect(r1.embeddings[0]).toEqual(r2.embeddings[0]); - }); - - it('reset clears embed state', async () => { - const custom: EmbeddingResponse = { - embeddings: [[0.5]], - model: 'm', - usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 }, - }; - provider.addEmbeddingResponse(custom); - await provider.embed!({ input: 'test' }); - provider.reset(); - expect(provider.embedCalls).toHaveLength(0); - // After reset, embed should return default (not queued) response - const result = await provider.embed!({ input: 'after reset' }); - expect(result.model).toBe('mock-embedding-model'); - }); -}); - -// ── Factory tests ───────────────────────────────────────────────── - -describe('createLLMProvider', () => { - afterEach(() => { - _resetLLM(); - vi.unstubAllEnvs(); - }); - - it('creates mock provider', () => { - const provider = createLLMProvider('mock'); - expect(provider).toBeInstanceOf(MockLLMProvider); - }); - - it('throws for unknown type', () => { - expect(() => createLLMProvider('unknown' as 'mock')).toThrow('Unknown LLM_PROVIDER'); - }); -}); diff --git a/vendor/bytelyst/llm/src/__tests__/providers.test.ts b/vendor/bytelyst/llm/src/__tests__/providers.test.ts deleted file mode 100644 index 7ba1510..0000000 --- a/vendor/bytelyst/llm/src/__tests__/providers.test.ts +++ /dev/null @@ -1,181 +0,0 @@ -/** - * Tests for PerplexityProvider and GeminiProvider. - * Uses vi.stubGlobal to mock fetch — no real API calls. - */ - -import { describe, it, expect, vi, afterEach } from 'vitest'; -import { PerplexityProvider } from '../providers/perplexity.js'; -import { GeminiProvider } from '../providers/gemini.js'; - -const makeOpenAIResponse = (content: string, model = 'test-model') => ({ - choices: [{ message: { content }, finish_reason: 'stop' }], - model, - usage: { prompt_tokens: 5, completion_tokens: 10, total_tokens: 15 }, -}); - -const makeGeminiResponse = (text: string) => ({ - candidates: [{ content: { parts: [{ text }] }, finishReason: 'STOP' }], - usageMetadata: { promptTokenCount: 5, candidatesTokenCount: 10, totalTokenCount: 15 }, -}); - -afterEach(() => { - vi.restoreAllMocks(); - vi.unstubAllEnvs(); -}); - -// ── PerplexityProvider ────────────────────────────────────────── - -describe('PerplexityProvider', () => { - it('isConfigured false without API key', () => { - const p = new PerplexityProvider({ apiKey: '' }); - expect(p.isConfigured()).toBe(false); - }); - - it('isConfigured true with API key', () => { - const p = new PerplexityProvider({ apiKey: 'test-key' }); - expect(p.isConfigured()).toBe(true); - }); - - it('reads apiKey from env', () => { - vi.stubEnv('PERPLEXITY_API_KEY', 'env-key'); - const p = new PerplexityProvider(); - expect(p.isConfigured()).toBe(true); - }); - - it('throws when not configured', async () => { - const p = new PerplexityProvider({ apiKey: '' }); - await expect(p.chatCompletion({ messages: [{ role: 'user', content: 'hi' }] })).rejects.toThrow( - 'Perplexity is not configured' - ); - }); - - it('calls Perplexity API and maps response', async () => { - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - json: async () => makeOpenAIResponse('analysis result', 'sonar'), - }); - vi.stubGlobal('fetch', fetchMock); - - const p = new PerplexityProvider({ apiKey: 'test-key', model: 'sonar' }); - const result = await p.chatCompletion({ - messages: [{ role: 'user', content: 'analyse BTC' }], - temperature: 0.2, - }); - - expect(result.content).toBe('analysis result'); - expect(result.model).toBe('sonar'); - expect(result.finishReason).toBe('stop'); - expect(result.usage.totalTokens).toBe(15); - - const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; - expect(url).toBe('https://api.perplexity.ai/chat/completions'); - expect((init.headers as Record)['Authorization']).toBe('Bearer test-key'); - }); - - it('throws on non-ok response', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: false, - status: 429, - text: async () => 'rate limited', - }) - ); - - const p = new PerplexityProvider({ apiKey: 'test-key' }); - await expect(p.chatCompletion({ messages: [{ role: 'user', content: 'hi' }] })).rejects.toThrow( - 'Perplexity error 429' - ); - }); -}); - -// ── GeminiProvider ────────────────────────────────────────────── - -describe('GeminiProvider', () => { - it('isConfigured false without API key', () => { - const p = new GeminiProvider({ apiKey: '' }); - expect(p.isConfigured()).toBe(false); - }); - - it('isConfigured true with API key', () => { - const p = new GeminiProvider({ apiKey: 'test-key' }); - expect(p.isConfigured()).toBe(true); - }); - - it('reads apiKey from env', () => { - vi.stubEnv('GEMINI_API_KEY', 'env-key'); - const p = new GeminiProvider(); - expect(p.isConfigured()).toBe(true); - }); - - it('throws when not configured', async () => { - const p = new GeminiProvider({ apiKey: '' }); - await expect(p.chatCompletion({ messages: [{ role: 'user', content: 'hi' }] })).rejects.toThrow( - 'Gemini is not configured' - ); - }); - - it('calls Gemini API and maps response', async () => { - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - json: async () => makeGeminiResponse('gemini analysis'), - }); - vi.stubGlobal('fetch', fetchMock); - - const p = new GeminiProvider({ apiKey: 'test-key', model: 'gemini-1.5-flash' }); - const result = await p.chatCompletion({ - messages: [ - { role: 'system', content: 'You are a trading assistant.' }, - { role: 'user', content: 'analyse BTC' }, - ], - temperature: 0.2, - }); - - expect(result.content).toBe('gemini analysis'); - expect(result.model).toBe('gemini-1.5-flash'); - expect(result.finishReason).toBe('stop'); - expect(result.usage.totalTokens).toBe(15); - - const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; - expect(url).toContain('generativelanguage.googleapis.com'); - expect(url).toContain('gemini-1.5-flash'); - expect(url).toContain('test-key'); - - const body = JSON.parse(init.body as string); - expect(body.systemInstruction.parts[0].text).toBe('You are a trading assistant.'); - expect(body.contents[0].role).toBe('user'); - }); - - it('maps MAX_TOKENS finish reason to length', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - candidates: [{ content: { parts: [{ text: 'truncated' }] }, finishReason: 'MAX_TOKENS' }], - usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1, totalTokenCount: 2 }, - }), - }) - ); - - const p = new GeminiProvider({ apiKey: 'test-key' }); - const result = await p.chatCompletion({ messages: [{ role: 'user', content: 'hi' }] }); - expect(result.finishReason).toBe('length'); - }); - - it('throws on non-ok response', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: false, - status: 400, - text: async () => 'bad request', - }) - ); - - const p = new GeminiProvider({ apiKey: 'test-key' }); - await expect(p.chatCompletion({ messages: [{ role: 'user', content: 'hi' }] })).rejects.toThrow( - 'Gemini error 400' - ); - }); -}); diff --git a/vendor/bytelyst/llm/src/factory.ts b/vendor/bytelyst/llm/src/factory.ts deleted file mode 100644 index 3765167..0000000 --- a/vendor/bytelyst/llm/src/factory.ts +++ /dev/null @@ -1,105 +0,0 @@ -/** - * LLM provider factory. - * - * Creates an LLMProvider based on LLM_PROVIDER env var. - * Auto-detects provider from endpoint/key env vars if not explicitly set. - * - * Provider selection priority: - * LLM_PROVIDER env var > auto-detect from endpoint/key env vars > openai - * - * To use a fallback chain (e.g. perplexity → openai → gemini), set: - * LLM_PROVIDER=fallback - * LLM_FALLBACK_ORDER=perplexity,openai,gemini (default if unset) - */ - -import { AzureOpenAIProvider } from './providers/azure-openai.js'; -import { FallbackLLMProvider } from './providers/fallback.js'; -import { GeminiProvider } from './providers/gemini.js'; -import { MockLLMProvider } from './providers/mock.js'; -import { OpenAIProvider } from './providers/openai.js'; -import { PerplexityProvider } from './providers/perplexity.js'; -import type { LLMProvider, LLMProviderType } from './types.js'; - -let _provider: LLMProvider | null = null; - -/** - * Resolve provider type from env vars. - * Priority: LLM_PROVIDER > OPENAI_PROVIDER > auto-detect from keys/endpoints. - */ -function resolveProviderType(): LLMProviderType { - const explicit = (process.env.LLM_PROVIDER || process.env.OPENAI_PROVIDER || '').toLowerCase(); - if (explicit === 'azure') return 'azure'; - if (explicit === 'openai') return 'openai'; - if (explicit === 'perplexity') return 'perplexity'; - if (explicit === 'gemini') return 'gemini'; - if (explicit === 'fallback') return 'fallback'; - if (explicit === 'mock') return 'mock'; - - // Auto-detect from environment - const azureEndpoint = process.env.AZURE_OPENAI_ENDPOINT ?? ''; - const baseUrl = process.env.OPENAI_BASE_URL ?? ''; - if (azureEndpoint.trim().length > 0) return 'azure'; - if (baseUrl.includes('.cognitive.microsoft.com') || baseUrl.includes('.openai.azure.com')) - return 'azure'; - if (process.env.PERPLEXITY_API_KEY) return 'perplexity'; - if (process.env.GEMINI_API_KEY) return 'gemini'; - - return 'openai'; -} - -/** - * Get the singleton LLM provider. - */ -export function getLLM(): LLMProvider { - if (!_provider) { - _provider = createLLMProvider(resolveProviderType()); - } - return _provider; -} - -/** - * Create an LLM provider by type. - * For 'fallback', reads LLM_FALLBACK_ORDER env var (comma-separated provider names). - */ -export function createLLMProvider(type: LLMProviderType): LLMProvider { - switch (type) { - case 'azure': - return new AzureOpenAIProvider(); - case 'openai': - return new OpenAIProvider(); - case 'perplexity': - return new PerplexityProvider(); - case 'gemini': - return new GeminiProvider(); - case 'fallback': { - const order = (process.env.LLM_FALLBACK_ORDER ?? 'perplexity,openai,gemini') - .split(',') - .map(s => s.trim() as LLMProviderType) - .filter(name => name && name !== 'fallback'); // prevent infinite recursion - if (order.length === 0) { - throw new Error('LLM_FALLBACK_ORDER must contain at least one non-fallback provider'); - } - return new FallbackLLMProvider(order.map(createLLMProvider)); - } - case 'mock': - return new MockLLMProvider(); - default: - throw new Error( - `Unknown LLM_PROVIDER: '${type}'. Valid: azure, openai, perplexity, gemini, fallback, mock` - ); - } -} - -/** - * Set the singleton LLM provider (for testing). - */ -export function setLLM(provider: LLMProvider): void { - _provider = provider; -} - -/** - * @internal - */ -export function _resetLLM(): void { - _provider = null; -} diff --git a/vendor/bytelyst/llm/src/fallback.ts b/vendor/bytelyst/llm/src/fallback.ts deleted file mode 100644 index 1f4fcb3..0000000 --- a/vendor/bytelyst/llm/src/fallback.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Fallback chain utility. - * - * Wraps an ordered list of LLMProviders into a single LLMProvider that - * tries each in sequence, skipping unconfigured ones, and moves to the - * next on any error. Throws only when all providers are exhausted. - */ - -import type { ChatCompletionRequest, ChatCompletionResponse, LLMProvider } from './types.js'; - -export function createFallbackChain(providers: LLMProvider[]): LLMProvider { - return { - isConfigured(): boolean { - return providers.some(p => p.isConfigured()); - }, - - async chatCompletion(req: ChatCompletionRequest): Promise { - const errors: string[] = []; - - for (const provider of providers) { - if (!provider.isConfigured()) continue; - try { - return await provider.chatCompletion(req); - } catch (err) { - errors.push(err instanceof Error ? err.message : String(err)); - } - } - - throw new Error( - errors.length > 0 - ? `All providers failed: ${errors.join(' | ')}` - : 'No providers configured' - ); - }, - }; -} diff --git a/vendor/bytelyst/llm/src/index.ts b/vendor/bytelyst/llm/src/index.ts deleted file mode 100644 index e3c2993..0000000 --- a/vendor/bytelyst/llm/src/index.ts +++ /dev/null @@ -1,24 +0,0 @@ -export type { - LLMProvider, - ChatCompletionRequest, - ChatCompletionResponse, - ChatMessage, - TokenUsage, - LLMProviderType, - ContentPart, - TextContentPart, - ImageUrlContentPart, - EmbeddingRequest, - EmbeddingResponse, -} from './types.js'; - -export { isVisionMessage, hasVisionContent, buildVisionMessage, getMessageText } from './types.js'; - -export { getLLM, createLLMProvider, setLLM, _resetLLM } from './factory.js'; -export { createFallbackChain } from './fallback.js'; -export { AzureOpenAIProvider, type AzureOpenAIConfig } from './providers/azure-openai.js'; -export { GeminiProvider, type GeminiConfig } from './providers/gemini.js'; -export { OpenAIProvider, type OpenAIConfig } from './providers/openai.js'; -export { PerplexityProvider, type PerplexityConfig } from './providers/perplexity.js'; -export { FallbackLLMProvider } from './providers/fallback.js'; -export { MockLLMProvider } from './providers/mock.js'; diff --git a/vendor/bytelyst/llm/src/providers/azure-openai.ts b/vendor/bytelyst/llm/src/providers/azure-openai.ts deleted file mode 100644 index badf172..0000000 --- a/vendor/bytelyst/llm/src/providers/azure-openai.ts +++ /dev/null @@ -1,226 +0,0 @@ -/** - * Azure OpenAI LLM provider. - * - * Uses Azure OpenAI REST API with api-key authentication. - * Reads config from AZURE_OPENAI_ENDPOINT, AZURE_OPENAI_KEY, AZURE_OPENAI_DEPLOYMENT. - * Supports text, vision (multipart content), streaming, and embeddings. - */ - -import type { - ChatCompletionRequest, - ChatCompletionResponse, - EmbeddingRequest, - EmbeddingResponse, - LLMProvider, -} from '../types.js'; - -export interface AzureOpenAIConfig { - endpoint: string; - apiKey: string; - deployment: string; - embeddingDeployment?: string; - apiVersion?: string; -} - -export class AzureOpenAIProvider implements LLMProvider { - private config: AzureOpenAIConfig; - - constructor(config?: Partial) { - this.config = { - endpoint: config?.endpoint || process.env.AZURE_OPENAI_ENDPOINT || '', - apiKey: config?.apiKey || process.env.AZURE_OPENAI_KEY || process.env.OPENAI_API_KEY || '', - deployment: - config?.deployment || - process.env.AZURE_OPENAI_DEPLOYMENT || - process.env.OPENAI_MODEL || - 'gpt-4o-mini', - embeddingDeployment: - config?.embeddingDeployment || - process.env.AZURE_OPENAI_EMBEDDING_DEPLOYMENT || - 'text-embedding-3-small', - apiVersion: config?.apiVersion || process.env.AZURE_OPENAI_API_VERSION || '2024-06-01', - }; - } - - isConfigured(): boolean { - return Boolean(this.config.endpoint && this.config.apiKey && this.config.deployment); - } - - private getBaseUrl(): string { - return this.config.endpoint.replace(/\/+$/, ''); - } - - private getChatUrl(): string { - const base = this.getBaseUrl(); - const deployment = encodeURIComponent(this.config.deployment); - const version = encodeURIComponent(this.config.apiVersion!); - return `${base}/openai/deployments/${deployment}/chat/completions?api-version=${version}`; - } - - private getHeaders(): Record { - return { - 'Content-Type': 'application/json', - 'api-key': this.config.apiKey, - }; - } - - async chatCompletion(req: ChatCompletionRequest): Promise { - if (!this.isConfigured()) { - throw new Error( - 'Azure OpenAI is not configured (missing AZURE_OPENAI_ENDPOINT or AZURE_OPENAI_KEY)' - ); - } - - const url = this.getChatUrl(); - - const body = { - messages: req.messages, - temperature: req.temperature, - max_tokens: req.maxTokens, - top_p: req.topP, - stop: req.stop, - response_format: req.responseFormat, - }; - - const response = await fetch(url, { - method: 'POST', - headers: this.getHeaders(), - body: JSON.stringify(body), - }); - - if (!response.ok) { - const text = await response.text(); - throw new Error(`Azure OpenAI error ${response.status}: ${text}`); - } - - const data = (await response.json()) as { - choices: Array<{ message: { content: string }; finish_reason: string }>; - model: string; - usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number }; - }; - - return { - content: data.choices[0]?.message?.content ?? '', - model: data.model, - finishReason: - (data.choices[0]?.finish_reason as ChatCompletionResponse['finishReason']) ?? null, - usage: { - promptTokens: data.usage.prompt_tokens, - completionTokens: data.usage.completion_tokens, - totalTokens: data.usage.total_tokens, - }, - }; - } - - async *chatCompletionStream(req: ChatCompletionRequest): AsyncIterable { - if (!this.isConfigured()) { - throw new Error( - 'Azure OpenAI is not configured (missing AZURE_OPENAI_ENDPOINT or AZURE_OPENAI_KEY)' - ); - } - - const url = this.getChatUrl(); - - const body = { - messages: req.messages, - temperature: req.temperature, - max_tokens: req.maxTokens, - top_p: req.topP, - stop: req.stop, - response_format: req.responseFormat, - stream: true, - }; - - const response = await fetch(url, { - method: 'POST', - headers: this.getHeaders(), - body: JSON.stringify(body), - }); - - if (!response.ok) { - const text = await response.text(); - throw new Error(`Azure OpenAI streaming error ${response.status}: ${text}`); - } - - if (!response.body) { - throw new Error('Azure OpenAI streaming: no response body'); - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split('\n'); - buffer = lines.pop() ?? ''; - - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed || !trimmed.startsWith('data: ')) continue; - const data = trimmed.slice(6); - if (data === '[DONE]') return; - try { - const parsed = JSON.parse(data) as { - choices: Array<{ delta: { content?: string } }>; - }; - const delta = parsed.choices?.[0]?.delta?.content; - if (delta) yield delta; - } catch { - // skip malformed SSE chunks - } - } - } - } finally { - reader.releaseLock(); - } - } - - async embed(req: EmbeddingRequest): Promise { - if (!this.isConfigured()) { - throw new Error( - 'Azure OpenAI is not configured (missing AZURE_OPENAI_ENDPOINT or AZURE_OPENAI_KEY)' - ); - } - - const base = this.getBaseUrl(); - const deployment = encodeURIComponent(this.config.embeddingDeployment!); - const version = encodeURIComponent(this.config.apiVersion!); - const url = `${base}/openai/deployments/${deployment}/embeddings?api-version=${version}`; - - const body = { - input: req.input, - }; - - const response = await fetch(url, { - method: 'POST', - headers: this.getHeaders(), - body: JSON.stringify(body), - }); - - if (!response.ok) { - const text = await response.text(); - throw new Error(`Azure OpenAI embedding error ${response.status}: ${text}`); - } - - const data = (await response.json()) as { - data: Array<{ embedding: number[]; index: number }>; - model: string; - usage: { prompt_tokens: number; total_tokens: number }; - }; - - return { - embeddings: data.data.sort((a, b) => a.index - b.index).map(d => d.embedding), - model: data.model, - usage: { - promptTokens: data.usage.prompt_tokens, - completionTokens: 0, - totalTokens: data.usage.total_tokens, - }, - }; - } -} diff --git a/vendor/bytelyst/llm/src/providers/fallback.ts b/vendor/bytelyst/llm/src/providers/fallback.ts deleted file mode 100644 index 24e199c..0000000 --- a/vendor/bytelyst/llm/src/providers/fallback.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * Fallback LLM provider. - * - * Tries each provider in order, falling back to the next on error or - * when a provider is not configured. Useful for resilient AI pipelines - * (e.g. perplexity → openai → gemini). - * - * Usage: - * const llm = new FallbackLLMProvider([ - * new PerplexityProvider(), - * new OpenAIProvider(), - * new GeminiProvider(), - * ]); - */ - -import type { ChatCompletionRequest, ChatCompletionResponse, LLMProvider } from '../types.js'; - -export class FallbackLLMProvider implements LLMProvider { - constructor(private readonly providers: LLMProvider[]) { - if (providers.length === 0) { - throw new Error('FallbackLLMProvider requires at least one provider'); - } - } - - isConfigured(): boolean { - return this.providers.some(p => p.isConfigured()); - } - - async chatCompletion(req: ChatCompletionRequest): Promise { - const errors: string[] = []; - - for (const provider of this.providers) { - if (!provider.isConfigured()) { - errors.push(`${provider.constructor.name}: not configured`); - continue; - } - try { - return await provider.chatCompletion(req); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - errors.push(`${provider.constructor.name}: ${msg}`); - } - } - - throw new Error(`All LLM providers failed:\n${errors.map(e => ` - ${e}`).join('\n')}`); - } -} diff --git a/vendor/bytelyst/llm/src/providers/gemini.ts b/vendor/bytelyst/llm/src/providers/gemini.ts deleted file mode 100644 index 194fbb7..0000000 --- a/vendor/bytelyst/llm/src/providers/gemini.ts +++ /dev/null @@ -1,122 +0,0 @@ -/** - * Google Gemini LLM provider. - * - * Uses Google's Generative Language API (not OpenAI-compatible). - * Reads config from GEMINI_API_KEY, GEMINI_MODEL. - */ - -import type { - ChatCompletionRequest, - ChatCompletionResponse, - ChatMessage, - LLMProvider, -} from '../types.js'; -import { getMessageText } from '../types.js'; - -export interface GeminiConfig { - apiKey: string; - model?: string; -} - -interface GeminiPart { - text: string; -} - -interface GeminiContent { - role: 'user' | 'model'; - parts: GeminiPart[]; -} - -export class GeminiProvider implements LLMProvider { - private config: GeminiConfig; - - constructor(config?: Partial) { - this.config = { - apiKey: config?.apiKey || process.env.GEMINI_API_KEY || '', - model: config?.model || process.env.GEMINI_MODEL || 'gemini-1.5-flash', - }; - } - - isConfigured(): boolean { - return Boolean(this.config.apiKey); - } - - async chatCompletion(req: ChatCompletionRequest): Promise { - if (!this.isConfigured()) { - throw new Error('Gemini is not configured (missing GEMINI_API_KEY)'); - } - - const model = req.model || this.config.model!; - const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:generateContent?key=${this.config.apiKey}`; - - const { systemInstruction, contents } = this.convertMessages(req.messages); - - const body: Record = { contents }; - if (systemInstruction) { - body.systemInstruction = { parts: [{ text: systemInstruction }] }; - } - if (req.temperature !== undefined) { - body.generationConfig = { temperature: req.temperature }; - } - - const response = await fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - - if (!response.ok) { - const text = await response.text(); - throw new Error(`Gemini error ${response.status}: ${text}`); - } - - const data = (await response.json()) as { - candidates: Array<{ - content: { parts: GeminiPart[] }; - finishReason: string; - }>; - usageMetadata?: { - promptTokenCount: number; - candidatesTokenCount: number; - totalTokenCount: number; - }; - }; - - const content = data.candidates[0]?.content?.parts?.map(p => p.text).join('') ?? ''; - const finishReason = data.candidates[0]?.finishReason; - - return { - content, - model, - finishReason: - finishReason === 'STOP' ? 'stop' : finishReason === 'MAX_TOKENS' ? 'length' : null, - usage: { - promptTokens: data.usageMetadata?.promptTokenCount ?? 0, - completionTokens: data.usageMetadata?.candidatesTokenCount ?? 0, - totalTokens: data.usageMetadata?.totalTokenCount ?? 0, - }, - }; - } - - private convertMessages(messages: ChatMessage[]): { - systemInstruction: string | null; - contents: GeminiContent[]; - } { - const systemMessages = messages.filter(m => m.role === 'system'); - const systemInstruction = systemMessages.map(m => getMessageText(m)).join('\n') || null; - - const contents: GeminiContent[] = messages - .filter(m => m.role !== 'system') - .map(m => ({ - role: (m.role === 'assistant' ? 'model' : 'user') as 'user' | 'model', - parts: [{ text: getMessageText(m) }], - })); - - // Gemini requires at least one user turn - if (contents.length === 0) { - contents.push({ role: 'user', parts: [{ text: '' }] }); - } - - return { systemInstruction, contents }; - } -} diff --git a/vendor/bytelyst/llm/src/providers/mock.ts b/vendor/bytelyst/llm/src/providers/mock.ts deleted file mode 100644 index ad624cf..0000000 --- a/vendor/bytelyst/llm/src/providers/mock.ts +++ /dev/null @@ -1,118 +0,0 @@ -/** - * Mock LLM provider — for testing. - * - * Returns pre-configured responses or a default echo response. - * Supports vision content, streaming, and embedding. - */ - -import type { - ChatCompletionRequest, - ChatCompletionResponse, - EmbeddingRequest, - EmbeddingResponse, - LLMProvider, -} from '../types.js'; -import { getMessageText } from '../types.js'; - -export class MockLLMProvider implements LLMProvider { - private responses: ChatCompletionResponse[] = []; - private embeddingResponses: EmbeddingResponse[] = []; - public calls: ChatCompletionRequest[] = []; - public embedCalls: EmbeddingRequest[] = []; - - constructor(responses?: ChatCompletionResponse[]) { - if (responses) this.responses = [...responses]; - } - - isConfigured(): boolean { - return true; - } - - /** Add a chat response to the queue. */ - addResponse(response: ChatCompletionResponse): void { - this.responses.push(response); - } - - /** Add an embedding response to the queue. */ - addEmbeddingResponse(response: EmbeddingResponse): void { - this.embeddingResponses.push(response); - } - - async chatCompletion(req: ChatCompletionRequest): Promise { - this.calls.push(req); - - if (this.responses.length > 0) { - return this.responses.shift()!; - } - - // Default echo response — handles both string and multipart content - const lastMessage = req.messages[req.messages.length - 1]; - const text = lastMessage ? getMessageText(lastMessage) : '(empty)'; - return { - content: `Mock response to: ${text}`, - model: req.model ?? 'mock-model', - finishReason: 'stop', - usage: { promptTokens: 10, completionTokens: 10, totalTokens: 20 }, - }; - } - - async *chatCompletionStream(req: ChatCompletionRequest): AsyncIterable { - this.calls.push(req); - - if (this.responses.length > 0) { - const resp = this.responses.shift()!; - // Yield word-by-word to simulate streaming - const words = resp.content.split(' '); - for (const word of words) { - yield word + ' '; - } - return; - } - - const lastMessage = req.messages[req.messages.length - 1]; - const text = lastMessage ? getMessageText(lastMessage) : '(empty)'; - const words = `Mock response to: ${text}`.split(' '); - for (const word of words) { - yield word + ' '; - } - } - - async embed(req: EmbeddingRequest): Promise { - this.embedCalls.push(req); - - if (this.embeddingResponses.length > 0) { - return this.embeddingResponses.shift()!; - } - - // Default: return deterministic pseudo-embeddings (dimension 8 for testing) - const inputs = Array.isArray(req.input) ? req.input : [req.input]; - const embeddings = inputs.map(text => { - // Simple hash-based deterministic vector for testing - const vec = new Array(8).fill(0); - for (let i = 0; i < text.length; i++) { - vec[i % 8] += text.charCodeAt(i) / 1000; - } - // Normalize - const mag = Math.sqrt(vec.reduce((sum, v) => sum + v * v, 0)) || 1; - return vec.map(v => v / mag); - }); - - return { - embeddings, - model: req.model ?? 'mock-embedding-model', - usage: { - promptTokens: inputs.join(' ').split(/\s+/).length, - completionTokens: 0, - totalTokens: inputs.join(' ').split(/\s+/).length, - }, - }; - } - - /** Reset call history and responses. */ - reset(): void { - this.calls = []; - this.embedCalls = []; - this.responses = []; - this.embeddingResponses = []; - } -} diff --git a/vendor/bytelyst/llm/src/providers/openai.ts b/vendor/bytelyst/llm/src/providers/openai.ts deleted file mode 100644 index a355c14..0000000 --- a/vendor/bytelyst/llm/src/providers/openai.ts +++ /dev/null @@ -1,205 +0,0 @@ -/** - * OpenAI direct LLM provider. - * - * Uses OpenAI REST API with Bearer token authentication. - * Reads config from OPENAI_API_KEY, OPENAI_BASE_URL, OPENAI_MODEL. - * Supports text, vision (multipart content), streaming, and embeddings. - */ - -import type { - ChatCompletionRequest, - ChatCompletionResponse, - EmbeddingRequest, - EmbeddingResponse, - LLMProvider, -} from '../types.js'; - -export interface OpenAIConfig { - apiKey: string; - baseUrl?: string; - model?: string; - embeddingModel?: string; -} - -export class OpenAIProvider implements LLMProvider { - private config: OpenAIConfig; - - constructor(config?: Partial) { - this.config = { - apiKey: config?.apiKey || process.env.OPENAI_API_KEY || '', - baseUrl: config?.baseUrl || process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1', - model: config?.model || process.env.OPENAI_MODEL || 'gpt-4o-mini', - embeddingModel: - config?.embeddingModel || process.env.LLM_EMBEDDING_MODEL || 'text-embedding-3-small', - }; - } - - isConfigured(): boolean { - return Boolean(this.config.apiKey); - } - - private getBaseUrl(): string { - return this.config.baseUrl!.replace(/\/+$/, ''); - } - - private getHeaders(): Record { - return { - 'Content-Type': 'application/json', - Authorization: `Bearer ${this.config.apiKey}`, - }; - } - - async chatCompletion(req: ChatCompletionRequest): Promise { - if (!this.isConfigured()) { - throw new Error('OpenAI is not configured (missing OPENAI_API_KEY)'); - } - - const url = `${this.getBaseUrl()}/chat/completions`; - - const body = { - model: req.model || this.config.model, - messages: req.messages, - temperature: req.temperature, - max_tokens: req.maxTokens, - top_p: req.topP, - stop: req.stop, - response_format: req.responseFormat, - }; - - const response = await fetch(url, { - method: 'POST', - headers: this.getHeaders(), - body: JSON.stringify(body), - }); - - if (!response.ok) { - const text = await response.text(); - throw new Error(`OpenAI error ${response.status}: ${text}`); - } - - const data = (await response.json()) as { - choices: Array<{ message: { content: string }; finish_reason: string }>; - model: string; - usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number }; - }; - - return { - content: data.choices[0]?.message?.content ?? '', - model: data.model, - finishReason: - (data.choices[0]?.finish_reason as ChatCompletionResponse['finishReason']) ?? null, - usage: { - promptTokens: data.usage.prompt_tokens, - completionTokens: data.usage.completion_tokens, - totalTokens: data.usage.total_tokens, - }, - }; - } - - async *chatCompletionStream(req: ChatCompletionRequest): AsyncIterable { - if (!this.isConfigured()) { - throw new Error('OpenAI is not configured (missing OPENAI_API_KEY)'); - } - - const url = `${this.getBaseUrl()}/chat/completions`; - - const body = { - model: req.model || this.config.model, - messages: req.messages, - temperature: req.temperature, - max_tokens: req.maxTokens, - top_p: req.topP, - stop: req.stop, - response_format: req.responseFormat, - stream: true, - }; - - const response = await fetch(url, { - method: 'POST', - headers: this.getHeaders(), - body: JSON.stringify(body), - }); - - if (!response.ok) { - const text = await response.text(); - throw new Error(`OpenAI streaming error ${response.status}: ${text}`); - } - - if (!response.body) { - throw new Error('OpenAI streaming: no response body'); - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split('\n'); - buffer = lines.pop() ?? ''; - - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed || !trimmed.startsWith('data: ')) continue; - const data = trimmed.slice(6); - if (data === '[DONE]') return; - try { - const parsed = JSON.parse(data) as { - choices: Array<{ delta: { content?: string } }>; - }; - const delta = parsed.choices?.[0]?.delta?.content; - if (delta) yield delta; - } catch { - // skip malformed SSE chunks - } - } - } - } finally { - reader.releaseLock(); - } - } - - async embed(req: EmbeddingRequest): Promise { - if (!this.isConfigured()) { - throw new Error('OpenAI is not configured (missing OPENAI_API_KEY)'); - } - - const url = `${this.getBaseUrl()}/embeddings`; - - const body = { - model: req.model || this.config.embeddingModel, - input: req.input, - }; - - const response = await fetch(url, { - method: 'POST', - headers: this.getHeaders(), - body: JSON.stringify(body), - }); - - if (!response.ok) { - const text = await response.text(); - throw new Error(`OpenAI embedding error ${response.status}: ${text}`); - } - - const data = (await response.json()) as { - data: Array<{ embedding: number[]; index: number }>; - model: string; - usage: { prompt_tokens: number; total_tokens: number }; - }; - - return { - embeddings: data.data.sort((a, b) => a.index - b.index).map(d => d.embedding), - model: data.model, - usage: { - promptTokens: data.usage.prompt_tokens, - completionTokens: 0, - totalTokens: data.usage.total_tokens, - }, - }; - } -} diff --git a/vendor/bytelyst/llm/src/providers/perplexity.ts b/vendor/bytelyst/llm/src/providers/perplexity.ts deleted file mode 100644 index b778311..0000000 --- a/vendor/bytelyst/llm/src/providers/perplexity.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * Perplexity LLM provider. - * - * Uses Perplexity's OpenAI-compatible API with real-time web search. - * Reads config from PERPLEXITY_API_KEY, PERPLEXITY_MODEL. - */ - -import type { ChatCompletionRequest, ChatCompletionResponse, LLMProvider } from '../types.js'; - -export interface PerplexityConfig { - apiKey: string; - model?: string; -} - -export class PerplexityProvider implements LLMProvider { - private config: PerplexityConfig; - - constructor(config?: Partial) { - this.config = { - apiKey: config?.apiKey || process.env.PERPLEXITY_API_KEY || '', - model: config?.model || process.env.PERPLEXITY_MODEL || 'sonar', - }; - } - - isConfigured(): boolean { - return Boolean(this.config.apiKey); - } - - async chatCompletion(req: ChatCompletionRequest): Promise { - if (!this.isConfigured()) { - throw new Error('Perplexity is not configured (missing PERPLEXITY_API_KEY)'); - } - - const body = { - model: req.model || this.config.model, - messages: req.messages, - temperature: req.temperature, - max_tokens: req.maxTokens, - top_p: req.topP, - }; - - const response = await fetch('https://api.perplexity.ai/chat/completions', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${this.config.apiKey}`, - }, - body: JSON.stringify(body), - }); - - if (!response.ok) { - const text = await response.text(); - throw new Error(`Perplexity error ${response.status}: ${text}`); - } - - const data = (await response.json()) as { - choices: Array<{ message: { content: string }; finish_reason: string }>; - model: string; - usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number }; - }; - - return { - content: data.choices[0]?.message?.content ?? '', - model: data.model, - finishReason: - (data.choices[0]?.finish_reason as ChatCompletionResponse['finishReason']) ?? null, - usage: { - promptTokens: data.usage?.prompt_tokens ?? 0, - completionTokens: data.usage?.completion_tokens ?? 0, - totalTokens: data.usage?.total_tokens ?? 0, - }, - }; - } -} diff --git a/vendor/bytelyst/llm/src/testing.ts b/vendor/bytelyst/llm/src/testing.ts deleted file mode 100644 index 18d2c6c..0000000 --- a/vendor/bytelyst/llm/src/testing.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Test helpers for @bytelyst/llm. - */ - -import { setLLM, _resetLLM } from './factory.js'; -import { MockLLMProvider } from './providers/mock.js'; - -export function setTestLLMProvider(): MockLLMProvider { - const provider = new MockLLMProvider(); - setLLM(provider); - return provider; -} - -export function resetTestLLM(): void { - _resetLLM(); -} - -export { MockLLMProvider } from './providers/mock.js'; diff --git a/vendor/bytelyst/llm/src/types.ts b/vendor/bytelyst/llm/src/types.ts deleted file mode 100644 index c5eb284..0000000 --- a/vendor/bytelyst/llm/src/types.ts +++ /dev/null @@ -1,131 +0,0 @@ -/** - * Cloud-agnostic LLM provider interfaces. - * - * Provides a unified chat completion API that works with - * Azure OpenAI, OpenAI direct, Perplexity, Gemini, or mock providers. - * Supports text, vision (image), and embedding modalities. - */ - -// ── Content Parts (vision support) ──────────────────────────────── - -/** A text segment within a multipart message. */ -export interface TextContentPart { - type: 'text'; - text: string; -} - -/** An image URL segment within a multipart message (vision). */ -export interface ImageUrlContentPart { - type: 'image_url'; - image_url: { url: string; detail?: 'auto' | 'low' | 'high' }; -} - -/** A single part of a multipart message — text or image. */ -export type ContentPart = TextContentPart | ImageUrlContentPart; - -// ── Chat Messages ───────────────────────────────────────────────── - -export interface ChatMessage { - role: 'system' | 'user' | 'assistant' | 'tool'; - /** Text string OR multipart content array (for vision messages). */ - content: string | ContentPart[]; - name?: string; -} - -// ── Provider Interface ──────────────────────────────────────────── - -export interface LLMProvider { - /** Send a chat completion request. */ - chatCompletion(req: ChatCompletionRequest): Promise; - - /** Stream a chat completion response — yields content delta strings. */ - chatCompletionStream?(req: ChatCompletionRequest): AsyncIterable; - - /** Generate vector embeddings for input text(s). */ - embed?(req: EmbeddingRequest): Promise; - - /** Check if the provider is configured with valid credentials. */ - isConfigured(): boolean; -} - -// ── Chat Completion ─────────────────────────────────────────────── - -export interface ChatCompletionRequest { - messages: ChatMessage[]; - model?: string; - temperature?: number; - maxTokens?: number; - topP?: number; - stop?: string[]; - responseFormat?: { type: 'text' | 'json_object' }; -} - -export interface ChatCompletionResponse { - content: string; - model: string; - usage: TokenUsage; - finishReason: 'stop' | 'length' | 'content_filter' | 'tool_calls' | null; -} - -// ── Embeddings ──────────────────────────────────────────────────── - -export interface EmbeddingRequest { - /** One or more texts to embed. */ - input: string | string[]; - /** Override the default embedding model. */ - model?: string; -} - -export interface EmbeddingResponse { - /** One embedding vector per input string. */ - embeddings: number[][]; - model: string; - usage: TokenUsage; -} - -// ── Shared ──────────────────────────────────────────────────────── - -export interface TokenUsage { - promptTokens: number; - completionTokens: number; - totalTokens: number; -} - -export type LLMProviderType = 'azure' | 'openai' | 'perplexity' | 'gemini' | 'fallback' | 'mock'; - -// ── Helpers ─────────────────────────────────────────────────────── - -/** Type guard: does this message contain image content parts? */ -export function isVisionMessage(msg: ChatMessage): boolean { - if (typeof msg.content === 'string') return false; - return msg.content.some(p => p.type === 'image_url'); -} - -/** Does the request contain any vision (image) messages? */ -export function hasVisionContent(req: ChatCompletionRequest): boolean { - return req.messages.some(isVisionMessage); -} - -/** Convenience builder for a user message with text + image. */ -export function buildVisionMessage( - text: string, - imageUrl: string, - detail: 'auto' | 'low' | 'high' = 'auto' -): ChatMessage { - return { - role: 'user', - content: [ - { type: 'text', text }, - { type: 'image_url', image_url: { url: imageUrl, detail } }, - ], - }; -} - -/** Extract plain text from a ChatMessage content (string or multipart). */ -export function getMessageText(msg: ChatMessage): string { - if (typeof msg.content === 'string') return msg.content; - return msg.content - .filter((p): p is TextContentPart => p.type === 'text') - .map(p => p.text) - .join('\n'); -} diff --git a/vendor/bytelyst/llm/tsconfig.json b/vendor/bytelyst/llm/tsconfig.json deleted file mode 100644 index 5edad81..0000000 --- a/vendor/bytelyst/llm/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/vendor/bytelyst/logger/package.json b/vendor/bytelyst/logger/package.json deleted file mode 100644 index 20446d0..0000000 --- a/vendor/bytelyst/logger/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "@bytelyst/logger", - "version": "0.1.5", - "description": "Structured logger factory for Next.js dashboards and Node.js services", - "type": "module", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/logger/src/__tests__/logger.test.ts b/vendor/bytelyst/logger/src/__tests__/logger.test.ts deleted file mode 100644 index 4d44a77..0000000 --- a/vendor/bytelyst/logger/src/__tests__/logger.test.ts +++ /dev/null @@ -1,191 +0,0 @@ -/** - * Tests for @bytelyst/logger package — createLogger + structured logging. - */ - -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { createLogger } from '../logger.js'; -import type { Logger } from '../types.js'; - -describe('createLogger', () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let stdoutSpy: any; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let stderrSpy: any; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let consoleSpy: any; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let consoleErrorSpy: any; - - beforeEach(() => { - stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); - stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); - consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('returns a Logger with all four log methods', () => { - const log = createLogger({ service: 'test-service' }); - expect(typeof log.error).toBe('function'); - expect(typeof log.warn).toBe('function'); - expect(typeof log.info).toBe('function'); - expect(typeof log.debug).toBe('function'); - }); - - describe('production mode (isDev: false)', () => { - let log: Logger; - - beforeEach(() => { - log = createLogger({ service: 'platform-service', isDev: false }); - }); - - it('info writes structured JSON to stdout', () => { - log.info('Server started', { port: 4003 }); - - expect(stdoutSpy).toHaveBeenCalledTimes(1); - const output = stdoutSpy.mock.calls[0][0] as string; - const parsed = JSON.parse(output.trim()); - - expect(parsed.level).toBe('info'); - expect(parsed.message).toBe('Server started'); - expect(parsed.service).toBe('platform-service'); - expect(parsed.port).toBe(4003); - expect(parsed.timestamp).toBeDefined(); - }); - - it('error writes structured JSON to stderr', () => { - log.error('Connection failed', new Error('timeout')); - - expect(stderrSpy).toHaveBeenCalledTimes(1); - const output = stderrSpy.mock.calls[0][0] as string; - const parsed = JSON.parse(output.trim()); - - expect(parsed.level).toBe('error'); - expect(parsed.message).toBe('Connection failed'); - expect(parsed.error).toBe('timeout'); - expect(parsed.stack).toBeDefined(); - expect(parsed.service).toBe('platform-service'); - }); - - it('warn writes structured JSON to stderr', () => { - log.warn('High memory usage', { usageMb: 512 }); - - expect(stderrSpy).toHaveBeenCalledTimes(1); - const output = stderrSpy.mock.calls[0][0] as string; - const parsed = JSON.parse(output.trim()); - - expect(parsed.level).toBe('warn'); - expect(parsed.message).toBe('High memory usage'); - expect(parsed.usageMb).toBe(512); - }); - - it('debug does NOT emit in production mode', () => { - log.debug('Debug info'); - - expect(stdoutSpy).not.toHaveBeenCalled(); - expect(stderrSpy).not.toHaveBeenCalled(); - }); - - it('error handles non-Error objects', () => { - log.error('Strange error', 'just a string'); - - const output = stderrSpy.mock.calls[0][0] as string; - const parsed = JSON.parse(output.trim()); - expect(parsed.error).toBe('just a string'); - expect(parsed.stack).toBeUndefined(); - }); - - it('error handles undefined error', () => { - log.error('No error object'); - - const output = stderrSpy.mock.calls[0][0] as string; - const parsed = JSON.parse(output.trim()); - expect(parsed.error).toBeUndefined(); - }); - - it('includes timestamp in ISO format', () => { - log.info('Timestamp test'); - - const output = stdoutSpy.mock.calls[0][0] as string; - const parsed = JSON.parse(output.trim()); - expect(parsed.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/); - }); - - it('appends newline to output', () => { - log.info('Newline check'); - - const output = stdoutSpy.mock.calls[0][0] as string; - expect(output.endsWith('\n')).toBe(true); - }); - }); - - describe('dev mode (isDev: true)', () => { - let log: Logger; - - beforeEach(() => { - log = createLogger({ service: 'dev-service', isDev: true }); - }); - - it('info uses console.log with prefix', () => { - log.info('Dev message'); - - expect(consoleSpy).toHaveBeenCalledTimes(1); - const output = consoleSpy.mock.calls[0][0] as string; - expect(output).toContain('[INFO]'); - expect(output).toContain('Dev message'); - }); - - it('error uses console.error with prefix', () => { - log.error('Dev error', new Error('boom')); - - expect(consoleErrorSpy).toHaveBeenCalledTimes(1); - const output = consoleErrorSpy.mock.calls[0][0] as string; - expect(output).toContain('[ERROR]'); - expect(output).toContain('Dev error'); - expect(output).toContain('boom'); - }); - - it('warn uses console.error with prefix', () => { - log.warn('Dev warning'); - - expect(consoleErrorSpy).toHaveBeenCalledTimes(1); - const output = consoleErrorSpy.mock.calls[0][0] as string; - expect(output).toContain('[WARN]'); - expect(output).toContain('Dev warning'); - }); - - it('debug emits in dev mode', () => { - log.debug('Debug details', { key: 'value' }); - - expect(consoleSpy).toHaveBeenCalledTimes(1); - const output = consoleSpy.mock.calls[0][0] as string; - expect(output).toContain('[DEBUG]'); - expect(output).toContain('Debug details'); - }); - - it('includes extra data in dev mode', () => { - log.info('With extras', { requestId: 'abc', userId: 'u1' }); - - const output = consoleSpy.mock.calls[0][0] as string; - expect(output).toContain('requestId'); - }); - }); - - describe('config defaults', () => { - it('defaults to dev mode when NODE_ENV is not production', () => { - const originalEnv = process.env.NODE_ENV; - process.env.NODE_ENV = 'development'; - - const log = createLogger({ service: 'test' }); - log.debug('Should emit in dev'); - - // Debug should emit in non-production - expect(consoleSpy.mock.calls.length + stdoutSpy.mock.calls.length).toBeGreaterThan(0); - - process.env.NODE_ENV = originalEnv; - }); - }); -}); diff --git a/vendor/bytelyst/logger/src/index.ts b/vendor/bytelyst/logger/src/index.ts deleted file mode 100644 index dcd5596..0000000 --- a/vendor/bytelyst/logger/src/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { createLogger } from './logger.js'; -export type { Logger, LoggerConfig, LogLevel, LogPayload } from './types.js'; diff --git a/vendor/bytelyst/logger/src/logger.ts b/vendor/bytelyst/logger/src/logger.ts deleted file mode 100644 index 6e3d034..0000000 --- a/vendor/bytelyst/logger/src/logger.ts +++ /dev/null @@ -1,83 +0,0 @@ -import type { LogLevel, LogPayload, Logger, LoggerConfig } from './types.js'; - -function formatPayload( - service: string, - level: LogLevel, - message: string, - error?: unknown, - extra?: Record -): LogPayload { - const payload: LogPayload = { - level, - message, - timestamp: new Date().toISOString(), - service, - ...extra, - }; - - if (error instanceof Error) { - payload.error = error.message; - payload.stack = error.stack; - } else if (error !== undefined) { - payload.error = String(error); - } - - return payload; -} - -function emit(payload: LogPayload, isDev: boolean): void { - if (isDev) { - const { level, message, error: err, ...rest } = payload; - const prefix = `[${level.toUpperCase()}]`; - const extras = Object.keys(rest).length > 2 ? ` ${JSON.stringify(rest)}` : ''; - const errStr = err ? ` — ${err}` : ''; - - if (level === 'error' || level === 'warn') { - // eslint-disable-next-line no-console - console.error(`${prefix} ${message}${errStr}${extras}`); - } else { - // eslint-disable-next-line no-console - console.log(`${prefix} ${message}${extras}`); - } - } else { - const out = JSON.stringify(payload); - if (payload.level === 'error' || payload.level === 'warn') { - process.stderr.write(out + '\n'); - } else { - process.stdout.write(out + '\n'); - } - } -} - -/** - * Create a structured logger for a service or dashboard. - * - * @example - * ```ts - * import { createLogger } from "@bytelyst/logger"; - * const log = createLogger({ service: "admin-dashboard" }); - * log.error("Login failed", error, { userId }); - * log.info("Seed complete", { containers: 8 }); - * ``` - */ -export function createLogger(config: LoggerConfig): Logger { - const { service } = config; - const isDev = config.isDev ?? process.env.NODE_ENV !== 'production'; - - return { - error(message: string, error?: unknown, extra?: Record): void { - emit(formatPayload(service, 'error', message, error, extra), isDev); - }, - warn(message: string, extra?: Record): void { - emit(formatPayload(service, 'warn', message, undefined, extra), isDev); - }, - info(message: string, extra?: Record): void { - emit(formatPayload(service, 'info', message, undefined, extra), isDev); - }, - debug(message: string, extra?: Record): void { - if (isDev) { - emit(formatPayload(service, 'debug', message, undefined, extra), isDev); - } - }, - }; -} diff --git a/vendor/bytelyst/logger/src/types.ts b/vendor/bytelyst/logger/src/types.ts deleted file mode 100644 index eb32ef9..0000000 --- a/vendor/bytelyst/logger/src/types.ts +++ /dev/null @@ -1,25 +0,0 @@ -export type LogLevel = 'info' | 'warn' | 'error' | 'debug'; - -export interface LogPayload { - level: LogLevel; - message: string; - timestamp: string; - service: string; - error?: string; - stack?: string; - [key: string]: unknown; -} - -export interface LoggerConfig { - /** Service name included in every log entry (e.g. "admin-dashboard", "billing-service") */ - service: string; - /** Override NODE_ENV detection for dev/prod mode. Default: reads process.env.NODE_ENV */ - isDev?: boolean; -} - -export interface Logger { - error(message: string, error?: unknown, extra?: Record): void; - warn(message: string, extra?: Record): void; - info(message: string, extra?: Record): void; - debug(message: string, extra?: Record): void; -} diff --git a/vendor/bytelyst/logger/tsconfig.json b/vendor/bytelyst/logger/tsconfig.json deleted file mode 100644 index 5a24989..0000000 --- a/vendor/bytelyst/logger/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src"] -} diff --git a/vendor/bytelyst/marketplace-client/package.json b/vendor/bytelyst/marketplace-client/package.json deleted file mode 100644 index 4036336..0000000 --- a/vendor/bytelyst/marketplace-client/package.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "@bytelyst/marketplace-client", - "version": "0.1.5", - "type": "module", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "scripts": { - "build": "tsc" - }, - "devDependencies": { - "typescript": "^5.7.3" - } -} diff --git a/vendor/bytelyst/marketplace-client/src/client.test.ts b/vendor/bytelyst/marketplace-client/src/client.test.ts deleted file mode 100644 index b1b80ef..0000000 --- a/vendor/bytelyst/marketplace-client/src/client.test.ts +++ /dev/null @@ -1,282 +0,0 @@ -import { describe, it, expect, vi, afterEach } from 'vitest'; -import { createMarketplaceClient } from './client.js'; -import type { - MarketplaceListingDoc, - MarketplaceReviewDoc, - MarketplaceInstallDoc, -} from './types.js'; - -const baseConfig = { - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - getAccessToken: () => 'test-token', -}; - -function mockListing(overrides?: Partial): MarketplaceListingDoc { - return { - id: 'lst_1', - productId: 'testapp', - templateType: 'fasting_protocol', - authorId: 'user-1', - authorName: 'Alice', - title: '16:8 Protocol', - shortDescription: 'Classic intermittent fasting', - description: 'A detailed 16:8 fasting protocol.', - tags: ['fasting', 'beginner'], - category: 'protocols', - payload: { hours: 16, breakHours: 8 }, - pricingModel: 'free', - priceInCents: 0, - certificationStatus: 'approved', - installCount: 100, - reviewCount: 10, - averageRating: 4.5, - visibility: 'public', - featured: false, - version: '1.0.0', - createdAt: '2026-01-01T00:00:00Z', - updatedAt: '2026-01-01T00:00:00Z', - ...overrides, - }; -} - -function mockReview(overrides?: Partial): MarketplaceReviewDoc { - return { - id: 'rev_1', - listingId: 'lst_1', - productId: 'testapp', - authorId: 'user-2', - rating: 5, - title: 'Great protocol!', - body: 'Really works well for me.', - verified: true, - createdAt: '2026-01-01T00:00:00Z', - ...overrides, - }; -} - -function mockInstall(overrides?: Partial): MarketplaceInstallDoc { - return { - id: 'inst_1', - listingId: 'lst_1', - productId: 'testapp', - userId: 'user-2', - version: '1.0.0', - installedAt: '2026-01-01T00:00:00Z', - uninstalledAt: null, - ...overrides, - }; -} - -describe('createMarketplaceClient', () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('should list listings', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ listings: [mockListing()], total: 1 }), - }) - ); - const client = createMarketplaceClient(baseConfig); - const result = await client.listListings({ category: 'protocols' }); - expect(result.listings).toHaveLength(1); - expect(result.total).toBe(1); - - const fetchMock = globalThis.fetch as ReturnType; - const url = fetchMock.mock.calls[0][0] as string; - expect(url).toContain('category=protocols'); - }); - - it('should get a listing by id', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockListing()), - }) - ); - const client = createMarketplaceClient(baseConfig); - const result = await client.getListing('lst_1'); - expect(result.title).toBe('16:8 Protocol'); - }); - - it('should create a listing', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockListing()), - }) - ); - const client = createMarketplaceClient(baseConfig); - const result = await client.createListing({ - templateType: 'fasting_protocol', - title: '16:8 Protocol', - shortDescription: 'Classic', - description: 'Detailed', - category: 'protocols', - payload: { hours: 16 }, - }); - expect(result.id).toBe('lst_1'); - - const fetchMock = globalThis.fetch as ReturnType; - expect(fetchMock).toHaveBeenCalledWith( - expect.stringContaining('/marketplace/listings'), - expect.objectContaining({ method: 'POST' }) - ); - }); - - it('should submit for certification', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockListing({ certificationStatus: 'submitted' })), - }) - ); - const client = createMarketplaceClient(baseConfig); - const result = await client.submitForCertification('lst_1', 'Ready for review'); - expect(result.certificationStatus).toBe('submitted'); - }); - - it('should install a listing', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockInstall()), - }) - ); - const client = createMarketplaceClient(baseConfig); - const result = await client.installListing('lst_1'); - expect(result.listingId).toBe('lst_1'); - }); - - it('should list my installs', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve([mockInstall()]), - }) - ); - const client = createMarketplaceClient(baseConfig); - const result = await client.listMyInstalls(); - expect(result).toHaveLength(1); - }); - - it('should list reviews', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve([mockReview()]), - }) - ); - const client = createMarketplaceClient(baseConfig); - const result = await client.listReviews('lst_1'); - expect(result).toHaveLength(1); - expect(result[0].rating).toBe(5); - }); - - it('should create a review', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockReview()), - }) - ); - const client = createMarketplaceClient(baseConfig); - const result = await client.createReview('lst_1', { - rating: 5, - title: 'Great!', - body: 'Love it.', - }); - expect(result.rating).toBe(5); - }); - - it('should update a listing', async () => { - const updated = mockListing({ title: 'Updated Title' }); - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve(updated), - }) - ); - const client = createMarketplaceClient(baseConfig); - const result = await client.updateListing('lst_1', { title: 'Updated Title' }); - expect(result.title).toBe('Updated Title'); - - const fetchMock = globalThis.fetch as ReturnType; - expect(fetchMock).toHaveBeenCalledWith( - 'http://localhost:4003/api/marketplace/listings/lst_1', - expect.objectContaining({ method: 'PATCH' }) - ); - }); - - it('should uninstall a listing', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - }) - ); - const client = createMarketplaceClient(baseConfig); - await expect(client.uninstallListing('lst_1')).resolves.toBeUndefined(); - - const fetchMock = globalThis.fetch as ReturnType; - expect(fetchMock).toHaveBeenCalledWith( - 'http://localhost:4003/api/marketplace/listings/lst_1/uninstall', - expect.objectContaining({ method: 'POST' }) - ); - }); - - it('should report a listing', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - }) - ); - const client = createMarketplaceClient(baseConfig); - await expect( - client.reportListing('lst_1', { reason: 'spam', details: 'Looks like spam' }) - ).resolves.toBeUndefined(); - }); - - it('should throw on non-ok response', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: false, - status: 500, - }) - ); - const client = createMarketplaceClient(baseConfig); - await expect(client.getListing('lst_1')).rejects.toThrow('getListing failed: 500'); - }); - - it('should send correct headers', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ listings: [], total: 0 }), - }) - ); - const client = createMarketplaceClient(baseConfig); - await client.listListings(); - - const fetchMock = globalThis.fetch as ReturnType; - const callHeaders = fetchMock.mock.calls[0][1].headers as Record; - expect(callHeaders['x-product-id']).toBe('testapp'); - expect(callHeaders['Authorization']).toBe('Bearer test-token'); - expect(callHeaders['x-request-id']).toBeDefined(); - }); -}); diff --git a/vendor/bytelyst/marketplace-client/src/client.ts b/vendor/bytelyst/marketplace-client/src/client.ts deleted file mode 100644 index bf3a63e..0000000 --- a/vendor/bytelyst/marketplace-client/src/client.ts +++ /dev/null @@ -1,219 +0,0 @@ -/** - * Browser/React Native-safe marketplace client for platform-service. - * - * Wraps platform-service /marketplace/* endpoints. - * No Node.js dependencies — uses globalThis.fetch. - */ - -import type { - CreateListingInput, - MarketplaceClient, - MarketplaceClientConfig, - MarketplaceInstallDoc, - MarketplaceListingDoc, - MarketplaceReviewDoc, -} from './types.js'; - -function generateRequestId(): string { - return typeof globalThis.crypto?.randomUUID === 'function' - ? globalThis.crypto.randomUUID() - : `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; -} - -export function createMarketplaceClient(config: MarketplaceClientConfig): MarketplaceClient { - const { baseUrl, productId, getAccessToken } = config; - - function headers(): Record { - const h: Record = { - 'Content-Type': 'application/json', - 'x-product-id': productId, - 'x-request-id': generateRequestId(), - }; - const token = getAccessToken(); - if (token) h['Authorization'] = `Bearer ${token}`; - return h; - } - - // ── Listings ────────────────────────────────────── - - async function listListings(query?: { - templateType?: string; - category?: string; - tags?: string; - pricingModel?: string; - sortBy?: string; - q?: string; - limit?: number; - offset?: number; - }): Promise<{ listings: MarketplaceListingDoc[]; total: number }> { - const params = new URLSearchParams(); - if (query?.templateType) params.set('templateType', query.templateType); - if (query?.category) params.set('category', query.category); - if (query?.tags) params.set('tags', query.tags); - if (query?.pricingModel) params.set('pricingModel', query.pricingModel); - if (query?.sortBy) params.set('sortBy', query.sortBy); - if (query?.q) params.set('q', query.q); - if (query?.limit) params.set('limit', String(query.limit)); - if (query?.offset) params.set('offset', String(query.offset)); - const qs = params.toString(); - const url = qs ? `${baseUrl}/marketplace/listings?${qs}` : `${baseUrl}/marketplace/listings`; - const res = await globalThis.fetch(url, { headers: headers() }); - if (!res.ok) throw new Error(`listListings failed: ${res.status}`); - return (await res.json()) as { listings: MarketplaceListingDoc[]; total: number }; - } - - async function getListing(id: string): Promise { - const res = await globalThis.fetch( - `${baseUrl}/marketplace/listings/${encodeURIComponent(id)}`, - { headers: headers() } - ); - if (!res.ok) throw new Error(`getListing failed: ${res.status}`); - return (await res.json()) as MarketplaceListingDoc; - } - - async function createListing(input: CreateListingInput): Promise { - const res = await globalThis.fetch(`${baseUrl}/marketplace/listings`, { - method: 'POST', - headers: headers(), - body: JSON.stringify(input), - }); - if (!res.ok) throw new Error(`createListing failed: ${res.status}`); - return (await res.json()) as MarketplaceListingDoc; - } - - async function updateListing( - id: string, - updates: Partial - ): Promise { - const res = await globalThis.fetch( - `${baseUrl}/marketplace/listings/${encodeURIComponent(id)}`, - { - method: 'PATCH', - headers: headers(), - body: JSON.stringify(updates), - } - ); - if (!res.ok) throw new Error(`updateListing failed: ${res.status}`); - return (await res.json()) as MarketplaceListingDoc; - } - - async function submitForCertification( - id: string, - notes?: string - ): Promise { - const res = await globalThis.fetch( - `${baseUrl}/marketplace/listings/${encodeURIComponent(id)}/submit`, - { - method: 'POST', - headers: headers(), - body: JSON.stringify({ notes }), - } - ); - if (!res.ok) throw new Error(`submitForCertification failed: ${res.status}`); - return (await res.json()) as MarketplaceListingDoc; - } - - // ── Installs ────────────────────────────────────── - - async function installListing(listingId: string): Promise { - const res = await globalThis.fetch( - `${baseUrl}/marketplace/listings/${encodeURIComponent(listingId)}/install`, - { - method: 'POST', - headers: headers(), - } - ); - if (!res.ok) throw new Error(`installListing failed: ${res.status}`); - return (await res.json()) as MarketplaceInstallDoc; - } - - async function uninstallListing(listingId: string): Promise { - const res = await globalThis.fetch( - `${baseUrl}/marketplace/listings/${encodeURIComponent(listingId)}/uninstall`, - { - method: 'POST', - headers: headers(), - } - ); - if (!res.ok) throw new Error(`uninstallListing failed: ${res.status}`); - } - - async function listMyInstalls(query?: { - limit?: number; - offset?: number; - }): Promise { - const params = new URLSearchParams(); - if (query?.limit) params.set('limit', String(query.limit)); - if (query?.offset) params.set('offset', String(query.offset)); - const qs = params.toString(); - const url = qs ? `${baseUrl}/marketplace/installs?${qs}` : `${baseUrl}/marketplace/installs`; - const res = await globalThis.fetch(url, { headers: headers() }); - if (!res.ok) throw new Error(`listMyInstalls failed: ${res.status}`); - return (await res.json()) as MarketplaceInstallDoc[]; - } - - // ── Reviews ─────────────────────────────────────── - - async function listReviews( - listingId: string, - query?: { sortBy?: string; limit?: number } - ): Promise { - const params = new URLSearchParams(); - if (query?.sortBy) params.set('sortBy', query.sortBy); - if (query?.limit) params.set('limit', String(query.limit)); - const qs = params.toString(); - const url = qs - ? `${baseUrl}/marketplace/listings/${encodeURIComponent(listingId)}/reviews?${qs}` - : `${baseUrl}/marketplace/listings/${encodeURIComponent(listingId)}/reviews`; - const res = await globalThis.fetch(url, { headers: headers() }); - if (!res.ok) throw new Error(`listReviews failed: ${res.status}`); - return (await res.json()) as MarketplaceReviewDoc[]; - } - - async function createReview( - listingId: string, - input: { rating: number; title: string; body: string } - ): Promise { - const res = await globalThis.fetch( - `${baseUrl}/marketplace/listings/${encodeURIComponent(listingId)}/reviews`, - { - method: 'POST', - headers: headers(), - body: JSON.stringify(input), - } - ); - if (!res.ok) throw new Error(`createReview failed: ${res.status}`); - return (await res.json()) as MarketplaceReviewDoc; - } - - // ── Reports ─────────────────────────────────────── - - async function reportListing( - listingId: string, - input: { reason: string; details: string } - ): Promise { - const res = await globalThis.fetch( - `${baseUrl}/marketplace/listings/${encodeURIComponent(listingId)}/report`, - { - method: 'POST', - headers: headers(), - body: JSON.stringify(input), - } - ); - if (!res.ok) throw new Error(`reportListing failed: ${res.status}`); - } - - return { - listListings, - getListing, - createListing, - updateListing, - submitForCertification, - installListing, - uninstallListing, - listMyInstalls, - listReviews, - createReview, - reportListing, - }; -} diff --git a/vendor/bytelyst/marketplace-client/src/index.ts b/vendor/bytelyst/marketplace-client/src/index.ts deleted file mode 100644 index 10febd0..0000000 --- a/vendor/bytelyst/marketplace-client/src/index.ts +++ /dev/null @@ -1,75 +0,0 @@ -export interface MarketplaceClientOptions { - baseUrl: string; - productId: string; - getAccessToken: () => string; -} - -export interface MarketplaceListing { - id: string; - title: string; - description: string; - category: string; - author: string; - downloads: number; -} - -function joinUrl(base: string, path: string): string { - const b = base.replace(/\/$/, ""); - const p = path.startsWith("/") ? path : `/${path}`; - return `${b}${p}`; -} - -function headers(opts: MarketplaceClientOptions): HeadersInit { - return { - Authorization: `Bearer ${opts.getAccessToken()}`, - "X-Product-Id": opts.productId, - Accept: "application/json", - "Content-Type": "application/json", - }; -} - -async function parseJson(res: Response): Promise { - if (!res.ok) { - const text = await res.text(); - throw new Error(`HTTP ${res.status}: ${text || res.statusText}`); - } - return res.json() as Promise; -} - -export function createMarketplaceClient(opts: MarketplaceClientOptions) { - const { baseUrl } = opts; - - return { - async listListings(listOpts?: { - category?: string; - }): Promise { - const q = new URLSearchParams(); - if (listOpts?.category !== undefined && listOpts.category !== "") { - q.set("category", listOpts.category); - } - const query = q.toString(); - const path = - query.length > 0 - ? `/marketplace/listings?${query}` - : "/marketplace/listings"; - const res = await fetch(joinUrl(baseUrl, path), { - method: "GET", - headers: headers(opts), - }); - return parseJson(res); - }, - - async installListing( - listingId: string - ): Promise<{ success: boolean }> { - const res = await fetch( - joinUrl( - baseUrl, - `/marketplace/listings/${encodeURIComponent(listingId)}/install` - ), - { method: "POST", headers: headers(opts), body: "{}" } - ); - return parseJson<{ success: boolean }>(res); - }, - }; -} diff --git a/vendor/bytelyst/marketplace-client/src/types.ts b/vendor/bytelyst/marketplace-client/src/types.ts deleted file mode 100644 index 482950d..0000000 --- a/vendor/bytelyst/marketplace-client/src/types.ts +++ /dev/null @@ -1,115 +0,0 @@ -/** - * Types for @bytelyst/marketplace-client. - * Browser/React Native-safe — no Node.js dependencies. - */ - -export interface MarketplaceListingDoc { - id: string; - productId: string; - templateType: string; - authorId: string; - authorName: string; - title: string; - shortDescription: string; - description: string; - tags: string[]; - category: string; - payload: Record; - pricingModel: 'free' | 'paid' | 'freemium'; - priceInCents: number; - certificationStatus: 'draft' | 'submitted' | 'in_review' | 'approved' | 'rejected' | 'suspended'; - installCount: number; - reviewCount: number; - averageRating: number; - visibility: 'private' | 'unlisted' | 'public'; - featured: boolean; - version: string; - createdAt: string; - updatedAt: string; -} - -export interface MarketplaceReviewDoc { - id: string; - listingId: string; - productId: string; - authorId: string; - rating: number; - title: string; - body: string; - verified: boolean; - createdAt: string; -} - -export interface MarketplaceInstallDoc { - id: string; - listingId: string; - productId: string; - userId: string; - version: string; - installedAt: string; - uninstalledAt: string | null; -} - -export interface CreateListingInput { - templateType: string; - title: string; - shortDescription: string; - description: string; - tags?: string[]; - category: string; - payload: Record; - pricingModel?: 'free' | 'paid' | 'freemium'; - priceInCents?: number; - visibility?: 'private' | 'unlisted' | 'public'; - version?: string; -} - -export interface MarketplaceClientConfig { - /** Platform-service base URL (e.g. "http://localhost:4003/api"). */ - baseUrl: string; - - /** Product identifier sent as x-product-id header on every request. */ - productId: string; - - /** Returns a JWT access token, or null if not authenticated. */ - getAccessToken: () => string | null; -} - -export interface MarketplaceClient { - // Listings - listListings(query?: { - templateType?: string; - category?: string; - tags?: string; - pricingModel?: string; - sortBy?: string; - q?: string; - limit?: number; - offset?: number; - }): Promise<{ listings: MarketplaceListingDoc[]; total: number }>; - getListing(id: string): Promise; - createListing(input: CreateListingInput): Promise; - updateListing( - id: string, - updates: Partial - ): Promise; - submitForCertification(id: string, notes?: string): Promise; - - // Installs - installListing(listingId: string): Promise; - uninstallListing(listingId: string): Promise; - listMyInstalls(query?: { limit?: number; offset?: number }): Promise; - - // Reviews - listReviews( - listingId: string, - query?: { sortBy?: string; limit?: number } - ): Promise; - createReview( - listingId: string, - input: { rating: number; title: string; body: string } - ): Promise; - - // Reports - reportListing(listingId: string, input: { reason: string; details: string }): Promise; -} diff --git a/vendor/bytelyst/marketplace-client/tsconfig.json b/vendor/bytelyst/marketplace-client/tsconfig.json deleted file mode 100644 index 8c5e8c2..0000000 --- a/vendor/bytelyst/marketplace-client/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "outDir": "dist", - "rootDir": "src", - "declaration": true, - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true - }, - "include": ["src"] -} diff --git a/vendor/bytelyst/monitoring/package.json b/vendor/bytelyst/monitoring/package.json deleted file mode 100644 index 1d9b877..0000000 --- a/vendor/bytelyst/monitoring/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "@bytelyst/monitoring", - "version": "0.1.5", - "type": "module", - "description": "Health-check aggregation utilities for ByteLyst services and dashboards", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/monitoring/src/__tests__/monitoring.test.ts b/vendor/bytelyst/monitoring/src/__tests__/monitoring.test.ts deleted file mode 100644 index b48f4dd..0000000 --- a/vendor/bytelyst/monitoring/src/__tests__/monitoring.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { checkService, generateHealthReport, type ServiceTarget } from '../index.js'; - -type FetchInput = Parameters[0]; -type MockFetchResult = Pick; - -function mockFetch(handler: (input: FetchInput) => Promise): void { - globalThis.fetch = vi.fn(handler) as unknown as typeof globalThis.fetch; -} - -describe('monitoring', () => { - const originalFetch = globalThis.fetch; - - beforeEach(() => { - vi.restoreAllMocks(); - }); - - afterEach(() => { - globalThis.fetch = originalFetch; - }); - - it('checkService returns healthy for 200', async () => { - mockFetch(async () => ({ - ok: true, - status: 200, - json: async () => ({ status: 'ok' }), - })); - - const res = await checkService({ name: 'svc', url: 'http://x', path: '/health' }); - expect(res.status).toBe('healthy'); - expect(res.details).toEqual({ status: 'ok' }); - }); - - it('checkService returns unhealthy for non-2xx', async () => { - mockFetch(async () => ({ - ok: false, - status: 503, - json: async () => ({}), - })); - - const res = await checkService({ name: 'svc', url: 'http://x', path: '/health' }); - expect(res.status).toBe('unhealthy'); - expect(res.error).toContain('HTTP 503'); - }); - - it('checkService returns unreachable on fetch error', async () => { - mockFetch(async () => { - throw new Error('network'); - }); - - const res = await checkService({ name: 'svc', url: 'http://x', path: '/health' }); - expect(res.status).toBe('unreachable'); - expect(res.error).toContain('network'); - }); - - it('generateHealthReport sets overall=down when all unreachable', async () => { - mockFetch(async () => { - throw new Error('network'); - }); - - const services: ServiceTarget[] = [ - { name: 'a', url: 'http://a', path: '/health' }, - { name: 'b', url: 'http://b', path: '/health' }, - ]; - - const report = await generateHealthReport(services, { timeoutMs: 10 }); - expect(report.overall).toBe('down'); - expect(report.summary.unreachable).toBe(2); - }); - - it('generateHealthReport sets overall=degraded when mixed', async () => { - mockFetch(async url => { - if (String(url).includes('good')) return { ok: true, status: 200, json: async () => ({}) }; - return { ok: false, status: 500, json: async () => ({}) }; - }); - - const services: ServiceTarget[] = [ - { name: 'good', url: 'http://good', path: '/health' }, - { name: 'bad', url: 'http://bad', path: '/health' }, - ]; - - const report = await generateHealthReport(services); - expect(report.overall).toBe('degraded'); - expect(report.summary.healthy).toBe(1); - expect(report.summary.unhealthy).toBe(1); - }); - - it('generateHealthReport sets overall=healthy when all healthy', async () => { - mockFetch(async () => ({ - ok: true, - status: 200, - json: async () => ({}), - })); - - const services: ServiceTarget[] = [ - { name: 'a', url: 'http://a', path: '/health' }, - { name: 'b', url: 'http://b', path: '/health' }, - ]; - - const report = await generateHealthReport(services); - expect(report.overall).toBe('healthy'); - expect(report.summary.healthy).toBe(2); - }); -}); diff --git a/vendor/bytelyst/monitoring/src/health.ts b/vendor/bytelyst/monitoring/src/health.ts deleted file mode 100644 index 6e99207..0000000 --- a/vendor/bytelyst/monitoring/src/health.ts +++ /dev/null @@ -1,105 +0,0 @@ -export interface ServiceTarget { - name: string; - url: string; - path: string; -} - -export interface ServiceCheck { - name: string; - url: string; - status: 'healthy' | 'unhealthy' | 'unreachable'; - responseTimeMs: number; - details?: Record; - error?: string; -} - -export interface HealthReport { - overall: 'healthy' | 'degraded' | 'down'; - timestamp: string; - services: ServiceCheck[]; - summary: { healthy: number; unhealthy: number; unreachable: number; total: number }; -} - -/** - * Default service targets (LysnrAI local stack). - */ -export const DEFAULT_SERVICES: ServiceTarget[] = [ - { name: 'Backend API', url: process.env.BACKEND_URL || 'http://localhost:8000', path: '/health' }, - { - name: 'Platform Service', - url: process.env.PLATFORM_SERVICE_URL || 'http://localhost:4003', - path: '/health', - }, - { - name: 'Admin Dashboard', - url: process.env.ADMIN_DASHBOARD_URL || 'http://localhost:3001', - path: '/api/health', - }, - { - name: 'User Dashboard', - url: process.env.USER_DASHBOARD_URL || 'http://localhost:3002', - path: '/api/health', - }, -]; - -export async function checkService( - svc: ServiceTarget, - opts?: { timeoutMs?: number } -): Promise { - const fullUrl = `${svc.url}${svc.path}`; - const start = performance.now(); - - try { - const res = await fetch(fullUrl, { signal: AbortSignal.timeout(opts?.timeoutMs ?? 5_000) }); - const elapsed = Math.round(performance.now() - start); - - if (res.ok) { - let details: Record | undefined; - try { - details = (await res.json()) as Record; - } catch { - /* ignore */ - } - return { name: svc.name, url: svc.url, status: 'healthy', responseTimeMs: elapsed, details }; - } - - return { - name: svc.name, - url: svc.url, - status: 'unhealthy', - responseTimeMs: elapsed, - error: `HTTP ${res.status}`, - }; - } catch (err) { - const elapsed = Math.round(performance.now() - start); - return { - name: svc.name, - url: svc.url, - status: 'unreachable', - responseTimeMs: elapsed, - error: String(err), - }; - } -} - -export async function generateHealthReport( - services: ServiceTarget[] = DEFAULT_SERVICES, - opts?: { timeoutMs?: number } -): Promise { - const checks = await Promise.all(services.map(svc => checkService(svc, opts))); - - const healthy = checks.filter(c => c.status === 'healthy').length; - const unhealthy = checks.filter(c => c.status === 'unhealthy').length; - const unreachable = checks.filter(c => c.status === 'unreachable').length; - - let overall: HealthReport['overall'] = 'healthy'; - if (unreachable === checks.length) overall = 'down'; - else if (unhealthy > 0 || unreachable > 0) overall = 'degraded'; - - return { - overall, - timestamp: new Date().toISOString(), - services: checks, - summary: { healthy, unhealthy, unreachable, total: checks.length }, - }; -} diff --git a/vendor/bytelyst/monitoring/src/index.ts b/vendor/bytelyst/monitoring/src/index.ts deleted file mode 100644 index 9d719a5..0000000 --- a/vendor/bytelyst/monitoring/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './health.js'; diff --git a/vendor/bytelyst/monitoring/tsconfig.json b/vendor/bytelyst/monitoring/tsconfig.json deleted file mode 100644 index 5edad81..0000000 --- a/vendor/bytelyst/monitoring/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/vendor/bytelyst/offline-queue/package.json b/vendor/bytelyst/offline-queue/package.json deleted file mode 100644 index 4aadb6a..0000000 --- a/vendor/bytelyst/offline-queue/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "@bytelyst/offline-queue", - "version": "0.1.5", - "type": "module", - "description": "Browser/React Native-safe persistent offline retry queue", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/offline-queue/src/index.test.ts b/vendor/bytelyst/offline-queue/src/index.test.ts deleted file mode 100644 index 2135622..0000000 --- a/vendor/bytelyst/offline-queue/src/index.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import { createOfflineQueue } from './index.js'; - -function createMemoryStorage() { - const store: Record = {}; - return { - getItem: (k: string) => store[k] ?? null, - setItem: (k: string, v: string) => { - store[k] = v; - }, - _store: store, - }; -} - -describe('createOfflineQueue', () => { - it('should start with zero length', () => { - const storage = createMemoryStorage(); - const queue = createOfflineQueue({ storageKey: 'test-q', storage }); - expect(queue.length()).toBe(0); - }); - - it('should enqueue items', () => { - const storage = createMemoryStorage(); - const queue = createOfflineQueue({ storageKey: 'test-q', storage }); - - queue.enqueue({ id: '1', action: 'create', path: '/sessions', payload: { name: 'test' } }); - expect(queue.length()).toBe(1); - - queue.enqueue({ id: '2', action: 'update', path: '/sessions/2', payload: { name: 'test2' } }); - expect(queue.length()).toBe(2); - }); - - it('should replace existing entry with same id + action', () => { - const storage = createMemoryStorage(); - const queue = createOfflineQueue({ storageKey: 'test-q', storage }); - - queue.enqueue({ id: '1', action: 'create', path: '/sessions', payload: { v: 1 } }); - queue.enqueue({ id: '1', action: 'create', path: '/sessions', payload: { v: 2 } }); - expect(queue.length()).toBe(1); - }); - - it('should not replace entries with different action', () => { - const storage = createMemoryStorage(); - const queue = createOfflineQueue({ storageKey: 'test-q', storage }); - - queue.enqueue({ id: '1', action: 'create', path: '/sessions', payload: {} }); - queue.enqueue({ id: '1', action: 'update', path: '/sessions/1', payload: {} }); - expect(queue.length()).toBe(2); - }); - - it('should flush all items successfully', async () => { - const storage = createMemoryStorage(); - const queue = createOfflineQueue({ storageKey: 'test-q', storage }); - - queue.enqueue({ id: '1', action: 'create', path: '/a', payload: { n: 1 } }); - queue.enqueue({ id: '2', action: 'create', path: '/b', payload: { n: 2 } }); - - const executor = vi.fn().mockResolvedValue(undefined); - const result = await queue.flush(executor); - - expect(result.flushed).toBe(2); - expect(result.failed).toBe(0); - expect(queue.length()).toBe(0); - expect(executor).toHaveBeenCalledTimes(2); - }); - - it('should retry failed items on next flush', async () => { - const storage = createMemoryStorage(); - const queue = createOfflineQueue({ storageKey: 'test-q', storage, maxRetries: 3 }); - - queue.enqueue({ id: '1', action: 'create', path: '/a', payload: {} }); - - const failingExecutor = vi.fn().mockRejectedValue(new Error('offline')); - const result = await queue.flush(failingExecutor); - - expect(result.flushed).toBe(0); - expect(result.failed).toBe(1); - expect(queue.length()).toBe(1); - }); - - it('should drop items after max retries', async () => { - const storage = createMemoryStorage(); - const queue = createOfflineQueue({ storageKey: 'test-q', storage, maxRetries: 2 }); - - queue.enqueue({ id: '1', action: 'create', path: '/a', payload: {} }); - - const failingExecutor = vi.fn().mockRejectedValue(new Error('offline')); - - // Retry 1 - await queue.flush(failingExecutor); - expect(queue.length()).toBe(1); - - // Retry 2 — should be dropped - await queue.flush(failingExecutor); - expect(queue.length()).toBe(0); - }); - - it('should cap queue at maxQueueSize', () => { - const storage = createMemoryStorage(); - const queue = createOfflineQueue({ storageKey: 'test-q', storage, maxQueueSize: 3 }); - - for (let i = 0; i < 5; i++) { - queue.enqueue({ id: `${i}`, action: 'create', path: `/item/${i}`, payload: {} }); - } - - expect(queue.length()).toBe(3); - }); - - it('should persist to storage', () => { - const storage = createMemoryStorage(); - const queue = createOfflineQueue({ storageKey: 'test-q', storage }); - - queue.enqueue({ id: '1', action: 'create', path: '/a', payload: { x: 1 } }); - - const stored = JSON.parse(storage._store['test-q']); - expect(stored).toHaveLength(1); - expect(stored[0].id).toBe('1'); - }); - - it('should clear the queue', () => { - const storage = createMemoryStorage(); - const queue = createOfflineQueue({ storageKey: 'test-q', storage }); - - queue.enqueue({ id: '1', action: 'create', path: '/a', payload: {} }); - queue.enqueue({ id: '2', action: 'create', path: '/b', payload: {} }); - expect(queue.length()).toBe(2); - - queue.clear(); - expect(queue.length()).toBe(0); - }); - - it('should return empty result when flushing empty queue', async () => { - const storage = createMemoryStorage(); - const queue = createOfflineQueue({ storageKey: 'test-q', storage }); - - const executor = vi.fn(); - const result = await queue.flush(executor); - - expect(result.flushed).toBe(0); - expect(result.failed).toBe(0); - expect(executor).not.toHaveBeenCalled(); - }); -}); diff --git a/vendor/bytelyst/offline-queue/src/index.ts b/vendor/bytelyst/offline-queue/src/index.ts deleted file mode 100644 index d11a1d0..0000000 --- a/vendor/bytelyst/offline-queue/src/index.ts +++ /dev/null @@ -1,166 +0,0 @@ -/** - * Persistent offline retry queue for browser and React Native. - * - * When an API call fails (offline, timeout, etc.), the operation is - * queued in configurable storage and retried on the next flush. - * - * No Node.js, React, or React Native dependencies. - * - * @example - * ```ts - * import { createOfflineQueue } from '@bytelyst/offline-queue'; - * - * const queue = createOfflineQueue({ - * storageKey: 'nomgap-offline-queue', - * storage: mmkvStorage, // or localStorage - * }); - * - * // On API failure: - * queue.enqueue({ id: 'sess-1', action: 'create', path: '/sessions', payload: { ... } }); - * - * // On app foreground / auth success: - * const result = await queue.flush(async (action, path, payload) => { - * await apiClient.request(action === 'create' ? 'POST' : 'PUT', path, payload); - * }); - * ``` - */ - -// ── Types ──────────────────────────────────────────────────── - -export interface QueueStorage { - getItem(key: string): string | null; - setItem(key: string, value: string): void; -} - -export interface OfflineQueueConfig { - /** Storage key for persisting the queue. */ - storageKey: string; - - /** Storage adapter (localStorage, MMKV, AsyncStorage wrapper, etc.). */ - storage: QueueStorage; - - /** Maximum retry attempts per item. Default: 5. */ - maxRetries?: number; - - /** Maximum queue size. Oldest items are dropped when exceeded. Default: 50. */ - maxQueueSize?: number; -} - -export interface QueueItem { - id: string; - action: string; - path: string; - payload: Record; - enqueuedAt: number; - retryCount: number; -} - -export interface FlushResult { - flushed: number; - failed: number; -} - -export interface OfflineQueue { - /** Enqueue a failed operation for later retry. Replaces existing entry with same id + action. */ - enqueue(item: { - id: string; - action: string; - path: string; - payload: Record; - }): void; - - /** Flush the queue — retry all pending items via the provided executor. */ - flush( - executor: (action: string, path: string, payload: Record) => Promise - ): Promise; - - /** Get current queue length. */ - length(): number; - - /** Clear the entire queue. */ - clear(): void; -} - -// ── Factory ────────────────────────────────────────────────── - -export function createOfflineQueue(config: OfflineQueueConfig): OfflineQueue { - const { storageKey, storage, maxRetries = 5, maxQueueSize = 50 } = config; - - function loadQueue(): QueueItem[] { - try { - const raw = storage.getItem(storageKey); - if (!raw) return []; - return JSON.parse(raw) as QueueItem[]; - } catch { - return []; - } - } - - function saveQueue(queue: QueueItem[]): void { - try { - storage.setItem(storageKey, JSON.stringify(queue)); - } catch { - // Storage unavailable - } - } - - function enqueue(item: { - id: string; - action: string; - path: string; - payload: Record; - }): void { - const queue = loadQueue(); - - // Replace existing entry for same entity + action - const filtered = queue.filter(q => !(q.id === item.id && q.action === item.action)); - - // Cap queue size - if (filtered.length >= maxQueueSize) { - filtered.shift(); - } - - filtered.push({ - ...item, - enqueuedAt: Date.now(), - retryCount: 0, - }); - - saveQueue(filtered); - } - - async function flush( - executor: (action: string, path: string, payload: Record) => Promise - ): Promise { - const queue = loadQueue(); - if (queue.length === 0) return { flushed: 0, failed: 0 }; - - let flushed = 0; - const remaining: QueueItem[] = []; - - for (const item of queue) { - try { - await executor(item.action, item.path, item.payload); - flushed++; - } catch { - if (item.retryCount + 1 < maxRetries) { - remaining.push({ ...item, retryCount: item.retryCount + 1 }); - } - // else: silently drop — too many retries - } - } - - saveQueue(remaining); - return { flushed, failed: remaining.length }; - } - - function length(): number { - return loadQueue().length; - } - - function clear(): void { - saveQueue([]); - } - - return { enqueue, flush, length, clear }; -} diff --git a/vendor/bytelyst/offline-queue/tsconfig.json b/vendor/bytelyst/offline-queue/tsconfig.json deleted file mode 100644 index 5a24989..0000000 --- a/vendor/bytelyst/offline-queue/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src"] -} diff --git a/vendor/bytelyst/ollama-client/package.json b/vendor/bytelyst/ollama-client/package.json deleted file mode 100644 index a449ce0..0000000 --- a/vendor/bytelyst/ollama-client/package.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "name": "@bytelyst/ollama-client", - "version": "0.1.6", - "description": "Shared Ollama API client — streaming chat, embeddings, model management, health checks", - "type": "module", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks", - "typecheck": "tsc --noEmit" - }, - "devDependencies": { - "vitest": "^3.0.0", - "typescript": "^5.7.0" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/ollama-client/src/client-parsers.test.ts b/vendor/bytelyst/ollama-client/src/client-parsers.test.ts deleted file mode 100644 index bd65194..0000000 --- a/vendor/bytelyst/ollama-client/src/client-parsers.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import { consumeSSEStream, consumeNdjsonStream } from './client-parsers.js'; - -function createResponse(body: string): Response { - const encoder = new TextEncoder(); - const stream = new ReadableStream({ - start(controller) { - controller.enqueue(encoder.encode(body)); - controller.close(); - }, - }); - return { ok: true, body: stream } as unknown as Response; -} - -describe('consumeSSEStream', () => { - it('parses data: lines and calls onData', async () => { - const response = createResponse('data: {"msg":"hello"}\ndata: {"msg":"world"}\n\n'); - const onData = vi.fn(); - const onDone = vi.fn(); - - await consumeSSEStream(response, onData, onDone); - - expect(onData).toHaveBeenCalledTimes(2); - expect(onData).toHaveBeenCalledWith({ msg: 'hello' }); - expect(onData).toHaveBeenCalledWith({ msg: 'world' }); - expect(onDone).toHaveBeenCalled(); - }); - - it('stops on [DONE] marker', async () => { - const response = createResponse('data: {"msg":"hello"}\ndata: [DONE]\ndata: {"msg":"after"}\n'); - const onData = vi.fn(); - const onDone = vi.fn(); - - await consumeSSEStream(response, onData, onDone); - - expect(onData).toHaveBeenCalledTimes(1); - expect(onDone).toHaveBeenCalled(); - }); - - it('calls onDone when response has no body', async () => { - const response = { ok: true, body: null } as unknown as Response; - const onDone = vi.fn(); - - await consumeSSEStream(response, vi.fn(), onDone); - expect(onDone).toHaveBeenCalled(); - }); - - it('skips malformed SSE data', async () => { - const response = createResponse('data: not-json\ndata: {"valid":true}\n'); - const onData = vi.fn(); - const onDone = vi.fn(); - - await consumeSSEStream(response, onData, onDone); - expect(onData).toHaveBeenCalledTimes(1); - expect(onData).toHaveBeenCalledWith({ valid: true }); - }); -}); - -describe('consumeNdjsonStream', () => { - it('parses NDJSON lines and calls onChunk', async () => { - const response = createResponse('{"a":1}\n{"a":2}\n'); - const onChunk = vi.fn(); - - await consumeNdjsonStream(response, onChunk); - - expect(onChunk).toHaveBeenCalledTimes(2); - expect(onChunk).toHaveBeenCalledWith({ a: 1 }); - expect(onChunk).toHaveBeenCalledWith({ a: 2 }); - }); - - it('handles no body', async () => { - const response = { ok: true, body: null } as unknown as Response; - const onChunk = vi.fn(); - - await consumeNdjsonStream(response, onChunk); - expect(onChunk).not.toHaveBeenCalled(); - }); - - it('processes remaining buffer', async () => { - const response = createResponse('{"a":1}\n{"a":2}'); - const onChunk = vi.fn(); - - await consumeNdjsonStream(response, onChunk); - expect(onChunk).toHaveBeenCalledTimes(2); - }); - - it('skips malformed lines', async () => { - const response = createResponse('{"a":1}\nbad\n{"a":2}\n'); - const onChunk = vi.fn(); - - await consumeNdjsonStream(response, onChunk); - expect(onChunk).toHaveBeenCalledTimes(2); - }); -}); diff --git a/vendor/bytelyst/ollama-client/src/client-parsers.ts b/vendor/bytelyst/ollama-client/src/client-parsers.ts deleted file mode 100644 index a86c356..0000000 --- a/vendor/bytelyst/ollama-client/src/client-parsers.ts +++ /dev/null @@ -1,115 +0,0 @@ -/** - * Browser-side stream consumers for Next.js API route clients. - * - * These functions consume a `Response` from a fetch call that returns - * streaming data (SSE or NDJSON) and invoke callbacks for each chunk. - */ - -/** - * Consume a Server-Sent Events (SSE) stream from a Response. - * - * Parses `data:` lines and invokes `onData` for each parsed JSON object. - * Stops when the stream ends or `[DONE]` is received. - * - * @param response - Fetch Response with SSE body - * @param onData - Callback for each parsed data event - * @param onDone - Callback when stream completes - */ -export async function consumeSSEStream( - response: Response, - onData: (data: Record) => void, - onDone: () => void -): Promise { - if (!response.body) { - onDone(); - return; - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split('\n'); - buffer = lines.pop() ?? ''; - - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed) continue; - - if (trimmed.startsWith('data:')) { - const payload = trimmed.slice(5).trim(); - if (payload === '[DONE]') { - onDone(); - return; - } - try { - onData(JSON.parse(payload) as Record); - } catch { - // skip malformed SSE data - } - } - } - } - } finally { - reader.releaseLock(); - } - - onDone(); -} - -/** - * Consume an NDJSON (newline-delimited JSON) stream from a Response. - * - * Parses each line as JSON and invokes `onChunk` for each parsed object. - * - * @param response - Fetch Response with NDJSON body - * @param onChunk - Callback for each parsed NDJSON line - */ -export async function consumeNdjsonStream( - response: Response, - onChunk: (chunk: T) => void -): Promise { - if (!response.body) return; - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split('\n'); - buffer = lines.pop() ?? ''; - - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed) continue; - try { - onChunk(JSON.parse(trimmed) as T); - } catch { - // skip malformed lines - } - } - } - - // Process remaining buffer - if (buffer.trim()) { - try { - onChunk(JSON.parse(buffer.trim()) as T); - } catch { - // skip - } - } - } finally { - reader.releaseLock(); - } -} diff --git a/vendor/bytelyst/ollama-client/src/client.test.ts b/vendor/bytelyst/ollama-client/src/client.test.ts deleted file mode 100644 index accd16c..0000000 --- a/vendor/bytelyst/ollama-client/src/client.test.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { OllamaClient } from './client.js'; - -const BASE_URL = 'http://localhost:11434'; - -function mockFetch(response: unknown, options?: { ok?: boolean; status?: number }) { - return vi.fn().mockResolvedValue({ - ok: options?.ok ?? true, - status: options?.status ?? 200, - json: () => Promise.resolve(response), - text: () => Promise.resolve(JSON.stringify(response)), - }); -} - -describe('OllamaClient', () => { - let client: OllamaClient; - - beforeEach(() => { - client = new OllamaClient({ baseUrl: BASE_URL }); - vi.restoreAllMocks(); - }); - - describe('constructor', () => { - it('strips trailing slashes from baseUrl', () => { - const c = new OllamaClient({ baseUrl: 'http://localhost:11434///' }); - expect(c.baseUrl).toBe('http://localhost:11434'); - }); - }); - - describe('tags', () => { - it('returns list of models', async () => { - const models = [{ name: 'llama3', size: 1000, digest: 'abc', modified_at: '2024-01-01' }]; - globalThis.fetch = mockFetch({ models }); - - const result = await client.tags(); - expect(result).toEqual(models); - expect(globalThis.fetch).toHaveBeenCalledWith( - `${BASE_URL}/api/tags`, - expect.objectContaining({ - headers: expect.objectContaining({ 'Content-Type': 'application/json' }), - }) - ); - }); - - it('returns empty array when models is null', async () => { - globalThis.fetch = mockFetch({ models: null }); - const result = await client.tags(); - expect(result).toEqual([]); - }); - }); - - describe('ps', () => { - it('returns running models', async () => { - const models = [ - { name: 'llama3', size: 1000, digest: 'abc', expires_at: '2024-01-01', size_vram: 500 }, - ]; - globalThis.fetch = mockFetch({ models }); - - const result = await client.ps(); - expect(result).toEqual(models); - }); - }); - - describe('show', () => { - it('sends POST with model name', async () => { - const showData = { modelfile: '', parameters: '', template: '', details: {} }; - globalThis.fetch = mockFetch(showData); - - const result = await client.show('llama3'); - expect(result).toEqual(showData); - expect(globalThis.fetch).toHaveBeenCalledWith( - `${BASE_URL}/api/show`, - expect.objectContaining({ - method: 'POST', - body: JSON.stringify({ name: 'llama3' }), - }) - ); - }); - }); - - describe('pull (non-streaming)', () => { - it('pulls a model', async () => { - globalThis.fetch = mockFetch({ status: 'success' }); - - const result = await client.pull('llama3'); - expect(result).toEqual({ status: 'success' }); - }); - - it('throws on failure', async () => { - globalThis.fetch = mockFetch('Pull failed', { ok: false, status: 500 }); - await expect(client.pull('bad-model')).rejects.toThrow('Ollama pull failed (500)'); - }); - }); - - describe('load', () => { - it('sends generate with keep_alive', async () => { - globalThis.fetch = mockFetch({}); - - await client.load('llama3', '15m'); - expect(globalThis.fetch).toHaveBeenCalledWith( - `${BASE_URL}/api/generate`, - expect.objectContaining({ - method: 'POST', - body: JSON.stringify({ model: 'llama3', prompt: '', keep_alive: '15m' }), - }) - ); - }); - }); - - describe('unload', () => { - it('sends generate with keep_alive: 0', async () => { - globalThis.fetch = mockFetch({}); - - await client.unload('llama3'); - expect(globalThis.fetch).toHaveBeenCalledWith( - `${BASE_URL}/api/generate`, - expect.objectContaining({ - method: 'POST', - body: JSON.stringify({ model: 'llama3', prompt: '', keep_alive: '0' }), - }) - ); - }); - }); - - describe('delete', () => { - it('sends DELETE request', async () => { - globalThis.fetch = mockFetch({}); - - await client.delete('llama3'); - expect(globalThis.fetch).toHaveBeenCalledWith( - `${BASE_URL}/api/delete`, - expect.objectContaining({ - method: 'DELETE', - body: JSON.stringify({ name: 'llama3' }), - }) - ); - }); - - it('throws on failure', async () => { - globalThis.fetch = mockFetch('model not found', { ok: false, status: 404 }); - await expect(client.delete('nope')).rejects.toThrow('Ollama /api/delete failed (404)'); - }); - }); - - describe('version', () => { - it('returns version string', async () => { - globalThis.fetch = mockFetch({ version: '0.5.4' }); - - const result = await client.version(); - expect(result).toBe('0.5.4'); - }); - }); -}); diff --git a/vendor/bytelyst/ollama-client/src/client.ts b/vendor/bytelyst/ollama-client/src/client.ts deleted file mode 100644 index d354948..0000000 --- a/vendor/bytelyst/ollama-client/src/client.ts +++ /dev/null @@ -1,145 +0,0 @@ -import type { - OllamaClientOptions, - OllamaModel, - OllamaRunningModel, - OllamaShowResponse, - OllamaPullProgress, - OllamaVersionResponse, -} from './types.js'; -import { parseNdjsonStream } from './ndjson.js'; - -/** - * Ollama API client for model management operations. - * - * Provides typed methods for all non-streaming Ollama endpoints: - * tags, ps, show, pull, load, unload, delete, version. - */ -export class OllamaClient { - readonly baseUrl: string; - private readonly timeoutMs: number; - - constructor(options: OllamaClientOptions) { - this.baseUrl = options.baseUrl.replace(/\/+$/, ''); - this.timeoutMs = options.timeoutMs ?? 30_000; - } - - private async fetchJson(path: string, init?: RequestInit): Promise { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), this.timeoutMs); - - try { - const res = await fetch(`${this.baseUrl}${path}`, { - ...init, - signal: init?.signal ?? controller.signal, - headers: { 'Content-Type': 'application/json', ...init?.headers }, - }); - if (!res.ok) { - const text = await res.text().catch(() => ''); - throw new Error(`Ollama ${path} failed (${res.status}): ${text.slice(0, 200)}`); - } - return (await res.json()) as T; - } finally { - clearTimeout(timeout); - } - } - - /** List all locally available models (GET /api/tags). */ - async tags(): Promise { - const data = await this.fetchJson<{ models: OllamaModel[] }>('/api/tags'); - return data.models ?? []; - } - - /** List currently running/loaded models (GET /api/ps). */ - async ps(): Promise { - const data = await this.fetchJson<{ models: OllamaRunningModel[] }>('/api/ps'); - return data.models ?? []; - } - - /** Show model details (POST /api/show). */ - async show(model: string): Promise { - return this.fetchJson('/api/show', { - method: 'POST', - body: JSON.stringify({ name: model }), - }); - } - - /** - * Pull a model from the Ollama registry (POST /api/pull). - * - * When `stream: false`, waits for the full download to complete. - * When `stream: true`, returns an async generator of progress chunks. - */ - async pull(model: string, stream?: false): Promise<{ status: string }>; - async pull(model: string, stream: true): Promise>; - async pull( - model: string, - stream: boolean = false - ): Promise<{ status: string } | AsyncGenerator> { - if (!stream) { - // Model pulls can download GBs — use 10 minute timeout instead of the default - const pullTimeoutMs = Math.max(this.timeoutMs, 600_000); - const res = await fetch(`${this.baseUrl}/api/pull`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: model, stream: false }), - signal: AbortSignal.timeout(pullTimeoutMs), - }); - if (!res.ok) { - const text = await res.text().catch(() => ''); - throw new Error(`Ollama pull failed (${res.status}): ${text.slice(0, 200)}`); - } - return (await res.json()) as { status: string }; - } - - // Streaming pull — return async generator (no timeout, consumer controls lifetime) - const res = await fetch(`${this.baseUrl}/api/pull`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: model, stream: true }), - }); - if (!res.ok) { - const text = await res.text().catch(() => ''); - throw new Error(`Ollama pull failed (${res.status}): ${text.slice(0, 200)}`); - } - if (!res.body) throw new Error('No response body from Ollama pull'); - - return parseNdjsonStream(res.body); - } - - /** - * Load a model into memory (POST /api/generate with empty prompt + keep_alive). - * - * @param model - Model name - * @param keepAlive - How long to keep the model loaded (default: '10m') - */ - async load(model: string, keepAlive: string = '10m'): Promise { - await this.fetchJson('/api/generate', { - method: 'POST', - body: JSON.stringify({ model, prompt: '', keep_alive: keepAlive }), - }); - } - - /** - * Unload a model from memory (POST /api/generate with keep_alive: '0'). - */ - async unload(model: string): Promise { - await this.fetchJson('/api/generate', { - method: 'POST', - body: JSON.stringify({ model, prompt: '', keep_alive: '0' }), - }); - } - - /** Delete a model (DELETE /api/delete). */ - async delete(model: string): Promise { - await this.fetchJson('/api/delete', { - method: 'DELETE', - body: JSON.stringify({ name: model }), - }); - } - - /** Get Ollama server version (GET /api/version). */ - async version(): Promise { - const data = await this.fetchJson('/api/version'); - return data.version; - } -} diff --git a/vendor/bytelyst/ollama-client/src/config.test.ts b/vendor/bytelyst/ollama-client/src/config.test.ts deleted file mode 100644 index fe2fb4a..0000000 --- a/vendor/bytelyst/ollama-client/src/config.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { resolveOllamaUrl } from './config.js'; - -describe('resolveOllamaUrl', () => { - it('returns default localhost when no env vars set', () => { - expect(resolveOllamaUrl({})).toBe('http://localhost:11434'); - }); - - it('uses OLLAMA_URL when set', () => { - expect(resolveOllamaUrl({ OLLAMA_URL: 'http://myhost:11434' })).toBe('http://myhost:11434'); - }); - - it('uses OLLAMA_HOST when set', () => { - expect(resolveOllamaUrl({ OLLAMA_HOST: 'http://otherhost:11434' })).toBe( - 'http://otherhost:11434' - ); - }); - - it('prefers OLLAMA_URL over OLLAMA_HOST', () => { - expect( - resolveOllamaUrl({ - OLLAMA_URL: 'http://primary:11434', - OLLAMA_HOST: 'http://secondary:11434', - }) - ).toBe('http://primary:11434'); - }); - - it('normalizes URL without scheme', () => { - expect(resolveOllamaUrl({ OLLAMA_URL: 'myhost:11434' })).toBe('http://myhost:11434'); - }); - - it('strips trailing slashes', () => { - expect(resolveOllamaUrl({ OLLAMA_URL: 'http://myhost:11434///' })).toBe('http://myhost:11434'); - }); - - it('trims whitespace', () => { - expect(resolveOllamaUrl({ OLLAMA_URL: ' http://myhost:11434 ' })).toBe('http://myhost:11434'); - }); - - it('preserves https scheme', () => { - expect(resolveOllamaUrl({ OLLAMA_URL: 'https://secure:11434' })).toBe('https://secure:11434'); - }); -}); diff --git a/vendor/bytelyst/ollama-client/src/config.ts b/vendor/bytelyst/ollama-client/src/config.ts deleted file mode 100644 index a34db87..0000000 --- a/vendor/bytelyst/ollama-client/src/config.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { execSync } from 'child_process'; -import fs from 'fs'; - -function normalizeUrl(input: string): string { - const trimmed = input.trim().replace(/\/+$/, ''); - if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) { - return trimmed; - } - return `http://${trimmed}`; -} - -function detectWslGatewayOllamaUrl(): string | null { - try { - if (process.platform !== 'linux') return null; - const version = fs.readFileSync('/proc/version', 'utf-8').toLowerCase(); - if (!version.includes('microsoft')) return null; - - const gw = execSync("ip route show default | awk '{print $3}' | head -1", { - encoding: 'utf-8', - stdio: ['ignore', 'pipe', 'ignore'], - }).trim(); - if (!gw) return null; - return `http://${gw}:11434`; - } catch { - return null; - } -} - -/** - * Resolve the Ollama base URL from environment variables with WSL2 fallback. - * - * Priority: - * 1. `OLLAMA_URL` or `OLLAMA_HOST` env var (explicit config) - * 2. WSL2 gateway IP (Windows-hosted Ollama detected via /proc/version) - * 3. `http://localhost:11434` (default) - * - * @param env - Environment variables object (defaults to `process.env`) - */ -export function resolveOllamaUrl(env: Record = process.env): string { - const explicit = env.OLLAMA_URL || env.OLLAMA_HOST; - if (explicit) return normalizeUrl(explicit); - - const inferred = detectWslGatewayOllamaUrl(); - if (inferred) return inferred; - - return 'http://localhost:11434'; -} diff --git a/vendor/bytelyst/ollama-client/src/embed.test.ts b/vendor/bytelyst/ollama-client/src/embed.test.ts deleted file mode 100644 index 48ffe94..0000000 --- a/vendor/bytelyst/ollama-client/src/embed.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { getEmbedding, getEmbeddingVector } from './embed.js'; - -const BASE_URL = 'http://localhost:11434'; - -describe('getEmbedding', () => { - beforeEach(() => { - vi.restoreAllMocks(); - }); - - it('returns embedding response', async () => { - const response = { model: 'nomic-embed-text', embeddings: [[0.1, 0.2, 0.3]] }; - globalThis.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve(response), - }); - - const result = await getEmbedding(BASE_URL, { model: 'nomic-embed-text', input: 'hello' }); - expect(result).toEqual(response); - expect(globalThis.fetch).toHaveBeenCalledWith( - `${BASE_URL}/api/embed`, - expect.objectContaining({ - method: 'POST', - body: JSON.stringify({ model: 'nomic-embed-text', input: 'hello' }), - }) - ); - }); - - it('throws on error response', async () => { - globalThis.fetch = vi.fn().mockResolvedValue({ - ok: false, - status: 500, - text: () => Promise.resolve('internal error'), - }); - - await expect( - getEmbedding(BASE_URL, { model: 'nomic-embed-text', input: 'hello' }) - ).rejects.toThrow('Ollama embed failed (500)'); - }); -}); - -describe('getEmbeddingVector', () => { - beforeEach(() => { - vi.restoreAllMocks(); - }); - - it('returns first embedding vector', async () => { - globalThis.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ model: 'nomic-embed-text', embeddings: [[0.1, 0.2]] }), - }); - - const result = await getEmbeddingVector(BASE_URL, 'hello'); - expect(result).toEqual([0.1, 0.2]); - }); - - it('returns empty array when no embeddings', async () => { - globalThis.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ model: 'nomic-embed-text', embeddings: [] }), - }); - - const result = await getEmbeddingVector(BASE_URL, 'hello'); - expect(result).toEqual([]); - }); -}); diff --git a/vendor/bytelyst/ollama-client/src/embed.ts b/vendor/bytelyst/ollama-client/src/embed.ts deleted file mode 100644 index 7578c12..0000000 --- a/vendor/bytelyst/ollama-client/src/embed.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { OllamaEmbedOptions, OllamaEmbeddingResponse } from './types.js'; - -/** - * Get embeddings for text using an Ollama embedding model. - * - * @param baseUrl - Ollama server base URL - * @param options - Embedding options (model, input text) - * @returns Array of embedding vectors (one per input string) - */ -export async function getEmbedding( - baseUrl: string, - options: OllamaEmbedOptions -): Promise { - const res = await fetch(`${baseUrl}/api/embed`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - model: options.model, - input: options.input, - ...(options.options && { options: options.options }), - }), - }); - - if (!res.ok) { - const text = await res.text().catch(() => ''); - throw new Error(`Ollama embed failed (${res.status}): ${text.slice(0, 200)}`); - } - - return (await res.json()) as OllamaEmbeddingResponse; -} - -/** - * Convenience: get a single embedding vector for a text string. - * - * @param baseUrl - Ollama server base URL - * @param text - Text to embed - * @param model - Embedding model name (default: 'nomic-embed-text') - * @returns Single embedding vector - */ -export async function getEmbeddingVector( - baseUrl: string, - text: string, - model: string = 'nomic-embed-text' -): Promise { - const response = await getEmbedding(baseUrl, { model, input: text }); - return response.embeddings?.[0] ?? []; -} diff --git a/vendor/bytelyst/ollama-client/src/format.test.ts b/vendor/bytelyst/ollama-client/src/format.test.ts deleted file mode 100644 index 3011147..0000000 --- a/vendor/bytelyst/ollama-client/src/format.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { formatBytes, estimateTokens, getModelContextWindow, formatUptime } from './format.js'; - -describe('formatBytes', () => { - it('formats 0 bytes', () => { - expect(formatBytes(0)).toBe('0 B'); - }); - - it('formats bytes', () => { - expect(formatBytes(512)).toBe('512 B'); - }); - - it('formats kilobytes', () => { - expect(formatBytes(1536)).toBe('1.5 KB'); - }); - - it('formats megabytes', () => { - expect(formatBytes(5242880)).toBe('5 MB'); - }); - - it('formats gigabytes', () => { - expect(formatBytes(4294967296)).toBe('4 GB'); - }); - - it('returns 0 B for negative input', () => { - expect(formatBytes(-100)).toBe('0 B'); - }); -}); - -describe('estimateTokens', () => { - it('returns 0 for empty string', () => { - expect(estimateTokens('')).toBe(0); - }); - - it('returns 0 for whitespace-only string', () => { - expect(estimateTokens(' ')).toBe(0); - }); - - it('estimates tokens for a sentence', () => { - const result = estimateTokens('Hello world this is a test'); - expect(result).toBeGreaterThan(0); - // 6 words × 1.3 = 7.8, ceil = 8 - expect(result).toBe(8); - }); -}); - -describe('getModelContextWindow', () => { - it('returns 128k for models with 128k in name', () => { - expect(getModelContextWindow('llama3-128k')).toBe(128_000); - }); - - it('returns 32k for models with 32k in name', () => { - expect(getModelContextWindow('gpt4-32k')).toBe(32_000); - }); - - it('returns 4096 for models without size marker', () => { - expect(getModelContextWindow('llama3:latest')).toBe(4_096); - }); -}); - -describe('formatUptime', () => { - it('formats minutes only', () => { - expect(formatUptime(120)).toBe('2m'); - }); - - it('formats hours and minutes', () => { - expect(formatUptime(3661)).toBe('1h 1m'); - }); - - it('formats days, hours, and minutes', () => { - expect(formatUptime(90061)).toBe('1d 1h 1m'); - }); - - it('formats zero', () => { - expect(formatUptime(0)).toBe('0m'); - }); -}); diff --git a/vendor/bytelyst/ollama-client/src/format.ts b/vendor/bytelyst/ollama-client/src/format.ts deleted file mode 100644 index c252ed0..0000000 --- a/vendor/bytelyst/ollama-client/src/format.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * Format a byte count into a human-readable string. - * - * @example formatBytes(1536) // '1.5 KB' - * @example formatBytes(0) // '0 B' - */ -export function formatBytes(bytes: number): string { - if (bytes <= 0) return '0 B'; - const k = 1024; - const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; -} - -/** - * Approximate token count for a text string. - * - * Uses a word-count × 1.3 heuristic (typical for English text with LLM tokenizers). - */ -export function estimateTokens(text: string): number { - const trimmed = text.trim(); - if (!trimmed) return 0; - return Math.ceil(trimmed.split(/\s+/).length * 1.3); -} - -/** - * Best-effort model context window lookup based on model name. - * - * Checks for common context window markers in the model name string. - * Falls back to 4096 if no marker is found. - */ -export function getModelContextWindow(modelName: string): number { - const n = modelName.toLowerCase(); - if (n.includes('128k')) return 128_000; - if (n.includes('64k')) return 64_000; - if (n.includes('32k')) return 32_000; - if (n.includes('16k')) return 16_000; - if (n.includes('8k')) return 8_000; - return 4_096; -} - -/** - * Format a duration in seconds to a human-readable uptime string. - * - * @example formatUptime(90061) // '1d 1h 1m' - * @example formatUptime(3661) // '1h 1m' - * @example formatUptime(120) // '2m' - */ -export function formatUptime(seconds: number): string { - const d = Math.floor(seconds / 86400); - const h = Math.floor((seconds % 86400) / 3600); - const m = Math.floor((seconds % 3600) / 60); - if (d > 0) return `${d}d ${h}h ${m}m`; - if (h > 0) return `${h}h ${m}m`; - return `${m}m`; -} diff --git a/vendor/bytelyst/ollama-client/src/health.test.ts b/vendor/bytelyst/ollama-client/src/health.test.ts deleted file mode 100644 index c1b7161..0000000 --- a/vendor/bytelyst/ollama-client/src/health.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { checkHealth, checkHealthDetailed } from './health.js'; - -const BASE_URL = 'http://localhost:11434'; - -describe('checkHealth', () => { - beforeEach(() => { - vi.restoreAllMocks(); - }); - - it('returns true when server is healthy', async () => { - globalThis.fetch = vi.fn().mockResolvedValue({ ok: true }); - expect(await checkHealth(BASE_URL)).toBe(true); - }); - - it('returns false when server returns non-ok', async () => { - globalThis.fetch = vi.fn().mockResolvedValue({ ok: false }); - expect(await checkHealth(BASE_URL)).toBe(false); - }); - - it('returns false when fetch throws', async () => { - globalThis.fetch = vi.fn().mockRejectedValue(new Error('ECONNREFUSED')); - expect(await checkHealth(BASE_URL)).toBe(false); - }); -}); - -describe('checkHealthDetailed', () => { - beforeEach(() => { - vi.restoreAllMocks(); - }); - - it('returns online + version when server is healthy', async () => { - globalThis.fetch = vi.fn().mockImplementation((url: string) => { - if (url.includes('/api/tags')) return Promise.resolve({ ok: true }); - if (url.includes('/api/version')) - return Promise.resolve({ - ok: true, - json: () => Promise.resolve({ version: '0.5.4' }), - }); - return Promise.reject(new Error('unexpected')); - }); - - const result = await checkHealthDetailed(BASE_URL); - expect(result).toEqual({ online: true, version: '0.5.4', url: BASE_URL }); - }); - - it('returns offline when server is unreachable', async () => { - globalThis.fetch = vi.fn().mockRejectedValue(new Error('ECONNREFUSED')); - - const result = await checkHealthDetailed(BASE_URL); - expect(result).toEqual({ online: false, version: null, url: BASE_URL }); - }); -}); diff --git a/vendor/bytelyst/ollama-client/src/health.ts b/vendor/bytelyst/ollama-client/src/health.ts deleted file mode 100644 index da3410b..0000000 --- a/vendor/bytelyst/ollama-client/src/health.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Check if the Ollama server is reachable. - * - * @param baseUrl - Ollama server base URL - * @param timeoutMs - Timeout in milliseconds (default: 3000) - * @returns `true` if the server responds to GET /api/tags within the timeout - */ -export async function checkHealth(baseUrl: string, timeoutMs: number = 3000): Promise { - try { - const res = await fetch(`${baseUrl}/api/tags`, { - signal: AbortSignal.timeout(timeoutMs), - }); - return res.ok; - } catch { - return false; - } -} - -/** - * Check health and return structured result with version info. - */ -export async function checkHealthDetailed( - baseUrl: string, - timeoutMs: number = 3000 -): Promise<{ online: boolean; version: string | null; url: string }> { - try { - const [tagsRes, versionRes] = await Promise.allSettled([ - fetch(`${baseUrl}/api/tags`, { signal: AbortSignal.timeout(timeoutMs) }), - fetch(`${baseUrl}/api/version`, { signal: AbortSignal.timeout(timeoutMs) }), - ]); - - const online = tagsRes.status === 'fulfilled' && tagsRes.value.ok; - let version: string | null = null; - - if (versionRes.status === 'fulfilled' && versionRes.value.ok) { - const data = (await versionRes.value.json()) as { version?: string }; - version = data.version ?? null; - } - - return { online, version, url: baseUrl }; - } catch { - return { online: false, version: null, url: baseUrl }; - } -} diff --git a/vendor/bytelyst/ollama-client/src/index.ts b/vendor/bytelyst/ollama-client/src/index.ts deleted file mode 100644 index 1393e4d..0000000 --- a/vendor/bytelyst/ollama-client/src/index.ts +++ /dev/null @@ -1,41 +0,0 @@ -// Types -export type { - OllamaModel, - OllamaModelDetails, - OllamaRunningModel, - OllamaChatMessage, - OllamaStreamChunk, - OllamaGenerateChunk, - OllamaEmbeddingResponse, - OllamaShowResponse, - OllamaPullProgress, - OllamaVersionResponse, - OllamaClientOptions, - OllamaChatOptions, - OllamaGenerateOptions, - OllamaEmbedOptions, -} from './types.js'; - -// Config -export { resolveOllamaUrl } from './config.js'; - -// Client -export { OllamaClient } from './client.js'; - -// Streaming -export { streamChat, streamGenerate } from './stream.js'; - -// Embeddings -export { getEmbedding, getEmbeddingVector } from './embed.js'; - -// Health -export { checkHealth, checkHealthDetailed } from './health.js'; - -// NDJSON parser -export { parseNdjsonStream } from './ndjson.js'; - -// Client-side stream consumers -export { consumeSSEStream, consumeNdjsonStream } from './client-parsers.js'; - -// Format utilities -export { formatBytes, estimateTokens, getModelContextWindow, formatUptime } from './format.js'; diff --git a/vendor/bytelyst/ollama-client/src/ndjson.test.ts b/vendor/bytelyst/ollama-client/src/ndjson.test.ts deleted file mode 100644 index 41be1d4..0000000 --- a/vendor/bytelyst/ollama-client/src/ndjson.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { parseNdjsonStream } from './ndjson.js'; - -function createReadableStream(chunks: string[]): ReadableStream { - const encoder = new TextEncoder(); - let index = 0; - return new ReadableStream({ - pull(controller) { - if (index < chunks.length) { - controller.enqueue(encoder.encode(chunks[index])); - index++; - } else { - controller.close(); - } - }, - }); -} - -describe('parseNdjsonStream', () => { - it('parses complete NDJSON lines', async () => { - const stream = createReadableStream(['{"a":1}\n{"a":2}\n{"a":3}\n']); - const results: unknown[] = []; - for await (const chunk of parseNdjsonStream(stream)) { - results.push(chunk); - } - expect(results).toEqual([{ a: 1 }, { a: 2 }, { a: 3 }]); - }); - - it('handles partial lines split across chunks', async () => { - const stream = createReadableStream(['{"a":', '1}\n{"a":2}\n']); - const results: unknown[] = []; - for await (const chunk of parseNdjsonStream(stream)) { - results.push(chunk); - } - expect(results).toEqual([{ a: 1 }, { a: 2 }]); - }); - - it('skips empty lines', async () => { - const stream = createReadableStream(['{"a":1}\n\n\n{"a":2}\n']); - const results: unknown[] = []; - for await (const chunk of parseNdjsonStream(stream)) { - results.push(chunk); - } - expect(results).toEqual([{ a: 1 }, { a: 2 }]); - }); - - it('skips malformed JSON lines', async () => { - const stream = createReadableStream(['{"a":1}\nnot json\n{"a":2}\n']); - const results: unknown[] = []; - for await (const chunk of parseNdjsonStream(stream)) { - results.push(chunk); - } - expect(results).toEqual([{ a: 1 }, { a: 2 }]); - }); - - it('processes remaining buffer after stream ends', async () => { - const stream = createReadableStream(['{"a":1}\n{"a":2}']); - const results: unknown[] = []; - for await (const chunk of parseNdjsonStream(stream)) { - results.push(chunk); - } - expect(results).toEqual([{ a: 1 }, { a: 2 }]); - }); - - it('handles empty stream', async () => { - const stream = createReadableStream([]); - const results: unknown[] = []; - for await (const chunk of parseNdjsonStream(stream)) { - results.push(chunk); - } - expect(results).toEqual([]); - }); -}); diff --git a/vendor/bytelyst/ollama-client/src/ndjson.ts b/vendor/bytelyst/ollama-client/src/ndjson.ts deleted file mode 100644 index 7642649..0000000 --- a/vendor/bytelyst/ollama-client/src/ndjson.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** - * Parse an NDJSON (newline-delimited JSON) stream into an async generator. - * - * Works with both Node.js `ReadableStream` and browser `ReadableStream`. - * Handles partial lines across chunk boundaries gracefully. - */ -export async function* parseNdjsonStream( - body: ReadableStream -): AsyncGenerator { - const reader = body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split('\n'); - buffer = lines.pop() ?? ''; - - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed) continue; - try { - yield JSON.parse(trimmed) as T; - } catch { - // skip malformed lines - } - } - } - - // Process remaining buffer after stream ends - if (buffer.trim()) { - try { - yield JSON.parse(buffer.trim()) as T; - } catch { - // skip - } - } - } finally { - reader.releaseLock(); - } -} diff --git a/vendor/bytelyst/ollama-client/src/stream.test.ts b/vendor/bytelyst/ollama-client/src/stream.test.ts deleted file mode 100644 index 905d9f1..0000000 --- a/vendor/bytelyst/ollama-client/src/stream.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { streamChat, streamGenerate } from './stream.js'; - -const BASE_URL = 'http://localhost:11434'; - -function createNdjsonResponse(chunks: object[]): Response { - const encoder = new TextEncoder(); - const ndjson = chunks.map(c => JSON.stringify(c)).join('\n') + '\n'; - const stream = new ReadableStream({ - start(controller) { - controller.enqueue(encoder.encode(ndjson)); - controller.close(); - }, - }); - return { ok: true, status: 200, body: stream } as unknown as Response; -} - -describe('streamChat', () => { - beforeEach(() => { - vi.restoreAllMocks(); - }); - - it('yields chat stream chunks', async () => { - const chunks = [ - { model: 'llama3', message: { role: 'assistant', content: 'Hello' }, done: false }, - { model: 'llama3', message: { role: 'assistant', content: ' world' }, done: false }, - { model: 'llama3', message: { role: 'assistant', content: '' }, done: true, eval_count: 10 }, - ]; - globalThis.fetch = vi.fn().mockResolvedValue(createNdjsonResponse(chunks)); - - const results = []; - for await (const chunk of streamChat(BASE_URL, { - model: 'llama3', - messages: [{ role: 'user', content: 'Hi' }], - })) { - results.push(chunk); - } - - expect(results).toHaveLength(3); - expect(results[0].message.content).toBe('Hello'); - expect(results[2].done).toBe(true); - expect(results[2].eval_count).toBe(10); - }); - - it('throws on non-ok response', async () => { - globalThis.fetch = vi.fn().mockResolvedValue({ - ok: false, - status: 500, - text: () => Promise.resolve('internal error'), - }); - - const gen = streamChat(BASE_URL, { - model: 'llama3', - messages: [{ role: 'user', content: 'Hi' }], - }); - await expect(gen.next()).rejects.toThrow('Ollama chat failed (500)'); - }); - - it('throws when response has no body', async () => { - globalThis.fetch = vi.fn().mockResolvedValue({ - ok: true, - status: 200, - body: null, - }); - - const gen = streamChat(BASE_URL, { - model: 'llama3', - messages: [{ role: 'user', content: 'Hi' }], - }); - await expect(gen.next()).rejects.toThrow('No response body'); - }); -}); - -describe('streamGenerate', () => { - beforeEach(() => { - vi.restoreAllMocks(); - }); - - it('yields generate stream chunks', async () => { - const chunks = [ - { model: 'llama3', response: 'Hello', done: false }, - { model: 'llama3', response: ' world', done: false }, - { model: 'llama3', response: '', done: true, eval_count: 5 }, - ]; - globalThis.fetch = vi.fn().mockResolvedValue(createNdjsonResponse(chunks)); - - const results = []; - for await (const chunk of streamGenerate(BASE_URL, { - model: 'llama3', - prompt: 'Say hello', - })) { - results.push(chunk); - } - - expect(results).toHaveLength(3); - expect(results[0].response).toBe('Hello'); - expect(results[2].done).toBe(true); - }); -}); diff --git a/vendor/bytelyst/ollama-client/src/stream.ts b/vendor/bytelyst/ollama-client/src/stream.ts deleted file mode 100644 index 5851d4a..0000000 --- a/vendor/bytelyst/ollama-client/src/stream.ts +++ /dev/null @@ -1,85 +0,0 @@ -import type { - OllamaChatOptions, - OllamaGenerateOptions, - OllamaStreamChunk, - OllamaGenerateChunk, -} from './types.js'; -import { parseNdjsonStream } from './ndjson.js'; - -/** - * Stream a chat completion from Ollama. - * - * Yields NDJSON chunks from `POST /api/chat` as an async generator. - * - * @param baseUrl - Ollama server base URL - * @param options - Chat options (model, messages, signal, etc.) - */ -export async function* streamChat( - baseUrl: string, - options: OllamaChatOptions -): AsyncGenerator { - const { model, messages, signal, ...rest } = options; - - const body: Record = { model, messages, stream: true }; - if (rest.options) body.options = rest.options; - if (rest.format) body.format = rest.format; - if (rest.keep_alive) body.keep_alive = rest.keep_alive; - - const res = await fetch(`${baseUrl}/api/chat`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - signal, - }); - - if (!res.ok) { - const text = await res.text().catch(() => ''); - throw new Error(`Ollama chat failed (${res.status}): ${text.slice(0, 200)}`); - } - - if (!res.body) { - throw new Error('No response body from Ollama'); - } - - yield* parseNdjsonStream(res.body); -} - -/** - * Stream a text generation from Ollama. - * - * Yields NDJSON chunks from `POST /api/generate` as an async generator. - * - * @param baseUrl - Ollama server base URL - * @param options - Generate options (model, prompt, signal, etc.) - */ -export async function* streamGenerate( - baseUrl: string, - options: OllamaGenerateOptions -): AsyncGenerator { - const { model, prompt, signal, ...rest } = options; - - const body: Record = { model, prompt, stream: true }; - if (rest.system) body.system = rest.system; - if (rest.options) body.options = rest.options; - if (rest.format) body.format = rest.format; - if (rest.keep_alive) body.keep_alive = rest.keep_alive; - if (rest.context) body.context = rest.context; - - const res = await fetch(`${baseUrl}/api/generate`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - signal, - }); - - if (!res.ok) { - const text = await res.text().catch(() => ''); - throw new Error(`Ollama generate failed (${res.status}): ${text.slice(0, 200)}`); - } - - if (!res.body) { - throw new Error('No response body from Ollama'); - } - - yield* parseNdjsonStream(res.body); -} diff --git a/vendor/bytelyst/ollama-client/src/types.ts b/vendor/bytelyst/ollama-client/src/types.ts deleted file mode 100644 index 410d89d..0000000 --- a/vendor/bytelyst/ollama-client/src/types.ts +++ /dev/null @@ -1,133 +0,0 @@ -// --- Model types --- - -export interface OllamaModelDetails { - family?: string; - families?: string[]; - format?: string; - parameter_size?: string; - quantization_level?: string; -} - -export interface OllamaModel { - name: string; - model?: string; - size: number; - digest: string; - modified_at: string; - details?: OllamaModelDetails; -} - -export interface OllamaRunningModel { - name: string; - model?: string; - size: number; - digest: string; - expires_at: string; - size_vram: number; - details?: OllamaModelDetails; -} - -// --- Chat types --- - -export interface OllamaChatMessage { - role: 'user' | 'assistant' | 'system'; - content: string; - images?: string[]; -} - -export interface OllamaStreamChunk { - model: string; - message: { role: string; content: string }; - done: boolean; - total_duration?: number; - eval_count?: number; - eval_duration?: number; - prompt_eval_count?: number; - prompt_eval_duration?: number; - load_duration?: number; -} - -// --- Generate types --- - -export interface OllamaGenerateChunk { - model: string; - response: string; - done: boolean; - total_duration?: number; - eval_count?: number; - eval_duration?: number; - prompt_eval_count?: number; - prompt_eval_duration?: number; - load_duration?: number; - context?: number[]; -} - -// --- Embedding types --- - -export interface OllamaEmbeddingResponse { - model: string; - embeddings: number[][]; - total_duration?: number; - load_duration?: number; - prompt_eval_count?: number; -} - -// --- Show types --- - -export interface OllamaShowResponse { - modelfile: string; - parameters: string; - template: string; - details: OllamaModelDetails; - model_info?: Record; -} - -// --- Pull progress types --- - -export interface OllamaPullProgress { - status: string; - digest?: string; - total?: number; - completed?: number; -} - -// --- Version types --- - -export interface OllamaVersionResponse { - version: string; -} - -// --- Client options --- - -export interface OllamaClientOptions { - /** Base URL of the Ollama server (e.g. http://localhost:11434) */ - baseUrl: string; - /** Default request timeout in milliseconds (default: 30000) */ - timeoutMs?: number; -} - -export interface OllamaChatOptions { - model: string; - messages: OllamaChatMessage[]; - signal?: AbortSignal; - options?: Record; - format?: 'json' | string; - keep_alive?: string; -} - -export interface OllamaGenerateOptions { - model: string; - prompt: string; - signal?: AbortSignal; - system?: string; - options?: Record; - format?: 'json' | string; - keep_alive?: string; - context?: number[]; -} - -export interface OllamaEmbedOptions { - model: string; - input: string | string[]; - options?: Record; -} diff --git a/vendor/bytelyst/ollama-client/tsconfig.json b/vendor/bytelyst/ollama-client/tsconfig.json deleted file mode 100644 index b74148d..0000000 --- a/vendor/bytelyst/ollama-client/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src", - "types": ["node"] - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/vendor/bytelyst/ollama-client/vitest.config.ts b/vendor/bytelyst/ollama-client/vitest.config.ts deleted file mode 100644 index 811c18a..0000000 --- a/vendor/bytelyst/ollama-client/vitest.config.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - test: { - globals: true, - passWithNoTests: true, - pool: 'forks', - }, -}); diff --git a/vendor/bytelyst/org-client/package.json b/vendor/bytelyst/org-client/package.json deleted file mode 100644 index a12b5f1..0000000 --- a/vendor/bytelyst/org-client/package.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "@bytelyst/org-client", - "version": "0.1.5", - "type": "module", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "scripts": { - "build": "tsc" - }, - "devDependencies": { - "typescript": "^5.7.3" - } -} diff --git a/vendor/bytelyst/org-client/src/client.test.ts b/vendor/bytelyst/org-client/src/client.test.ts deleted file mode 100644 index 4dbc8cc..0000000 --- a/vendor/bytelyst/org-client/src/client.test.ts +++ /dev/null @@ -1,289 +0,0 @@ -import { describe, it, expect, vi, afterEach } from 'vitest'; -import { createOrgClient } from './client.js'; -import type { OrganizationDoc, WorkspaceDoc, MembershipDoc, LicenseDoc } from './types.js'; - -const baseConfig = { - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - getAccessToken: () => 'admin-token', -}; - -function mockOrg(overrides?: Partial): OrganizationDoc { - return { - id: 'org_1', - productId: 'testapp', - name: 'Test Org', - slug: 'test-org', - status: 'active', - ownerUserId: 'user-1', - createdAt: '2026-01-01T00:00:00Z', - updatedAt: '2026-01-01T00:00:00Z', - ...overrides, - }; -} - -function mockWorkspace(overrides?: Partial): WorkspaceDoc { - return { - id: 'ws_1', - orgId: 'org_1', - productId: 'testapp', - name: 'Engineering', - slug: 'engineering', - status: 'active', - createdAt: '2026-01-01T00:00:00Z', - updatedAt: '2026-01-01T00:00:00Z', - ...overrides, - }; -} - -function mockMembership(overrides?: Partial): MembershipDoc { - return { - id: 'mbr_org1_user1_org', - orgId: 'org_1', - productId: 'testapp', - scope: 'org', - userId: 'user-1', - role: 'admin', - status: 'active', - createdAt: '2026-01-01T00:00:00Z', - updatedAt: '2026-01-01T00:00:00Z', - ...overrides, - }; -} - -function mockLicense(overrides?: Partial): LicenseDoc { - return { - id: 'lic_1', - productId: 'testapp', - key: 'LYSNR-XXXX-YYYY-ZZZZ', - userId: 'user-1', - plan: 'pro', - status: 'active', - activatedAt: '2026-01-01T00:00:00Z', - expiresAt: null, - deviceIds: ['device-1'], - maxDevices: 3, - createdAt: '2026-01-01T00:00:00Z', - updatedAt: '2026-01-01T00:00:00Z', - ...overrides, - }; -} - -describe('createOrgClient', () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('should list orgs', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve([mockOrg()]), - }) - ); - const client = createOrgClient(baseConfig); - const result = await client.listOrgs(); - expect(result).toHaveLength(1); - expect(result[0].name).toBe('Test Org'); - }); - - it('should create an org', async () => { - const org = mockOrg(); - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve(org), - }) - ); - const client = createOrgClient(baseConfig); - const result = await client.createOrg({ name: 'Test Org', slug: 'test-org' }); - expect(result.id).toBe('org_1'); - }); - - it('should get an org by id', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockOrg()), - }) - ); - const client = createOrgClient(baseConfig); - const result = await client.getOrg('org_1'); - expect(result.slug).toBe('test-org'); - }); - - it('should list workspaces for an org', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve([mockWorkspace()]), - }) - ); - const client = createOrgClient(baseConfig); - const result = await client.listWorkspaces('org_1'); - expect(result).toHaveLength(1); - expect(result[0].name).toBe('Engineering'); - }); - - it('should create a workspace', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockWorkspace()), - }) - ); - const client = createOrgClient(baseConfig); - const result = await client.createWorkspace('org_1', { - name: 'Engineering', - slug: 'engineering', - }); - expect(result.orgId).toBe('org_1'); - }); - - it('should list memberships', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve([mockMembership()]), - }) - ); - const client = createOrgClient(baseConfig); - const result = await client.listMemberships('org_1'); - expect(result).toHaveLength(1); - expect(result[0].role).toBe('admin'); - }); - - it('should add a member', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockMembership()), - }) - ); - const client = createOrgClient(baseConfig); - const result = await client.addMember('org_1', { userId: 'user-1', role: 'admin' }); - expect(result.userId).toBe('user-1'); - }); - - it('should generate a license', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockLicense()), - }) - ); - const client = createOrgClient(baseConfig); - const result = await client.generateLicense({ userId: 'user-1', plan: 'pro' }); - expect(result.key).toContain('LYSNR'); - }); - - it('should activate a license', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockLicense()), - }) - ); - const client = createOrgClient(baseConfig); - const result = await client.activateLicense({ key: 'LYSNR-XXXX', deviceId: 'device-1' }); - expect(result.status).toBe('active'); - }); - - it('should update an org', async () => { - const org = mockOrg({ name: 'Updated Org' }); - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve(org), - }) - ); - const client = createOrgClient(baseConfig); - const result = await client.updateOrg('org_1', { name: 'Updated Org' }); - expect(result.name).toBe('Updated Org'); - - const fetchMock = globalThis.fetch as ReturnType; - expect(fetchMock).toHaveBeenCalledWith( - 'http://localhost:4003/api/orgs/org_1', - expect.objectContaining({ method: 'PATCH' }) - ); - }); - - it('should update a workspace', async () => { - const ws = mockWorkspace({ name: 'Renamed' }); - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve(ws), - }) - ); - const client = createOrgClient(baseConfig); - const result = await client.updateWorkspace('org_1', 'ws_1', { name: 'Renamed' }); - expect(result.name).toBe('Renamed'); - - const fetchMock = globalThis.fetch as ReturnType; - expect(fetchMock).toHaveBeenCalledWith( - 'http://localhost:4003/api/orgs/org_1/workspaces/ws_1', - expect.objectContaining({ method: 'PATCH' }) - ); - }); - - it('should deactivate a license', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - }) - ); - const client = createOrgClient(baseConfig); - await expect( - client.deactivateLicense({ key: 'LYSNR-XXXX', deviceId: 'device-1' }) - ).resolves.toBeUndefined(); - - const fetchMock = globalThis.fetch as ReturnType; - expect(fetchMock).toHaveBeenCalledWith( - 'http://localhost:4003/api/licenses/deactivate', - expect.objectContaining({ method: 'POST' }) - ); - }); - - it('should throw 403 for non-admin', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: false, - status: 403, - }) - ); - const client = createOrgClient(baseConfig); - await expect(client.listOrgs()).rejects.toThrow('listOrgs failed: 403'); - }); - - it('should send correct headers', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve([]), - }) - ); - const client = createOrgClient(baseConfig); - await client.listOrgs(); - - const fetchMock = globalThis.fetch as ReturnType; - const callHeaders = fetchMock.mock.calls[0][1].headers as Record; - expect(callHeaders['x-product-id']).toBe('testapp'); - expect(callHeaders['Authorization']).toBe('Bearer admin-token'); - expect(callHeaders['x-request-id']).toBeDefined(); - }); -}); diff --git a/vendor/bytelyst/org-client/src/client.ts b/vendor/bytelyst/org-client/src/client.ts deleted file mode 100644 index a2135ee..0000000 --- a/vendor/bytelyst/org-client/src/client.ts +++ /dev/null @@ -1,224 +0,0 @@ -/** - * Browser/React Native-safe org, workspace, membership, and license client - * for platform-service. - * - * All org routes require admin-only access (super_admin or admin JWT role). - * No Node.js dependencies — uses globalThis.fetch. - */ - -import type { - LicenseDoc, - MembershipDoc, - OrgClient, - OrgClientConfig, - OrganizationDoc, - WorkspaceDoc, -} from './types.js'; - -function generateRequestId(): string { - return typeof globalThis.crypto?.randomUUID === 'function' - ? globalThis.crypto.randomUUID() - : `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; -} - -export function createOrgClient(config: OrgClientConfig): OrgClient { - const { baseUrl, productId, getAccessToken } = config; - - function headers(): Record { - const h: Record = { - 'Content-Type': 'application/json', - 'x-product-id': productId, - 'x-request-id': generateRequestId(), - }; - const token = getAccessToken(); - if (token) h['Authorization'] = `Bearer ${token}`; - return h; - } - - // ── Organizations ───────────────────────────────── - - async function listOrgs(query?: { status?: string; limit?: number }): Promise { - const params = new URLSearchParams(); - if (query?.status) params.set('status', query.status); - if (query?.limit) params.set('limit', String(query.limit)); - const qs = params.toString(); - const url = qs ? `${baseUrl}/orgs?${qs}` : `${baseUrl}/orgs`; - const res = await globalThis.fetch(url, { headers: headers() }); - if (!res.ok) throw new Error(`listOrgs failed: ${res.status}`); - return (await res.json()) as OrganizationDoc[]; - } - - async function createOrg(input: { - name: string; - slug: string; - ownerUserId?: string; - }): Promise { - const res = await globalThis.fetch(`${baseUrl}/orgs`, { - method: 'POST', - headers: headers(), - body: JSON.stringify({ ...input, productId }), - }); - if (!res.ok) throw new Error(`createOrg failed: ${res.status}`); - return (await res.json()) as OrganizationDoc; - } - - async function getOrg(id: string): Promise { - const res = await globalThis.fetch(`${baseUrl}/orgs/${encodeURIComponent(id)}`, { - headers: headers(), - }); - if (!res.ok) throw new Error(`getOrg failed: ${res.status}`); - return (await res.json()) as OrganizationDoc; - } - - async function updateOrg( - id: string, - updates: Partial - ): Promise { - const res = await globalThis.fetch(`${baseUrl}/orgs/${encodeURIComponent(id)}`, { - method: 'PATCH', - headers: headers(), - body: JSON.stringify(updates), - }); - if (!res.ok) throw new Error(`updateOrg failed: ${res.status}`); - return (await res.json()) as OrganizationDoc; - } - - // ── Workspaces ──────────────────────────────────── - - async function listWorkspaces(orgId: string): Promise { - const res = await globalThis.fetch(`${baseUrl}/orgs/${encodeURIComponent(orgId)}/workspaces`, { - headers: headers(), - }); - if (!res.ok) throw new Error(`listWorkspaces failed: ${res.status}`); - return (await res.json()) as WorkspaceDoc[]; - } - - async function createWorkspace( - orgId: string, - input: { name: string; slug: string; description?: string } - ): Promise { - const res = await globalThis.fetch(`${baseUrl}/orgs/${encodeURIComponent(orgId)}/workspaces`, { - method: 'POST', - headers: headers(), - body: JSON.stringify(input), - }); - if (!res.ok) throw new Error(`createWorkspace failed: ${res.status}`); - return (await res.json()) as WorkspaceDoc; - } - - async function updateWorkspace( - orgId: string, - workspaceId: string, - updates: Partial - ): Promise { - const res = await globalThis.fetch( - `${baseUrl}/orgs/${encodeURIComponent(orgId)}/workspaces/${encodeURIComponent(workspaceId)}`, - { - method: 'PATCH', - headers: headers(), - body: JSON.stringify(updates), - } - ); - if (!res.ok) throw new Error(`updateWorkspace failed: ${res.status}`); - return (await res.json()) as WorkspaceDoc; - } - - // ── Memberships ─────────────────────────────────── - - async function listMemberships( - orgId: string, - query?: { scope?: string; limit?: number } - ): Promise { - const params = new URLSearchParams(); - if (query?.scope) params.set('scope', query.scope); - if (query?.limit) params.set('limit', String(query.limit)); - const qs = params.toString(); - const url = qs - ? `${baseUrl}/orgs/${encodeURIComponent(orgId)}/memberships?${qs}` - : `${baseUrl}/orgs/${encodeURIComponent(orgId)}/memberships`; - const res = await globalThis.fetch(url, { headers: headers() }); - if (!res.ok) throw new Error(`listMemberships failed: ${res.status}`); - return (await res.json()) as MembershipDoc[]; - } - - async function addMember( - orgId: string, - input: { userId: string; role?: string; scope?: string; workspaceId?: string } - ): Promise { - const res = await globalThis.fetch(`${baseUrl}/orgs/${encodeURIComponent(orgId)}/memberships`, { - method: 'POST', - headers: headers(), - body: JSON.stringify(input), - }); - if (!res.ok) throw new Error(`addMember failed: ${res.status}`); - return (await res.json()) as MembershipDoc; - } - - async function updateMember( - orgId: string, - membershipId: string, - updates: { role?: string; status?: string } - ): Promise { - const res = await globalThis.fetch( - `${baseUrl}/orgs/${encodeURIComponent(orgId)}/memberships/${encodeURIComponent(membershipId)}`, - { - method: 'PATCH', - headers: headers(), - body: JSON.stringify(updates), - } - ); - if (!res.ok) throw new Error(`updateMember failed: ${res.status}`); - return (await res.json()) as MembershipDoc; - } - - // ── Licenses ────────────────────────────────────── - - async function generateLicense(input: { - userId: string; - plan: string; - maxDevices?: number; - }): Promise { - const res = await globalThis.fetch(`${baseUrl}/licenses`, { - method: 'POST', - headers: headers(), - body: JSON.stringify({ ...input, productId }), - }); - if (!res.ok) throw new Error(`generateLicense failed: ${res.status}`); - return (await res.json()) as LicenseDoc; - } - - async function activateLicense(input: { key: string; deviceId: string }): Promise { - const res = await globalThis.fetch(`${baseUrl}/licenses/activate`, { - method: 'POST', - headers: headers(), - body: JSON.stringify(input), - }); - if (!res.ok) throw new Error(`activateLicense failed: ${res.status}`); - return (await res.json()) as LicenseDoc; - } - - async function deactivateLicense(input: { key: string; deviceId: string }): Promise { - const res = await globalThis.fetch(`${baseUrl}/licenses/deactivate`, { - method: 'POST', - headers: headers(), - body: JSON.stringify(input), - }); - if (!res.ok) throw new Error(`deactivateLicense failed: ${res.status}`); - } - - return { - listOrgs, - createOrg, - getOrg, - updateOrg, - listWorkspaces, - createWorkspace, - updateWorkspace, - listMemberships, - addMember, - updateMember, - generateLicense, - activateLicense, - deactivateLicense, - }; -} diff --git a/vendor/bytelyst/org-client/src/index.ts b/vendor/bytelyst/org-client/src/index.ts deleted file mode 100644 index 2eaba2e..0000000 --- a/vendor/bytelyst/org-client/src/index.ts +++ /dev/null @@ -1,58 +0,0 @@ -export interface OrgClientOptions { - baseUrl: string; - productId: string; - getAccessToken: () => string; -} - -export interface OrgDoc { - id: string; - name: string; - slug: string; - memberCount: number; - plan: string; - metadata?: Record; -} - -function joinUrl(base: string, path: string): string { - const b = base.replace(/\/$/, ""); - const p = path.startsWith("/") ? path : `/${path}`; - return `${b}${p}`; -} - -function headers(opts: OrgClientOptions): HeadersInit { - return { - Authorization: `Bearer ${opts.getAccessToken()}`, - "X-Product-Id": opts.productId, - Accept: "application/json", - }; -} - -async function parseJson(res: Response): Promise { - if (!res.ok) { - const text = await res.text(); - throw new Error(`HTTP ${res.status}: ${text || res.statusText}`); - } - return res.json() as Promise; -} - -export function createOrgClient(opts: OrgClientOptions) { - const { baseUrl } = opts; - - return { - async listOrgs(): Promise { - const res = await fetch(joinUrl(baseUrl, "/organizations"), { - method: "GET", - headers: headers(opts), - }); - return parseJson(res); - }, - - async getOrg(orgId: string): Promise { - const res = await fetch( - joinUrl(baseUrl, `/organizations/${encodeURIComponent(orgId)}`), - { method: "GET", headers: headers(opts) } - ); - return parseJson(res); - }, - }; -} diff --git a/vendor/bytelyst/org-client/src/types.ts b/vendor/bytelyst/org-client/src/types.ts deleted file mode 100644 index 4ac9a86..0000000 --- a/vendor/bytelyst/org-client/src/types.ts +++ /dev/null @@ -1,113 +0,0 @@ -/** - * Types for @bytelyst/org-client. - * Browser/React Native-safe — no Node.js dependencies. - */ - -export interface OrganizationDoc { - id: string; - productId: string; - name: string; - slug: string; - status: 'active' | 'disabled'; - ownerUserId: string; - metadata?: Record; - createdAt: string; - updatedAt: string; -} - -export interface WorkspaceDoc { - id: string; - orgId: string; - productId: string; - name: string; - slug: string; - status: 'active' | 'archived'; - description?: string; - metadata?: Record; - createdAt: string; - updatedAt: string; -} - -export interface MembershipDoc { - id: string; - orgId: string; - productId: string; - scope: 'org' | 'workspace'; - workspaceId?: string; - userId: string; - role: 'owner' | 'admin' | 'member' | 'viewer'; - status: 'active' | 'invited' | 'disabled'; - invitedBy?: string; - createdAt: string; - updatedAt: string; -} - -export interface LicenseDoc { - id: string; - productId: string; - key: string; - userId: string; - plan: 'free' | 'pro' | 'enterprise'; - status: 'active' | 'revoked' | 'expired'; - activatedAt: string | null; - expiresAt: string | null; - deviceIds: string[]; - maxDevices: number; - createdAt: string; - updatedAt: string; -} - -export interface OrgClientConfig { - /** Platform-service base URL (e.g. "http://localhost:4003/api"). */ - baseUrl: string; - - /** Product identifier sent as x-product-id header on every request. */ - productId: string; - - /** Returns a JWT access token (admin role required), or null. */ - getAccessToken: () => string | null; -} - -export interface OrgClient { - // Organizations - listOrgs(query?: { status?: string; limit?: number }): Promise; - createOrg(input: { name: string; slug: string; ownerUserId?: string }): Promise; - getOrg(id: string): Promise; - updateOrg(id: string, updates: Partial): Promise; - - // Workspaces - listWorkspaces(orgId: string): Promise; - createWorkspace( - orgId: string, - input: { name: string; slug: string; description?: string } - ): Promise; - updateWorkspace( - orgId: string, - workspaceId: string, - updates: Partial - ): Promise; - - // Memberships - listMemberships( - orgId: string, - query?: { scope?: string; limit?: number } - ): Promise; - addMember( - orgId: string, - input: { userId: string; role?: string; scope?: string; workspaceId?: string } - ): Promise; - updateMember( - orgId: string, - membershipId: string, - updates: { role?: string; status?: string } - ): Promise; - - // Licenses - generateLicense(input: { - userId: string; - plan: string; - maxDevices?: number; - }): Promise; - activateLicense(input: { key: string; deviceId: string }): Promise; - deactivateLicense(input: { key: string; deviceId: string }): Promise; -} diff --git a/vendor/bytelyst/org-client/tsconfig.json b/vendor/bytelyst/org-client/tsconfig.json deleted file mode 100644 index 8c5e8c2..0000000 --- a/vendor/bytelyst/org-client/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "outDir": "dist", - "rootDir": "src", - "declaration": true, - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true - }, - "include": ["src"] -} diff --git a/vendor/bytelyst/palace/package.json b/vendor/bytelyst/palace/package.json deleted file mode 100644 index 813a38b..0000000 --- a/vendor/bytelyst/palace/package.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "@bytelyst/palace", - "version": "0.1.4", - "description": "Shared MemPalace primitives — types, cosine similarity, dedup, relevance decay, extraction prompts, KG helpers, wake-up context builder", - "type": "module", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks" - }, - "devDependencies": { - "@types/node": "^22.12.0", - "vitest": "^3.0.5" - }, - "peerDependencies": { - "zod": "^3.0.0" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/palace/src/__tests__/cosine.test.ts b/vendor/bytelyst/palace/src/__tests__/cosine.test.ts deleted file mode 100644 index 4d8a449..0000000 --- a/vendor/bytelyst/palace/src/__tests__/cosine.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { cosineSimilarity, normalizeVector, topKByCosine } from '../cosine.js'; - -describe('cosineSimilarity', () => { - it('returns 1.0 for identical vectors', () => { - expect(cosineSimilarity([1, 0, 0], [1, 0, 0])).toBeCloseTo(1.0); - }); - - it('returns 0.0 for orthogonal vectors', () => { - expect(cosineSimilarity([1, 0], [0, 1])).toBeCloseTo(0.0); - }); - - it('returns -1.0 for opposite vectors', () => { - expect(cosineSimilarity([1, 0], [-1, 0])).toBeCloseTo(-1.0); - }); - - it('returns 0 for empty vectors', () => { - expect(cosineSimilarity([], [])).toBe(0); - }); - - it('returns 0 for mismatched dimensions', () => { - expect(cosineSimilarity([1, 2], [1, 2, 3])).toBe(0); - }); - - it('returns 0 for zero vector', () => { - expect(cosineSimilarity([0, 0, 0], [1, 2, 3])).toBe(0); - }); - - it('computes correct similarity for arbitrary vectors', () => { - const a = [1, 2, 3]; - const b = [4, 5, 6]; - // Known value: (4+10+18) / (sqrt(14)*sqrt(77)) ≈ 0.9746 - expect(cosineSimilarity(a, b)).toBeCloseTo(0.9746, 3); - }); -}); - -describe('normalizeVector', () => { - it('normalizes to unit length', () => { - const v = normalizeVector([3, 4]); - const magnitude = Math.sqrt(v[0] ** 2 + v[1] ** 2); - expect(magnitude).toBeCloseTo(1.0); - }); - - it('returns zero vector for zero input', () => { - expect(normalizeVector([0, 0, 0])).toEqual([0, 0, 0]); - }); - - it('preserves direction', () => { - const v = normalizeVector([2, 0, 0]); - expect(v[0]).toBeCloseTo(1); - expect(v[1]).toBeCloseTo(0); - expect(v[2]).toBeCloseTo(0); - }); -}); - -describe('topKByCosine', () => { - const items = [ - { id: 'a', emb: [1, 0, 0] }, - { id: 'b', emb: [0, 1, 0] }, - { id: 'c', emb: [0.9, 0.1, 0] }, - { id: 'd', emb: undefined as number[] | undefined }, - ]; - - it('returns top-K sorted by score', () => { - const results = topKByCosine([1, 0, 0], items, i => i.emb, 2); - expect(results).toHaveLength(2); - expect(results[0].item.id).toBe('a'); - expect(results[0].score).toBeCloseTo(1.0); - expect(results[1].item.id).toBe('c'); - }); - - it('skips items with missing embeddings', () => { - const results = topKByCosine([1, 0, 0], items, i => i.emb, 10); - expect(results).toHaveLength(3); // 'd' skipped - }); - - it('respects minScore filter', () => { - const results = topKByCosine([1, 0, 0], items, i => i.emb, 10, 0.5); - expect(results.every(r => r.score >= 0.5)).toBe(true); - }); - - it('returns empty array for empty items', () => { - const results = topKByCosine([1, 0, 0], [], () => undefined, 5); - expect(results).toEqual([]); - }); -}); diff --git a/vendor/bytelyst/palace/src/__tests__/decay.test.ts b/vendor/bytelyst/palace/src/__tests__/decay.test.ts deleted file mode 100644 index dff95bd..0000000 --- a/vendor/bytelyst/palace/src/__tests__/decay.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { computeDecayedRelevance, boostRelevance } from '../decay.js'; - -describe('computeDecayedRelevance', () => { - it('returns original relevance for just-created memory', () => { - const now = new Date(); - expect(computeDecayedRelevance(0.8, now.toISOString(), 30, now)).toBeCloseTo(0.8); - }); - - it('halves relevance after exactly one half-life', () => { - const now = new Date(); - const thirtyDaysAgo = new Date(now.getTime() - 30 * 86_400_000); - expect(computeDecayedRelevance(1.0, thirtyDaysAgo.toISOString(), 30, now)).toBeCloseTo(0.5, 2); - }); - - it('quarters relevance after two half-lives', () => { - const now = new Date(); - const sixtyDaysAgo = new Date(now.getTime() - 60 * 86_400_000); - expect(computeDecayedRelevance(1.0, sixtyDaysAgo.toISOString(), 30, now)).toBeCloseTo(0.25, 2); - }); - - it('returns 0 for zero relevance', () => { - expect(computeDecayedRelevance(0, '2024-01-01')).toBe(0); - }); - - it('clamps to [0, 1]', () => { - const now = new Date(); - expect(computeDecayedRelevance(1.5, now.toISOString(), 30, now)).toBeLessThanOrEqual(1); - }); - - it('returns original relevance for zero half-life', () => { - expect(computeDecayedRelevance(0.8, '2024-01-01', 0)).toBe(0.8); - }); - - it('accepts Date objects', () => { - const now = new Date(); - expect(computeDecayedRelevance(0.8, now, 30, now)).toBeCloseTo(0.8); - }); -}); - -describe('boostRelevance', () => { - it('boosts relevance toward 1.0', () => { - expect(boostRelevance(0.5, 0.3)).toBeCloseTo(0.65); - }); - - it('does not exceed 1.0', () => { - expect(boostRelevance(0.99, 0.5)).toBeLessThanOrEqual(1.0); - }); - - it('returns 0 for zero relevance with zero boost', () => { - expect(boostRelevance(0, 0)).toBe(0); - }); - - it('applies default boost factor of 0.3', () => { - expect(boostRelevance(0.5)).toBeCloseTo(0.65); - }); -}); diff --git a/vendor/bytelyst/palace/src/__tests__/dedup.test.ts b/vendor/bytelyst/palace/src/__tests__/dedup.test.ts deleted file mode 100644 index 680cc32..0000000 --- a/vendor/bytelyst/palace/src/__tests__/dedup.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { isContentDuplicate, isExactDuplicate, findClosestMatch } from '../dedup.js'; - -describe('isContentDuplicate', () => { - const baseEmbedding = [1, 0, 0]; - const similarEmbedding = [0.99, 0.1, 0]; // very similar to base - const differentEmbedding = [0, 1, 0]; // orthogonal - - it('detects near-duplicate above threshold', () => { - expect(isContentDuplicate(baseEmbedding, [similarEmbedding], 0.9)).toBe(true); - }); - - it('rejects non-duplicate below threshold', () => { - expect(isContentDuplicate(baseEmbedding, [differentEmbedding], 0.9)).toBe(false); - }); - - it('returns false for empty existing embeddings', () => { - expect(isContentDuplicate(baseEmbedding, [], 0.9)).toBe(false); - }); - - it('skips embeddings with mismatched dimensions', () => { - expect(isContentDuplicate([1, 0], [[1, 0, 0]], 0.9)).toBe(false); - }); - - it('uses default threshold of 0.90', () => { - expect(isContentDuplicate(baseEmbedding, [similarEmbedding])).toBe(true); - }); -}); - -describe('isExactDuplicate', () => { - it('detects exact match', () => { - expect(isExactDuplicate('Hello World', 'Hello World')).toBe(true); - }); - - it('is case-insensitive', () => { - expect(isExactDuplicate('Hello', 'hello')).toBe(true); - }); - - it('trims whitespace', () => { - expect(isExactDuplicate(' hello ', 'hello')).toBe(true); - }); - - it('rejects different content', () => { - expect(isExactDuplicate('Hello', 'World')).toBe(false); - }); -}); - -describe('findClosestMatch', () => { - it('finds the closest embedding', () => { - const result = findClosestMatch( - [1, 0, 0], - [ - [0, 1, 0], - [0.9, 0.1, 0], - [0, 0, 1], - ] - ); - expect(result).not.toBeNull(); - expect(result!.index).toBe(1); - expect(result!.score).toBeGreaterThan(0.9); - }); - - it('returns null for empty embeddings', () => { - expect(findClosestMatch([1, 0], [])).toBeNull(); - }); - - it('returns null when no embedding exceeds minScore', () => { - expect(findClosestMatch([1, 0, 0], [[0, 1, 0]], 0.99)).toBeNull(); - }); -}); diff --git a/vendor/bytelyst/palace/src/__tests__/extraction.test.ts b/vendor/bytelyst/palace/src/__tests__/extraction.test.ts deleted file mode 100644 index a787dc7..0000000 --- a/vendor/bytelyst/palace/src/__tests__/extraction.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - buildExtractionPrompt, - parseExtractionResponse, - regexFallbackExtraction, -} from '../extraction.js'; - -describe('buildExtractionPrompt', () => { - it('includes hall types in the prompt', () => { - const prompt = buildExtractionPrompt('some content', { - hallTypes: ['decisions', 'events', 'discoveries'], - }); - expect(prompt).toContain('decisions, events, discoveries'); - expect(prompt).toContain('some content'); - }); - - it('includes title when provided', () => { - const prompt = buildExtractionPrompt('content', { - title: 'My Note', - hallTypes: ['decisions'], - }); - expect(prompt).toContain('Title: My Note'); - }); - - it('includes context when provided', () => { - const prompt = buildExtractionPrompt('content', { - context: 'Work brain', - hallTypes: ['decisions'], - }); - expect(prompt).toContain('Context: Work brain'); - }); - - it('omits title/context when not provided', () => { - const prompt = buildExtractionPrompt('content', { - hallTypes: ['decisions'], - }); - expect(prompt).not.toContain('Title:'); - expect(prompt).not.toContain('Context:'); - }); -}); - -describe('parseExtractionResponse', () => { - it('parses valid JSON array', () => { - const input = JSON.stringify([ - { hall: 'decisions', content: 'Use TypeScript', roomSlug: 'tech', entities: ['TypeScript'] }, - ]); - const result = parseExtractionResponse(input); - expect(result).toHaveLength(1); - expect(result[0].hall).toBe('decisions'); - expect(result[0].content).toBe('Use TypeScript'); - expect(result[0].roomSlug).toBe('tech'); - expect(result[0].entities).toEqual(['TypeScript']); - }); - - it('handles JSON wrapped in code fences', () => { - const input = - '```json\n[{"hall":"events","content":"Released v2","roomSlug":"releases","entities":[]}]\n```'; - const result = parseExtractionResponse(input); - expect(result).toHaveLength(1); - expect(result[0].hall).toBe('events'); - }); - - it('returns empty array for malformed JSON', () => { - expect(parseExtractionResponse('not json at all')).toEqual([]); - }); - - it('returns empty array for empty string', () => { - expect(parseExtractionResponse('')).toEqual([]); - }); - - it('filters out items missing required fields', () => { - const input = JSON.stringify([{ hall: 'decisions', content: 'valid' }, { noHall: true }]); - const result = parseExtractionResponse(input); - expect(result).toHaveLength(1); - }); - - it('defaults roomSlug to general', () => { - const input = JSON.stringify([{ hall: 'events', content: 'something' }]); - const result = parseExtractionResponse(input); - expect(result[0].roomSlug).toBe('general'); - }); - - it('handles room_slug snake_case variant', () => { - const input = JSON.stringify([{ hall: 'events', content: 'x', room_slug: 'my-room' }]); - const result = parseExtractionResponse(input); - expect(result[0].roomSlug).toBe('my-room'); - }); -}); - -describe('regexFallbackExtraction', () => { - it('extracts Decision: lines', () => { - const result = regexFallbackExtraction('Decision: Use Cosmos DB for storage'); - expect(result).toHaveLength(1); - expect(result[0].hall).toBe('decisions'); - expect(result[0].content).toContain('Use Cosmos DB'); - }); - - it('extracts TODO: lines', () => { - const result = regexFallbackExtraction('TODO: Fix the auth bug'); - expect(result).toHaveLength(1); - expect(result[0].hall).toBe('decisions'); - }); - - it('extracts Found: lines as discoveries', () => { - const result = regexFallbackExtraction('Found: New API endpoint for search'); - expect(result).toHaveLength(1); - expect(result[0].hall).toBe('discoveries'); - }); - - it('extracts @mentions as entities', () => { - const result = regexFallbackExtraction('Decision: @alice proposed the new schema'); - expect(result[0].entities).toContain('alice'); - }); - - it('extracts #tags as entities', () => { - const result = regexFallbackExtraction('Found: #typescript has better types'); - expect(result[0].entities).toContain('typescript'); - }); - - it('returns empty array for plain text without patterns', () => { - expect(regexFallbackExtraction('Just some random text here')).toEqual([]); - }); - - it('handles multiple patterns in multi-line content', () => { - const content = `Decision: Adopt ESM -Found: Vitest is faster -TODO: Migrate tests`; - const result = regexFallbackExtraction(content); - expect(result).toHaveLength(3); - }); -}); diff --git a/vendor/bytelyst/palace/src/__tests__/halls.test.ts b/vendor/bytelyst/palace/src/__tests__/halls.test.ts deleted file mode 100644 index dd07b0c..0000000 --- a/vendor/bytelyst/palace/src/__tests__/halls.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { HALL_PRESETS, getHallPreset, hallFromLabel, ALL_HALL_TYPES } from '../halls.js'; - -describe('HALL_PRESETS', () => { - it('notelett has 6 halls', () => { - expect(HALL_PRESETS.notelett.halls).toHaveLength(6); - expect(HALL_PRESETS.notelett.halls).toContain('insights'); - expect(HALL_PRESETS.notelett.halls).not.toContain('errors'); - }); - - it('mindlyst has 6 halls with patterns and emotions', () => { - expect(HALL_PRESETS.mindlyst.halls).toHaveLength(6); - expect(HALL_PRESETS.mindlyst.halls).toContain('patterns'); - expect(HALL_PRESETS.mindlyst.halls).toContain('emotions'); - }); - - it('coding has 6 halls with errors and advice', () => { - expect(HALL_PRESETS.coding.halls).toHaveLength(6); - expect(HALL_PRESETS.coding.halls).toContain('errors'); - expect(HALL_PRESETS.coding.halls).toContain('advice'); - }); -}); - -describe('getHallPreset', () => { - it('returns preset by name', () => { - expect(getHallPreset('notelett')?.name).toBe('NoteLett'); - }); - - it('returns undefined for unknown preset', () => { - expect(getHallPreset('nonexistent')).toBeUndefined(); - }); -}); - -describe('hallFromLabel', () => { - it('matches exact hall type', () => { - expect(hallFromLabel('decisions')).toBe('decisions'); - }); - - it('is case-insensitive', () => { - expect(hallFromLabel('DECISIONS')).toBe('decisions'); - }); - - it('matches singular form via synonym', () => { - expect(hallFromLabel('decision')).toBe('decisions'); - }); - - it('maps synonyms to halls', () => { - expect(hallFromLabel('bug')).toBe('errors'); - expect(hallFromLabel('feeling')).toBe('emotions'); - expect(hallFromLabel('tip')).toBe('advice'); - expect(hallFromLabel('trend')).toBe('patterns'); - expect(hallFromLabel('fact')).toBe('discoveries'); - }); - - it('respects allowedHalls filter', () => { - // 'errors' is not in notelett preset - expect(hallFromLabel('bug', HALL_PRESETS.notelett.halls)).toBeUndefined(); - // 'errors' is in coding preset - expect(hallFromLabel('bug', HALL_PRESETS.coding.halls)).toBe('errors'); - }); - - it('returns undefined for unrecognized label', () => { - expect(hallFromLabel('xyzzy')).toBeUndefined(); - }); - - it('trims whitespace', () => { - expect(hallFromLabel(' events ')).toBe('events'); - }); -}); - -describe('ALL_HALL_TYPES', () => { - it('has 9 total hall types', () => { - expect(ALL_HALL_TYPES).toHaveLength(9); - }); -}); diff --git a/vendor/bytelyst/palace/src/__tests__/kg.test.ts b/vendor/bytelyst/palace/src/__tests__/kg.test.ts deleted file mode 100644 index 40590f1..0000000 --- a/vendor/bytelyst/palace/src/__tests__/kg.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { findContradictions, mergeTriples, isTripleCurrent } from '../kg.js'; -import type { TripleInput } from '../kg.js'; - -const now = new Date('2025-06-01T00:00:00Z'); - -describe('isTripleCurrent', () => { - it('returns true for triple with no validTo', () => { - expect(isTripleCurrent({ validTo: undefined }, now)).toBe(true); - }); - - it('returns true for triple with future validTo', () => { - expect(isTripleCurrent({ validTo: '2026-01-01T00:00:00Z' }, now)).toBe(true); - }); - - it('returns false for triple with past validTo', () => { - expect(isTripleCurrent({ validTo: '2024-01-01T00:00:00Z' }, now)).toBe(false); - }); -}); - -describe('findContradictions', () => { - it('detects contradiction (same subject+predicate, different object)', () => { - const existing: TripleInput[] = [ - { subject: 'App', predicate: 'uses', object: 'PostgreSQL', validFrom: '2025-01-01' }, - ]; - const incoming: TripleInput[] = [ - { subject: 'App', predicate: 'uses', object: 'Cosmos DB', validFrom: '2025-06-01' }, - ]; - const result = findContradictions(existing, incoming, now); - expect(result).toHaveLength(1); - expect(result[0].existing.object).toBe('PostgreSQL'); - expect(result[0].incoming.object).toBe('Cosmos DB'); - }); - - it('does not flag same triple as contradiction', () => { - const existing: TripleInput[] = [ - { subject: 'App', predicate: 'uses', object: 'Cosmos DB', validFrom: '2025-01-01' }, - ]; - const incoming: TripleInput[] = [ - { subject: 'App', predicate: 'uses', object: 'Cosmos DB', validFrom: '2025-06-01' }, - ]; - expect(findContradictions(existing, incoming, now)).toHaveLength(0); - }); - - it('ignores expired triples', () => { - const existing: TripleInput[] = [ - { - subject: 'App', - predicate: 'uses', - object: 'PostgreSQL', - validFrom: '2024-01-01', - validTo: '2024-12-31', - }, - ]; - const incoming: TripleInput[] = [ - { subject: 'App', predicate: 'uses', object: 'Cosmos DB', validFrom: '2025-06-01' }, - ]; - expect(findContradictions(existing, incoming, now)).toHaveLength(0); - }); - - it('is case-insensitive', () => { - const existing: TripleInput[] = [ - { subject: 'app', predicate: 'Uses', object: 'PostgreSQL', validFrom: '2025-01-01' }, - ]; - const incoming: TripleInput[] = [ - { subject: 'App', predicate: 'uses', object: 'Cosmos DB', validFrom: '2025-06-01' }, - ]; - expect(findContradictions(existing, incoming, now)).toHaveLength(1); - }); -}); - -describe('mergeTriples', () => { - it('adds non-conflicting triples', () => { - const existing: TripleInput[] = [ - { subject: 'App', predicate: 'uses', object: 'TypeScript', validFrom: '2025-01-01' }, - ]; - const incoming: TripleInput[] = [ - { subject: 'App', predicate: 'runs-on', object: 'Node.js', validFrom: '2025-06-01' }, - ]; - const result = mergeTriples(existing, incoming, now); - expect(result.added).toHaveLength(1); - expect(result.skipped).toHaveLength(0); - expect(result.invalidated).toHaveLength(0); - expect(result.merged).toHaveLength(2); - }); - - it('skips duplicate triples', () => { - const existing: TripleInput[] = [ - { subject: 'App', predicate: 'uses', object: 'TypeScript', validFrom: '2025-01-01' }, - ]; - const incoming: TripleInput[] = [ - { subject: 'App', predicate: 'uses', object: 'TypeScript', validFrom: '2025-06-01' }, - ]; - const result = mergeTriples(existing, incoming, now); - expect(result.skipped).toHaveLength(1); - expect(result.added).toHaveLength(0); - }); - - it('invalidates contradicted triples', () => { - const existing: TripleInput[] = [ - { subject: 'App', predicate: 'uses', object: 'PostgreSQL', validFrom: '2025-01-01' }, - ]; - const incoming: TripleInput[] = [ - { subject: 'App', predicate: 'uses', object: 'Cosmos DB', validFrom: '2025-06-01' }, - ]; - const result = mergeTriples(existing, incoming, now); - expect(result.invalidated).toHaveLength(1); - expect(result.added).toHaveLength(1); - // The invalidated triple should have validTo set - const invalidated = result.merged.find(t => t.object === 'PostgreSQL'); - expect(invalidated?.validTo).toBeDefined(); - }); -}); diff --git a/vendor/bytelyst/palace/src/__tests__/wakeup.test.ts b/vendor/bytelyst/palace/src/__tests__/wakeup.test.ts deleted file mode 100644 index a4e58b4..0000000 --- a/vendor/bytelyst/palace/src/__tests__/wakeup.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - buildWakeUpLayers, - truncateToTokenBudget, - estimateTokens, - WAKEUP_PRESETS, -} from '../wakeup.js'; - -describe('estimateTokens', () => { - it('estimates ~4 chars per token', () => { - expect(estimateTokens('abcd')).toBe(1); - expect(estimateTokens('abcdefgh')).toBe(2); - }); - - it('returns 0 for empty string', () => { - expect(estimateTokens('')).toBe(0); - }); -}); - -describe('truncateToTokenBudget', () => { - it('returns unchanged text if within budget', () => { - expect(truncateToTokenBudget('short', 100)).toBe('short'); - }); - - it('truncates long text with ellipsis', () => { - const longText = 'a'.repeat(1000); - const result = truncateToTokenBudget(longText, 10); // 40 chars max - expect(result.length).toBeLessThanOrEqual(43); // 40 + '...' - expect(result.endsWith('...')).toBe(true); - }); - - it('returns empty string for empty input', () => { - expect(truncateToTokenBudget('', 100)).toBe(''); - }); -}); - -describe('buildWakeUpLayers', () => { - const config = WAKEUP_PRESETS.notelett; // 600 total, 50/150/400 - - it('assembles all three layers', () => { - const result = buildWakeUpLayers( - 'Project: NoteLett', - 'Key fact: Uses Cosmos DB', - 'Related: User asked about search', - config - ); - expect(result.text).toContain('[Identity]'); - expect(result.text).toContain('[Critical Facts]'); - expect(result.text).toContain('[Relevant Memories]'); - expect(result.layers).toHaveLength(3); - }); - - it('handles empty L0', () => { - const result = buildWakeUpLayers('', 'facts', 'memories', config); - expect(result.layers.find(l => l.label === 'L0:identity')).toBeUndefined(); - expect(result.layers).toHaveLength(2); - }); - - it('handles all empty layers gracefully', () => { - const result = buildWakeUpLayers('', '', '', config); - expect(result.text).toBe(''); - expect(result.layers).toHaveLength(0); - expect(result.truncated).toBe(false); - }); - - it('marks truncated when content exceeds budget', () => { - const longL2 = 'x '.repeat(5000); - const result = buildWakeUpLayers('id', 'fact', longL2, config); - expect(result.truncated).toBe(true); - }); - - it('respects total budget', () => { - const result = buildWakeUpLayers( - 'identity context here', - 'critical facts here', - 'semantic memories here', - config - ); - const tokens = estimateTokens(result.text); - expect(tokens).toBeLessThanOrEqual(config.totalBudget + 10); // small margin for headers - }); -}); - -describe('WAKEUP_PRESETS', () => { - it('has notelett preset with 600 budget', () => { - expect(WAKEUP_PRESETS.notelett.totalBudget).toBe(600); - }); - - it('has mindlyst preset with 800 budget', () => { - expect(WAKEUP_PRESETS.mindlyst.totalBudget).toBe(800); - }); - - it('has coding preset with 800 budget', () => { - expect(WAKEUP_PRESETS.coding.totalBudget).toBe(800); - }); -}); diff --git a/vendor/bytelyst/palace/src/config.ts b/vendor/bytelyst/palace/src/config.ts deleted file mode 100644 index 569c9b5..0000000 --- a/vendor/bytelyst/palace/src/config.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Palace configuration schema — Zod-based env var validation. - * - * Products extend their backend config with these palace-specific vars. - */ - -import { z } from 'zod'; - -export const palaceConfigSchema = z.object({ - PALACE_ENABLED: z - .enum(['true', 'false']) - .default('true') - .transform(v => v === 'true'), - - PALACE_WAKE_UP_BUDGET: z.coerce.number().default(800), - - PALACE_DEDUP_THRESHOLD: z.coerce.number().min(0).max(1).default(0.9), - - PALACE_RELEVANCE_HALF_LIFE_DAYS: z.coerce.number().min(1).default(30), - - PALACE_MAX_MEMORIES_PER_SEARCH: z.coerce.number().min(1).default(20), - - PALACE_EXTRACTION_MAX_CHARS: z.coerce.number().min(100).default(6000), -}); - -export type PalaceConfig = z.infer; - -/** - * Parse palace config from environment. - * Products typically merge this with their own config schema. - */ -export function parsePalaceConfig( - env: Record = process.env as Record -): PalaceConfig { - return palaceConfigSchema.parse(env); -} diff --git a/vendor/bytelyst/palace/src/cosine.ts b/vendor/bytelyst/palace/src/cosine.ts deleted file mode 100644 index c6b5ae2..0000000 --- a/vendor/bytelyst/palace/src/cosine.ts +++ /dev/null @@ -1,70 +0,0 @@ -/** - * Vector similarity utilities for semantic search and deduplication. - */ - -/** - * Compute cosine similarity between two vectors. - * Returns a value between -1 and 1 (1 = identical direction). - * Returns 0 if either vector is zero-length or dimensions don't match. - */ -export function cosineSimilarity(a: number[], b: number[]): number { - if (a.length !== b.length || a.length === 0) return 0; - - let dotProduct = 0; - let normA = 0; - let normB = 0; - - for (let i = 0; i < a.length; i++) { - dotProduct += a[i] * b[i]; - normA += a[i] * a[i]; - normB += b[i] * b[i]; - } - - const denominator = Math.sqrt(normA) * Math.sqrt(normB); - if (denominator === 0) return 0; - - return dotProduct / denominator; -} - -/** - * Normalize a vector to unit length (magnitude = 1). - * Returns a zero vector if input is zero-length. - */ -export function normalizeVector(v: number[]): number[] { - const magnitude = Math.sqrt(v.reduce((sum, val) => sum + val * val, 0)); - if (magnitude === 0) return v.map(() => 0); - return v.map(val => val / magnitude); -} - -/** - * Find the top-K most similar items to a query vector. - * - * @param query - The query embedding vector - * @param items - Array of items to search - * @param getEmbedding - Function to extract embedding from an item (returns undefined if missing) - * @param k - Maximum number of results to return - * @param minScore - Minimum cosine similarity score (default: 0) - * @returns Sorted array of { item, score } pairs, highest score first - */ -export function topKByCosine( - query: number[], - items: T[], - getEmbedding: (item: T) => number[] | undefined, - k: number, - minScore = 0 -): Array<{ item: T; score: number }> { - const scored: Array<{ item: T; score: number }> = []; - - for (const item of items) { - const embedding = getEmbedding(item); - if (!embedding || embedding.length === 0) continue; - - const score = cosineSimilarity(query, embedding); - if (score >= minScore) { - scored.push({ item, score }); - } - } - - scored.sort((a, b) => b.score - a.score); - return scored.slice(0, k); -} diff --git a/vendor/bytelyst/palace/src/decay.ts b/vendor/bytelyst/palace/src/decay.ts deleted file mode 100644 index 122e85f..0000000 --- a/vendor/bytelyst/palace/src/decay.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * Relevance decay for palace memories. - * - * Uses exponential half-life decay: relevance halves every N days. - * Memories that are accessed/referenced get their relevance boosted. - */ - -const MS_PER_DAY = 86_400_000; - -/** - * Compute decayed relevance using exponential half-life. - * - * @param originalRelevance - Initial relevance score (0-1) - * @param createdAt - ISO date string or Date when the memory was created/last accessed - * @param halfLifeDays - Number of days for relevance to halve (default: 30) - * @param asOf - Reference time for decay calculation (default: now) - * @returns Decayed relevance score (0-1) - */ -export function computeDecayedRelevance( - originalRelevance: number, - createdAt: string | Date, - halfLifeDays = 30, - asOf: Date = new Date() -): number { - if (halfLifeDays <= 0) return originalRelevance; - if (originalRelevance <= 0) return 0; - - const created = typeof createdAt === 'string' ? new Date(createdAt) : createdAt; - const elapsedMs = asOf.getTime() - created.getTime(); - - if (elapsedMs <= 0) return Math.min(originalRelevance, 1); - - const elapsedDays = elapsedMs / MS_PER_DAY; - const decayFactor = Math.pow(0.5, elapsedDays / halfLifeDays); - - return Math.max(0, Math.min(1, originalRelevance * decayFactor)); -} - -/** - * Compute a boosted relevance for a memory that was accessed/referenced. - * - * Boost formula: new = old + (1 - old) * boostFactor - * This asymptotically approaches 1.0 without exceeding it. - * - * @param currentRelevance - Current relevance score (0-1) - * @param boostFactor - How much of the remaining gap to close (default: 0.3) - * @returns Boosted relevance score (0-1) - */ -export function boostRelevance(currentRelevance: number, boostFactor = 0.3): number { - const boosted = currentRelevance + (1 - currentRelevance) * boostFactor; - return Math.min(1, Math.max(0, boosted)); -} diff --git a/vendor/bytelyst/palace/src/dedup.ts b/vendor/bytelyst/palace/src/dedup.ts deleted file mode 100644 index 9fe604e..0000000 --- a/vendor/bytelyst/palace/src/dedup.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** - * Deduplication utilities for palace memories. - * - * Detects near-duplicate content using cosine similarity over embeddings. - * Products handle the Cosmos/DB queries; this module operates on pure data. - */ - -import { cosineSimilarity } from './cosine.js'; - -/** - * Check if a candidate embedding is a near-duplicate of any existing embedding. - * - * @param candidate - Embedding of the new memory - * @param existingEmbeddings - Embeddings of existing memories in the same room/hall - * @param threshold - Cosine similarity threshold (default: 0.90) - * @returns true if any existing embedding exceeds the threshold - */ -export function isContentDuplicate( - candidate: number[], - existingEmbeddings: number[][], - threshold = 0.9 -): boolean { - for (const existing of existingEmbeddings) { - if (existing.length !== candidate.length) continue; - if (cosineSimilarity(candidate, existing) > threshold) { - return true; - } - } - return false; -} - -/** - * Check if two text strings are exact duplicates after normalization. - * Trims whitespace and lowercases before comparison. - */ -export function isExactDuplicate(a: string, b: string): boolean { - return a.trim().toLowerCase() === b.trim().toLowerCase(); -} - -/** - * Find the most similar embedding and return its index + score. - * Returns null if no embeddings exist or none exceed minScore. - */ -export function findClosestMatch( - candidate: number[], - existingEmbeddings: number[][], - minScore = 0 -): { index: number; score: number } | null { - let bestIndex = -1; - let bestScore = minScore; - - for (let i = 0; i < existingEmbeddings.length; i++) { - const existing = existingEmbeddings[i]; - if (existing.length !== candidate.length) continue; - - const score = cosineSimilarity(candidate, existing); - if (score > bestScore) { - bestScore = score; - bestIndex = i; - } - } - - return bestIndex >= 0 ? { index: bestIndex, score: bestScore } : null; -} diff --git a/vendor/bytelyst/palace/src/extraction.ts b/vendor/bytelyst/palace/src/extraction.ts deleted file mode 100644 index bd3c304..0000000 --- a/vendor/bytelyst/palace/src/extraction.ts +++ /dev/null @@ -1,154 +0,0 @@ -/** - * Memory extraction utilities — prompt building, response parsing, regex fallback. - * - * Products call their own LLM provider with the prompt from buildExtractionPrompt(), - * then pass the response to parseExtractionResponse(). - * If LLM is unavailable, regexFallbackExtraction() provides basic extraction. - */ - -import type { ExtractedMemory } from './types.js'; - -export interface ExtractionContext { - title?: string; - context?: string; - hallTypes: readonly string[]; -} - -/** - * Build a structured extraction prompt for an LLM. - * - * @param content - The text content to extract memories from - * @param ctx - Context including title, additional context, and allowed hall types - * @returns A system/user prompt string ready for LLM chat() - */ -export function buildExtractionPrompt(content: string, ctx: ExtractionContext): string { - const hallList = ctx.hallTypes.join(', '); - const titleLine = ctx.title ? `\nTitle: ${ctx.title}` : ''; - const contextLine = ctx.context ? `\nContext: ${ctx.context}` : ''; - - return `Extract structured memories from the following content. - -For each distinct memory, return a JSON array where each element has: -- "hall": one of [${hallList}] -- "content": the memory summarized in 1-2 sentences -- "roomSlug": a short kebab-case topic slug (e.g. "auth-migration", "api-design") -- "entities": array of named entities mentioned (people, projects, technologies, places) - -Rules: -- Only extract genuinely important or referenceable facts, decisions, or events -- Skip trivial or obvious statements -- Each memory should be self-contained (understandable without the original context) -- Prefer specific details over vague summaries -- Return valid JSON only — no markdown fences, no explanation${titleLine}${contextLine} - -Content: -${content}`; -} - -/** - * Parse an LLM extraction response into ExtractedMemory[]. - * - * Handles: - * - Clean JSON arrays - * - JSON wrapped in markdown code fences - * - Malformed JSON (returns empty array) - */ -export function parseExtractionResponse(llmOutput: string): ExtractedMemory[] { - if (!llmOutput || llmOutput.trim().length === 0) return []; - - let cleaned = llmOutput.trim(); - - // Strip markdown code fences if present - if (cleaned.startsWith('```')) { - cleaned = cleaned.replace(/^```(?:json)?\s*\n?/, '').replace(/\n?```\s*$/, ''); - } - - try { - const parsed = JSON.parse(cleaned); - - if (!Array.isArray(parsed)) return []; - - return parsed - .filter( - (item: unknown): item is Record => - typeof item === 'object' && item !== null && 'hall' in item && 'content' in item - ) - .map(item => ({ - hall: String(item.hall || ''), - content: String(item.content || ''), - roomSlug: String(item.roomSlug || item.room_slug || 'general'), - entities: Array.isArray(item.entities) ? item.entities.map(String) : [], - })); - } catch { - return []; - } -} - -/** - * Regex-based fallback extraction when LLM is unavailable. - * - * Scans for common patterns: - * - "Decision:" / "Decided:" → decisions - * - "TODO:" / "Action:" → decisions - * - "Found:" / "Discovered:" / "Learned:" → discoveries - * - "Prefer:" / "Always:" / "Never:" → preferences - * - "Event:" / "Happened:" / date patterns → events - * - "Tip:" / "Note:" / "Remember:" → advice - * - * @param content - Raw text content - * @returns Array of extracted memories (best-effort) - */ -export function regexFallbackExtraction(content: string): ExtractedMemory[] { - const memories: ExtractedMemory[] = []; - const lines = content.split('\n'); - - const patterns: Array<{ regex: RegExp; hall: string }> = [ - { regex: /^(?:decision|decided|resolve[ds]?):\s*(.+)/i, hall: 'decisions' }, - { regex: /^(?:todo|action|task):\s*(.+)/i, hall: 'decisions' }, - { regex: /^(?:found|discovered|learned|til):\s*(.+)/i, hall: 'discoveries' }, - { regex: /^(?:prefer|always|never):\s*(.+)/i, hall: 'preferences' }, - { regex: /^(?:event|happened|occurred):\s*(.+)/i, hall: 'events' }, - { regex: /^(?:tip|note|remember|important):\s*(.+)/i, hall: 'advice' }, - { regex: /^(?:error|bug|issue|broken):\s*(.+)/i, hall: 'errors' }, - { regex: /^(?:pattern|recurring|trend):\s*(.+)/i, hall: 'patterns' }, - { regex: /^(?:feeling|mood|emotion):\s*(.+)/i, hall: 'emotions' }, - { regex: /^(?:insight|observation|noticed):\s*(.+)/i, hall: 'insights' }, - ]; - - for (const line of lines) { - const trimmed = line.replace(/^[\s\-*>#]+/, '').trim(); - if (!trimmed) continue; - - for (const { regex, hall } of patterns) { - const match = trimmed.match(regex); - if (match && match[1]) { - memories.push({ - hall, - content: match[1].trim(), - roomSlug: 'general', - entities: extractEntities(match[1]), - }); - break; - } - } - } - - return memories; -} - -/** - * Extract simple entities from text (mentions, tags, capitalized phrases). - */ -function extractEntities(text: string): string[] { - const entities = new Set(); - - // @mentions - const mentions = text.match(/@(\w+)/g); - if (mentions) mentions.forEach(m => entities.add(m.slice(1))); - - // #tags - const tags = text.match(/#(\w+)/g); - if (tags) tags.forEach(t => entities.add(t.slice(1))); - - return Array.from(entities); -} diff --git a/vendor/bytelyst/palace/src/halls.ts b/vendor/bytelyst/palace/src/halls.ts deleted file mode 100644 index 3e4220f..0000000 --- a/vendor/bytelyst/palace/src/halls.ts +++ /dev/null @@ -1,103 +0,0 @@ -/** - * Hall types and presets for different products. - * - * Each product picks a preset (or defines custom halls). - * Halls categorize memories by type — decisions, events, discoveries, etc. - */ - -export const ALL_HALL_TYPES = [ - 'decisions', - 'events', - 'discoveries', - 'preferences', - 'advice', - 'insights', - 'patterns', - 'emotions', - 'errors', -] as const; - -export type HallType = (typeof ALL_HALL_TYPES)[number]; - -export interface HallPreset { - name: string; - halls: HallType[]; -} - -/** - * Product-specific hall presets. - * - * - notelett: insights instead of errors (note-taking domain) - * - mindlyst: patterns + emotions (multimodal/emotional domain) - * - coding: errors + advice (developer/agent domain, e.g. Claw-Cowork) - */ -export const HALL_PRESETS: Record = { - notelett: { - name: 'NoteLett', - halls: ['decisions', 'events', 'discoveries', 'preferences', 'advice', 'insights'], - }, - mindlyst: { - name: 'MindLyst', - halls: ['decisions', 'events', 'discoveries', 'preferences', 'patterns', 'emotions'], - }, - coding: { - name: 'Coding Agent', - halls: ['decisions', 'events', 'discoveries', 'preferences', 'advice', 'errors'], - }, -}; - -/** - * Get a hall preset by name. - * Returns undefined if the preset does not exist. - */ -export function getHallPreset(presetName: string): HallPreset | undefined { - return HALL_PRESETS[presetName]; -} - -/** - * Classify a label string to the closest hall type. - * Case-insensitive, tries exact match first, then substring. - * Returns undefined if no match. - */ -export function hallFromLabel(label: string, allowedHalls?: HallType[]): HallType | undefined { - const normalized = label.toLowerCase().trim(); - const candidates = allowedHalls ?? (ALL_HALL_TYPES as unknown as HallType[]); - - // Exact match - const exact = candidates.find(h => h === normalized); - if (exact) return exact; - - // Substring match (e.g. "decision" → "decisions") - const partial = candidates.find(h => h.startsWith(normalized) || normalized.startsWith(h)); - if (partial) return partial; - - // Common synonyms - const synonymMap: Record = { - decision: 'decisions', - event: 'events', - discovery: 'discoveries', - preference: 'preferences', - insight: 'insights', - pattern: 'patterns', - emotion: 'emotions', - error: 'errors', - fact: 'discoveries', - finding: 'discoveries', - todo: 'decisions', - task: 'decisions', - bug: 'errors', - fix: 'decisions', - feeling: 'emotions', - mood: 'emotions', - trend: 'patterns', - recurring: 'patterns', - tip: 'advice', - recommendation: 'advice', - suggestion: 'advice', - }; - - const synonym = synonymMap[normalized]; - if (synonym && candidates.includes(synonym)) return synonym; - - return undefined; -} diff --git a/vendor/bytelyst/palace/src/index.ts b/vendor/bytelyst/palace/src/index.ts deleted file mode 100644 index 4689d58..0000000 --- a/vendor/bytelyst/palace/src/index.ts +++ /dev/null @@ -1,48 +0,0 @@ -// ── Types ────────────────────────────────────────────────────────── -export type { - BasePalaceWingDoc, - BasePalaceRoomDoc, - BasePalaceMemoryDoc, - BasePalaceTunnelDoc, - BasePalaceKGTripleDoc, - BasePalaceDiaryDoc, - ExtractedMemory, -} from './types.js'; - -// ── Halls ────────────────────────────────────────────────────────── -export { ALL_HALL_TYPES, HALL_PRESETS, getHallPreset, hallFromLabel } from './halls.js'; -export type { HallType, HallPreset } from './halls.js'; - -// ── Cosine Similarity ────────────────────────────────────────────── -export { cosineSimilarity, normalizeVector, topKByCosine } from './cosine.js'; - -// ── Deduplication ────────────────────────────────────────────────── -export { isContentDuplicate, isExactDuplicate, findClosestMatch } from './dedup.js'; - -// ── Relevance Decay ──────────────────────────────────────────────── -export { computeDecayedRelevance, boostRelevance } from './decay.js'; - -// ── Extraction ───────────────────────────────────────────────────── -export { - buildExtractionPrompt, - parseExtractionResponse, - regexFallbackExtraction, -} from './extraction.js'; -export type { ExtractionContext } from './extraction.js'; - -// ── Knowledge Graph ──────────────────────────────────────────────── -export { findContradictions, mergeTriples, isTripleCurrent } from './kg.js'; -export type { TripleInput } from './kg.js'; - -// ── Wake-Up Context ──────────────────────────────────────────────── -export { - buildWakeUpLayers, - truncateToTokenBudget, - estimateTokens, - WAKEUP_PRESETS, -} from './wakeup.js'; -export type { WakeUpConfig, WakeUpContext, WakeUpLayer } from './wakeup.js'; - -// ── Config ───────────────────────────────────────────────────────── -export { palaceConfigSchema, parsePalaceConfig } from './config.js'; -export type { PalaceConfig } from './config.js'; diff --git a/vendor/bytelyst/palace/src/kg.ts b/vendor/bytelyst/palace/src/kg.ts deleted file mode 100644 index def0052..0000000 --- a/vendor/bytelyst/palace/src/kg.ts +++ /dev/null @@ -1,138 +0,0 @@ -/** - * Knowledge graph helpers for palace triple management. - * - * Triples are (subject, predicate, object) with temporal validity. - * This module provides pure functions for contradiction detection, - * merging, and currency checking. - */ - -/** - * A lightweight triple for comparison (does not require full doc fields). - */ -export interface TripleInput { - subject: string; - predicate: string; - object: string; - validFrom: string; - validTo?: string; - confidence?: number; -} - -/** - * Find contradictions between existing triples and incoming ones. - * - * A contradiction exists when: - * - Same subject + predicate, different object - * - Both are currently valid (no validTo or validTo in the future) - * - * @returns Array of { existing, incoming } contradiction pairs - */ -export function findContradictions( - existing: TripleInput[], - incoming: TripleInput[], - asOf: Date = new Date() -): Array<{ existing: TripleInput; incoming: TripleInput }> { - const contradictions: Array<{ existing: TripleInput; incoming: TripleInput }> = []; - - for (const inc of incoming) { - for (const ext of existing) { - if ( - normalizeEntity(ext.subject) === normalizeEntity(inc.subject) && - normalizeEntity(ext.predicate) === normalizeEntity(inc.predicate) && - normalizeEntity(ext.object) !== normalizeEntity(inc.object) && - isTripleCurrent(ext, asOf) && - isTripleCurrent(inc, asOf) - ) { - contradictions.push({ existing: ext, incoming: inc }); - } - } - } - - return contradictions; -} - -/** - * Merge incoming triples into existing set. - * - * - If an incoming triple contradicts an existing one, the existing triple - * is invalidated (validTo set) and the incoming one is kept. - * - If an incoming triple is a duplicate (same S/P/O), it is skipped. - * - Otherwise, the incoming triple is added. - * - * @returns { merged, invalidated, added, skipped } counts - */ -export function mergeTriples( - existing: TripleInput[], - incoming: TripleInput[], - asOf: Date = new Date() -): { - merged: TripleInput[]; - invalidated: TripleInput[]; - added: TripleInput[]; - skipped: TripleInput[]; -} { - const invalidated: TripleInput[] = []; - const added: TripleInput[] = []; - const skipped: TripleInput[] = []; - - const merged = [...existing]; - - for (const inc of incoming) { - // Check for exact duplicate - const isDuplicate = merged.some( - ext => - normalizeEntity(ext.subject) === normalizeEntity(inc.subject) && - normalizeEntity(ext.predicate) === normalizeEntity(inc.predicate) && - normalizeEntity(ext.object) === normalizeEntity(inc.object) && - isTripleCurrent(ext, asOf) - ); - - if (isDuplicate) { - skipped.push(inc); - continue; - } - - // Check for contradiction - const contradictIdx = merged.findIndex( - ext => - normalizeEntity(ext.subject) === normalizeEntity(inc.subject) && - normalizeEntity(ext.predicate) === normalizeEntity(inc.predicate) && - normalizeEntity(ext.object) !== normalizeEntity(inc.object) && - isTripleCurrent(ext, asOf) - ); - - if (contradictIdx >= 0) { - // Invalidate the old triple - const old = merged[contradictIdx]; - merged[contradictIdx] = { ...old, validTo: asOf.toISOString() }; - invalidated.push(old); - } - - merged.push(inc); - added.push(inc); - } - - return { merged, invalidated, added, skipped }; -} - -/** - * Check if a triple is currently valid. - * - * @param triple - The triple to check - * @param asOf - Reference time (default: now) - * @returns true if the triple has no validTo or validTo is in the future - */ -export function isTripleCurrent( - triple: Pick, - asOf: Date = new Date() -): boolean { - if (!triple.validTo) return true; - return new Date(triple.validTo).getTime() > asOf.getTime(); -} - -/** - * Normalize an entity string for comparison (lowercase, trim, collapse whitespace). - */ -function normalizeEntity(s: string): string { - return s.toLowerCase().trim().replace(/\s+/g, ' '); -} diff --git a/vendor/bytelyst/palace/src/types.ts b/vendor/bytelyst/palace/src/types.ts deleted file mode 100644 index d62f84b..0000000 --- a/vendor/bytelyst/palace/src/types.ts +++ /dev/null @@ -1,105 +0,0 @@ -/** - * Base palace document types. - * - * Products extend these with product-specific fields - * (e.g. sourceWorkspaceId for NoteLett, sourceBrainId for MindLyst). - */ - -// ── Wing (top-level grouping — workspace/brain/project) ──────────── - -export interface BasePalaceWingDoc { - id: string; - productId: string; - userId: string; - name: string; - description?: string; - memoryCount: number; - l1Cache?: string; - l1CacheUpdatedAt?: string; - createdAt: string; - updatedAt: string; -} - -// ── Room (topic within a wing) ───────────────────────────────────── - -export interface BasePalaceRoomDoc { - id: string; - productId: string; - userId: string; - wingId: string; - name: string; - description?: string; - memoryCount: number; - createdAt: string; - updatedAt: string; -} - -// ── Memory (core unit — one fact/decision/event/etc.) ────────────── - -export interface BasePalaceMemoryDoc { - id: string; - productId: string; - userId: string; - wingId: string; - roomId: string; - hall: string; - content: string; - relevance: number; - embedding?: number[]; - sourceId?: string; - createdAt: string; - updatedAt: string; -} - -// ── Tunnel (cross-room/cross-wing link) ──────────────────────────── - -export interface BasePalaceTunnelDoc { - id: string; - productId: string; - userId: string; - fromMemoryId: string; - fromWingId: string; - toMemoryId: string; - toWingId: string; - relationship: string; - strength: number; - createdAt: string; -} - -// ── Knowledge Graph Triple ───────────────────────────────────────── - -export interface BasePalaceKGTripleDoc { - id: string; - productId: string; - userId: string; - wingId: string; - subject: string; - predicate: string; - object: string; - confidence: number; - validFrom: string; - validTo?: string; - sourceMemoryId?: string; - createdAt: string; -} - -// ── Diary Entry ──────────────────────────────────────────────────── - -export interface BasePalaceDiaryDoc { - id: string; - productId: string; - userId: string; - roleId: string; - wingId?: string; - entry: string; - createdAt: string; -} - -// ── Extracted memory (output of extraction pipeline) ─────────────── - -export interface ExtractedMemory { - hall: string; - content: string; - roomSlug: string; - entities: string[]; -} diff --git a/vendor/bytelyst/palace/src/wakeup.ts b/vendor/bytelyst/palace/src/wakeup.ts deleted file mode 100644 index 22717b3..0000000 --- a/vendor/bytelyst/palace/src/wakeup.ts +++ /dev/null @@ -1,126 +0,0 @@ -/** - * Wake-up context builder for palace-augmented sessions. - * - * Builds a layered context string (L0/L1/L2) within a token budget. - * Products provide the raw data; this module assembles and truncates. - */ - -export interface WakeUpLayer { - label: string; - content: string; - priority: number; -} - -export interface WakeUpConfig { - totalBudget: number; - l0Budget: number; - l1Budget: number; - l2Budget: number; -} - -export interface WakeUpContext { - text: string; - layers: { label: string; charCount: number }[]; - totalChars: number; - truncated: boolean; -} - -/** - * Approximate token count for a string. - * Uses the rough heuristic of ~4 characters per token. - */ -export function estimateTokens(text: string): number { - return Math.ceil(text.length / 4); -} - -/** - * Truncate text to fit within a token budget. - * - * @param text - Input text - * @param maxTokens - Maximum tokens allowed - * @returns Truncated text (with "..." appended if truncated) - */ -export function truncateToTokenBudget(text: string, maxTokens: number): string { - if (!text) return ''; - - const maxChars = maxTokens * 4; - if (text.length <= maxChars) return text; - - // Truncate at word boundary - const truncated = text.slice(0, maxChars); - const lastSpace = truncated.lastIndexOf(' '); - const cutPoint = lastSpace > maxChars * 0.8 ? lastSpace : maxChars; - - return truncated.slice(0, cutPoint) + '...'; -} - -/** - * Build a wake-up context from L0/L1/L2 layers within a total token budget. - * - * Layer priority: - * - L0 (identity/project context) — always included, smallest budget - * - L1 (critical facts from recent memories) — high priority - * - L2 (semantically relevant memories) — fills remaining budget - * - * @param l0 - Identity/project context string - * @param l1 - Critical facts string - * @param l2 - Semantically relevant memories string - * @param config - Token budget configuration - * @returns Assembled wake-up context with metadata - */ -export function buildWakeUpLayers( - l0: string, - l1: string, - l2: string, - config: WakeUpConfig -): WakeUpContext { - const layers: { label: string; charCount: number }[] = []; - const parts: string[] = []; - let truncated = false; - - // L0: identity (always included) - const l0Truncated = truncateToTokenBudget(l0, config.l0Budget); - if (l0Truncated) { - parts.push(`[Identity]\n${l0Truncated}`); - layers.push({ label: 'L0:identity', charCount: l0Truncated.length }); - if (l0Truncated.endsWith('...')) truncated = true; - } - - // L1: critical facts - const l1Truncated = truncateToTokenBudget(l1, config.l1Budget); - if (l1Truncated) { - parts.push(`[Critical Facts]\n${l1Truncated}`); - layers.push({ label: 'L1:facts', charCount: l1Truncated.length }); - if (l1Truncated.endsWith('...')) truncated = true; - } - - // L2: semantic context (gets remaining budget) - const usedTokens = estimateTokens(parts.join('\n\n')); - const remainingBudget = Math.max(0, config.totalBudget - usedTokens); - const l2Budget = Math.min(config.l2Budget, remainingBudget); - - const l2Truncated = truncateToTokenBudget(l2, l2Budget); - if (l2Truncated) { - parts.push(`[Relevant Memories]\n${l2Truncated}`); - layers.push({ label: 'L2:semantic', charCount: l2Truncated.length }); - if (l2Truncated.endsWith('...')) truncated = true; - } - - const text = parts.join('\n\n'); - - return { - text, - layers, - totalChars: text.length, - truncated, - }; -} - -/** - * Default wake-up configs for each product. - */ -export const WAKEUP_PRESETS: Record = { - notelett: { totalBudget: 600, l0Budget: 50, l1Budget: 150, l2Budget: 400 }, - mindlyst: { totalBudget: 800, l0Budget: 80, l1Budget: 200, l2Budget: 500 }, - coding: { totalBudget: 800, l0Budget: 80, l1Budget: 200, l2Budget: 500 }, -}; diff --git a/vendor/bytelyst/palace/tsconfig.json b/vendor/bytelyst/palace/tsconfig.json deleted file mode 100644 index 5edad81..0000000 --- a/vendor/bytelyst/palace/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/vendor/bytelyst/platform-client/package.json b/vendor/bytelyst/platform-client/package.json deleted file mode 100644 index 79195c1..0000000 --- a/vendor/bytelyst/platform-client/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "@bytelyst/platform-client", - "version": "0.1.5", - "type": "module", - "description": "Browser/React Native-safe typed fetch wrapper for platform-service", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/platform-client/src/index.test.ts b/vendor/bytelyst/platform-client/src/index.test.ts deleted file mode 100644 index e39a0cf..0000000 --- a/vendor/bytelyst/platform-client/src/index.test.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { describe, it, expect, vi, afterEach } from 'vitest'; -import { createPlatformClient, ApiError } from './index.js'; - -describe('createPlatformClient', () => { - const baseConfig = { - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - getAccessToken: () => 'test-token', - }; - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('should make GET requests with auth header', async () => { - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - status: 200, - json: () => Promise.resolve({ items: [] }), - }); - vi.stubGlobal('fetch', fetchMock); - - const api = createPlatformClient(baseConfig); - const result = await api.get('/items'); - - expect(result).toEqual({ items: [] }); - expect(fetchMock).toHaveBeenCalledWith( - 'http://localhost:4003/api/items', - expect.objectContaining({ - method: 'GET', - headers: expect.objectContaining({ - Authorization: 'Bearer test-token', - 'x-product-id': 'testapp', - }), - }) - ); - }); - - it('should make POST requests with body', async () => { - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - status: 200, - json: () => Promise.resolve({ id: '1' }), - }); - vi.stubGlobal('fetch', fetchMock); - - const api = createPlatformClient(baseConfig); - const result = await api.post('/items', { name: 'test' }); - - expect(result).toEqual({ id: '1' }); - expect(fetchMock).toHaveBeenCalledWith( - 'http://localhost:4003/api/items', - expect.objectContaining({ - method: 'POST', - body: JSON.stringify({ name: 'test' }), - }) - ); - }); - - it('should handle 204 No Content', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - status: 204, - json: () => Promise.reject(new Error('no body')), - }) - ); - - const api = createPlatformClient(baseConfig); - const result = await api.del('/items/1'); - - expect(result).toBeUndefined(); - }); - - it('should throw ApiError on non-OK response', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: false, - status: 400, - json: () => Promise.resolve({ message: 'Bad request' }), - }) - ); - - const api = createPlatformClient(baseConfig); - - await expect(api.get('/items')).rejects.toThrow(ApiError); - try { - await api.get('/items'); - } catch (e) { - expect(e).toBeInstanceOf(ApiError); - expect((e as ApiError).status).toBe(400); - } - }); - - it('should attempt token refresh on 401', async () => { - let callCount = 0; - vi.stubGlobal( - 'fetch', - vi.fn().mockImplementation(() => { - callCount++; - if (callCount === 1) { - return Promise.resolve({ - ok: false, - status: 401, - json: () => Promise.resolve({ message: 'Unauthorized' }), - }); - } - return Promise.resolve({ - ok: true, - status: 200, - json: () => Promise.resolve({ refreshed: true }), - }); - }) - ); - - const refreshFn = vi.fn().mockResolvedValue(true); - const api = createPlatformClient({ - ...baseConfig, - refreshAccessToken: refreshFn, - }); - - const result = await api.get('/items'); - expect(refreshFn).toHaveBeenCalledOnce(); - expect(result).toEqual({ refreshed: true }); - }); - - it('should not include auth header when token is null', async () => { - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - status: 200, - json: () => Promise.resolve({}), - }); - vi.stubGlobal('fetch', fetchMock); - - const api = createPlatformClient({ - ...baseConfig, - getAccessToken: () => null, - }); - await api.get('/public/items'); - - const headers = fetchMock.mock.calls[0][1].headers as Record; - expect(headers['Authorization']).toBeUndefined(); - }); - - it('should include x-request-id header', async () => { - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - status: 200, - json: () => Promise.resolve({}), - }); - vi.stubGlobal('fetch', fetchMock); - - const api = createPlatformClient(baseConfig); - await api.get('/items'); - - const headers = fetchMock.mock.calls[0][1].headers as Record; - expect(headers['x-request-id']).toBeDefined(); - expect(headers['x-request-id'].length).toBeGreaterThan(0); - }); -}); diff --git a/vendor/bytelyst/platform-client/src/index.ts b/vendor/bytelyst/platform-client/src/index.ts deleted file mode 100644 index ca87d7d..0000000 --- a/vendor/bytelyst/platform-client/src/index.ts +++ /dev/null @@ -1,156 +0,0 @@ -/** - * Browser/React Native-safe typed fetch wrapper for platform-service. - * - * Client-side counterpart to @bytelyst/api-client (which is server-side). - * Uses bearer tokens from storage (not httpOnly cookies). - * Includes auto-retry on 401 via token refresh. - * - * @example - * ```ts - * import { createPlatformClient } from '@bytelyst/platform-client'; - * - * const api = createPlatformClient({ - * baseUrl: 'http://localhost:4003/api', - * productId: 'nomgap', - * getAccessToken: () => authClient.getAccessToken(), - * refreshAccessToken: () => authClient.refreshAccessToken(), - * }); - * - * const sessions = await api.get('/fasting-sessions'); - * await api.post('/fasting-sessions', { protocol: '16:8' }); - * ``` - */ - -// ── Types ──────────────────────────────────────────────────── - -export interface PlatformClientConfig { - /** Platform-service base URL (e.g. "http://localhost:4003/api"). */ - baseUrl: string; - - /** Product identifier sent as x-product-id header. */ - productId: string; - - /** Function that returns the current access token, or null. */ - getAccessToken: () => string | null; - - /** Optional function to refresh the access token. Returns true on success. */ - refreshAccessToken?: () => Promise; - - /** Request timeout in milliseconds. Default: 15000. */ - timeoutMs?: number; -} - -export class ApiError extends Error { - constructor( - public readonly status: number, - public readonly body: unknown, - message?: string - ) { - super(message ?? `API error ${status}`); - this.name = 'ApiError'; - } -} - -export interface PlatformClient { - get(path: string, headers?: Record): Promise; - post(path: string, body?: unknown, headers?: Record): Promise; - put(path: string, body?: unknown, headers?: Record): Promise; - del(path: string, headers?: Record): Promise; - request( - method: string, - path: string, - body?: unknown, - headers?: Record - ): Promise; -} - -// ── UUID helper ────────────────────────────────────────────── - -function uuid(): string { - if (typeof globalThis.crypto?.randomUUID === 'function') { - return globalThis.crypto.randomUUID(); - } - return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { - const r = (Math.random() * 16) | 0; - return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16); - }); -} - -// ── Factory ────────────────────────────────────────────────── - -export function createPlatformClient(config: PlatformClientConfig): PlatformClient { - const { baseUrl, productId, getAccessToken, refreshAccessToken, timeoutMs = 15_000 } = config; - - async function doRequest( - method: string, - path: string, - body?: unknown, - extraHeaders?: Record, - isRetry = false - ): Promise { - const url = `${baseUrl}${path}`; - const headers: Record = { - 'Content-Type': 'application/json', - 'x-product-id': productId, - 'x-request-id': uuid(), - ...extraHeaders, - }; - - const token = getAccessToken(); - if (token) headers['Authorization'] = `Bearer ${token}`; - - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - - try { - const res = await globalThis.fetch(url, { - method, - headers, - body: body != null ? JSON.stringify(body) : undefined, - signal: controller.signal, - }); - - if (res.status === 204) return undefined as T; - - const json = await res.json().catch(() => ({})); - - // Auto-refresh on 401 (once) - if (res.status === 401 && !isRetry && !path.startsWith('/auth/') && refreshAccessToken) { - clearTimeout(timer); - const refreshed = await refreshAccessToken(); - if (refreshed) { - return doRequest(method, path, body, extraHeaders, true); - } - } - - if (!res.ok) { - throw new ApiError( - res.status, - json, - (json as Record).message ?? `HTTP ${res.status}` - ); - } - - return json as T; - } finally { - clearTimeout(timer); - } - } - - return { - get: (path: string, headers?: Record) => - doRequest('GET', path, undefined, headers), - post: (path: string, body?: unknown, headers?: Record) => - doRequest('POST', path, body, headers), - put: (path: string, body?: unknown, headers?: Record) => - doRequest('PUT', path, body, headers), - del: (path: string, headers?: Record) => - doRequest('DELETE', path, undefined, headers), - request: ( - method: string, - path: string, - body?: unknown, - headers?: Record - ) => doRequest(method, path, body, headers), - }; -} diff --git a/vendor/bytelyst/platform-client/tsconfig.json b/vendor/bytelyst/platform-client/tsconfig.json deleted file mode 100644 index 318c075..0000000 --- a/vendor/bytelyst/platform-client/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src", - "lib": ["ES2022", "DOM"] - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/vendor/bytelyst/push/package.json b/vendor/bytelyst/push/package.json deleted file mode 100644 index d8cb284..0000000 --- a/vendor/bytelyst/push/package.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "@bytelyst/push", - "version": "0.1.5", - "type": "module", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - }, - "./testing": { - "import": "./dist/testing.js", - "types": "./dist/testing.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks" - }, - "devDependencies": { - "vitest": "^3.0.0" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/push/src/__tests__/push.test.ts b/vendor/bytelyst/push/src/__tests__/push.test.ts deleted file mode 100644 index a543e0b..0000000 --- a/vendor/bytelyst/push/src/__tests__/push.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * Tests for push notification providers. - */ - -import { describe, it, expect, beforeEach } from 'vitest'; -import { MockPushProvider } from '../providers/mock.js'; - -describe('MockPushProvider', () => { - let provider: MockPushProvider; - - beforeEach(() => { - provider = new MockPushProvider(); - }); - - it('isConfigured returns true', () => { - expect(provider.isConfigured()).toBe(true); - }); - - it('send records notification', async () => { - const result = await provider.send({ - deviceToken: 'token-123', - platform: 'ios', - title: 'Test', - body: 'Hello', - }); - expect(result.success).toBe(true); - expect(result.messageId).toBeDefined(); - expect(provider.sent).toHaveLength(1); - expect(provider.sent[0]!.title).toBe('Test'); - }); - - it('sendBatch records all notifications', async () => { - const results = await provider.sendBatch([ - { deviceToken: 't1', platform: 'ios', title: 'A', body: 'a' }, - { deviceToken: 't2', platform: 'android', title: 'B', body: 'b' }, - ]); - expect(results).toHaveLength(2); - expect(results.every(r => r.success)).toBe(true); - expect(provider.sent).toHaveLength(2); - }); - - it('reset clears sent history', async () => { - await provider.send({ deviceToken: 't', platform: 'web', title: 'X', body: 'x' }); - provider.reset(); - expect(provider.sent).toHaveLength(0); - }); -}); diff --git a/vendor/bytelyst/push/src/factory.ts b/vendor/bytelyst/push/src/factory.ts deleted file mode 100644 index b3693fb..0000000 --- a/vendor/bytelyst/push/src/factory.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** - * Push notification provider factory. - * - * Creates a PushProvider based on PUSH_PROVIDER env var. - * Defaults to 'mock' since no push infra is wired yet. - */ - -import { ExpoPushProvider } from './providers/expo.js'; -import { MockPushProvider } from './providers/mock.js'; -import type { PushProvider, PushProviderType } from './types.js'; - -let _provider: PushProvider | null = null; - -export function getPush(): PushProvider { - if (!_provider) { - const type = (process.env.PUSH_PROVIDER || 'mock') as PushProviderType; - _provider = createPushProvider(type); - } - return _provider; -} - -export function createPushProvider(type: PushProviderType): PushProvider { - switch (type) { - case 'expo': - return new ExpoPushProvider(); - case 'firebase': - throw new Error('Firebase push provider not yet implemented. Use expo or mock.'); - case 'mock': - return new MockPushProvider(); - default: - throw new Error(`Unknown PUSH_PROVIDER: '${type}'. Valid: expo, firebase, mock`); - } -} - -export function setPush(provider: PushProvider): void { - _provider = provider; -} - -export function _resetPush(): void { - _provider = null; -} diff --git a/vendor/bytelyst/push/src/index.ts b/vendor/bytelyst/push/src/index.ts deleted file mode 100644 index b25caf6..0000000 --- a/vendor/bytelyst/push/src/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export type { PushProvider, PushNotification, PushResult, PushProviderType } from './types.js'; - -export { getPush, createPushProvider, setPush, _resetPush } from './factory.js'; -export { MockPushProvider } from './providers/mock.js'; -export { ExpoPushProvider } from './providers/expo.js'; diff --git a/vendor/bytelyst/push/src/providers/expo.ts b/vendor/bytelyst/push/src/providers/expo.ts deleted file mode 100644 index df27795..0000000 --- a/vendor/bytelyst/push/src/providers/expo.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** - * Expo push notification provider. - * - * Uses the Expo push notification service REST API. - * No SDK dependency — just HTTP requests. - */ - -import type { PushNotification, PushProvider, PushResult } from '../types.js'; - -const EXPO_PUSH_URL = 'https://exp.host/--/api/v2/push/send'; - -export class ExpoPushProvider implements PushProvider { - isConfigured(): boolean { - return true; // Expo push is open — no API key required for basic use - } - - async send(notification: PushNotification): Promise { - const results = await this.sendBatch([notification]); - return results[0]!; - } - - async sendBatch(notifications: PushNotification[]): Promise { - const messages = notifications.map(n => ({ - to: n.deviceToken, - title: n.title, - body: n.body, - data: n.data, - badge: n.badge, - sound: n.sound ?? 'default', - channelId: n.channelId, - })); - - try { - const response = await fetch(EXPO_PUSH_URL, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(messages), - }); - - if (!response.ok) { - const text = await response.text(); - return notifications.map(() => ({ - success: false, - error: `Expo push error ${response.status}: ${text}`, - })); - } - - const data = (await response.json()) as { - data: Array<{ status: string; id?: string; message?: string }>; - }; - - return data.data.map(ticket => ({ - success: ticket.status === 'ok', - messageId: ticket.id, - error: ticket.status !== 'ok' ? ticket.message : undefined, - })); - } catch (err) { - return notifications.map(() => ({ - success: false, - error: err instanceof Error ? err.message : 'Unknown error', - })); - } - } -} diff --git a/vendor/bytelyst/push/src/providers/mock.ts b/vendor/bytelyst/push/src/providers/mock.ts deleted file mode 100644 index 5e639fd..0000000 --- a/vendor/bytelyst/push/src/providers/mock.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * Mock push notification provider — for testing and local dev. - * - * Logs notifications instead of sending them. - */ - -import type { PushNotification, PushProvider, PushResult } from '../types.js'; - -export class MockPushProvider implements PushProvider { - public sent: PushNotification[] = []; - - isConfigured(): boolean { - return true; - } - - async send(notification: PushNotification): Promise { - this.sent.push(notification); - return { success: true, messageId: `mock-${Date.now()}-${this.sent.length}` }; - } - - async sendBatch(notifications: PushNotification[]): Promise { - return Promise.all(notifications.map(n => this.send(n))); - } - - reset(): void { - this.sent = []; - } -} diff --git a/vendor/bytelyst/push/src/testing.ts b/vendor/bytelyst/push/src/testing.ts deleted file mode 100644 index 5461036..0000000 --- a/vendor/bytelyst/push/src/testing.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Test helpers for @bytelyst/push. - */ - -import { setPush, _resetPush } from './factory.js'; -import { MockPushProvider } from './providers/mock.js'; - -export function setTestPushProvider(): MockPushProvider { - const provider = new MockPushProvider(); - setPush(provider); - return provider; -} - -export function resetTestPush(): void { - _resetPush(); -} - -export { MockPushProvider } from './providers/mock.js'; diff --git a/vendor/bytelyst/push/src/types.ts b/vendor/bytelyst/push/src/types.ts deleted file mode 100644 index 3779028..0000000 --- a/vendor/bytelyst/push/src/types.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Cloud-agnostic push notification interfaces. - * - * Provides a unified push API that works with - * Firebase, APNS, Expo, or mock/log providers. - */ - -export interface PushProvider { - /** Send a single push notification. */ - send(notification: PushNotification): Promise; - - /** Send a batch of push notifications. */ - sendBatch(notifications: PushNotification[]): Promise; - - /** Check if the push provider is configured. */ - isConfigured(): boolean; -} - -export interface PushNotification { - deviceToken: string; - platform: 'ios' | 'android' | 'web'; - title: string; - body: string; - data?: Record; - badge?: number; - sound?: string; - channelId?: string; -} - -export interface PushResult { - success: boolean; - messageId?: string; - error?: string; -} - -export type PushProviderType = 'firebase' | 'expo' | 'mock'; diff --git a/vendor/bytelyst/push/tsconfig.json b/vendor/bytelyst/push/tsconfig.json deleted file mode 100644 index 5edad81..0000000 --- a/vendor/bytelyst/push/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/vendor/bytelyst/queue/package.json b/vendor/bytelyst/queue/package.json deleted file mode 100644 index 3f13bc9..0000000 --- a/vendor/bytelyst/queue/package.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "name": "@bytelyst/queue", - "version": "0.1.5", - "description": "Durable job queue with pluggable stores and worker runtime", - "type": "module", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks" - }, - "devDependencies": { - "@types/node": "^22.12.0", - "vitest": "^3.0.5" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/queue/src/file-store.ts b/vendor/bytelyst/queue/src/file-store.ts deleted file mode 100644 index 2484ac0..0000000 --- a/vendor/bytelyst/queue/src/file-store.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { randomUUID } from 'node:crypto'; -import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; -import { dirname } from 'node:path'; -import type { EnqueueJobInput, ListJobsOptions, QueueJob, QueueStore } from './types.js'; - -export interface FileQueueStoreOptions { - filePath: string; -} - -export class FileQueueStore implements QueueStore { - private readonly filePath: string; - private operation = Promise.resolve(); - - constructor(options: FileQueueStoreOptions) { - this.filePath = options.filePath; - } - - enqueue( - queueName: string, - input: EnqueueJobInput - ): Promise> { - return this.withLock(async () => { - const state = await this.readState(); - const queue = state[queueName] ?? []; - if (input.idempotencyKey) { - const existing = queue.find(job => job.idempotencyKey === input.idempotencyKey); - if (existing) return existing as QueueJob; - } - - const now = new Date(); - const job: QueueJob = { - id: input.id || randomUUID(), - queueName, - type: input.type, - payload: input.payload, - status: 'queued', - attempts: 0, - maxAttempts: input.maxAttempts ?? 3, - createdAt: now.toISOString(), - scheduledAt: new Date(now.getTime() + (input.delayMs ?? 0)).toISOString(), - progress: input.progress, - metadata: input.metadata, - idempotencyKey: input.idempotencyKey, - productId: input.productId, - userId: input.userId, - }; - state[queueName] = [...queue, job]; - await this.writeState(state); - return job; - }); - } - - get( - queueName: string, - id: string - ): Promise | undefined> { - return this.withLock(async () => { - const state = await this.readState(); - const job = (state[queueName] ?? []).find(item => item.id === id); - return job as QueueJob | undefined; - }); - } - - list( - queueName: string, - options?: ListJobsOptions - ): Promise>> { - return this.withLock(async () => { - const state = await this.readState(); - const jobs = (state[queueName] ?? []) - .filter(job => !options?.status || job.status === options.status) - .sort((a, b) => b.createdAt.localeCompare(a.createdAt)) - .slice(0, options?.limit ?? 50); - return jobs as Array>; - }); - } - - claimNext( - queueName: string, - workerId: string, - leaseMs: number, - now = new Date() - ): Promise | undefined> { - return this.withLock(async () => { - const state = await this.readState(); - const queue = state[queueName] ?? []; - const next = queue - .filter(job => this.isClaimable(job, now)) - .sort( - (a, b) => - a.scheduledAt.localeCompare(b.scheduledAt) || a.createdAt.localeCompare(b.createdAt) - )[0]; - if (!next) return undefined; - - next.status = 'running'; - next.attempts += 1; - next.startedAt = next.startedAt || now.toISOString(); - next.leaseOwner = workerId; - next.leaseExpiresAt = new Date(now.getTime() + leaseMs).toISOString(); - await this.writeState(state); - return next as QueueJob; - }); - } - - patch( - queueName: string, - id: string, - patch: Partial> - ): Promise | undefined> { - return this.withLock(async () => { - const state = await this.readState(); - const queue = state[queueName] ?? []; - const index = queue.findIndex(job => job.id === id); - if (index === -1) return undefined; - - const merged = { - ...queue[index], - ...patch, - }; - queue[index] = merged; - state[queueName] = queue; - await this.writeState(state); - return merged as QueueJob; - }); - } - - clear(queueName?: string): Promise { - return this.withLock(async () => { - if (!queueName) { - await this.writeState({}); - return; - } - const state = await this.readState(); - delete state[queueName]; - await this.writeState(state); - }); - } - - private async withLock(fn: () => Promise): Promise { - const run = this.operation.then(fn, fn); - this.operation = run.then( - () => undefined, - () => undefined - ); - return run; - } - - private async readState(): Promise> { - try { - const raw = await readFile(this.filePath, 'utf-8'); - return JSON.parse(raw) as Record; - } catch { - return {}; - } - } - - private async writeState(state: Record): Promise { - await mkdir(dirname(this.filePath), { recursive: true }); - const tempPath = `${this.filePath}.${randomUUID()}.tmp`; - await writeFile(tempPath, JSON.stringify(state, null, 2), 'utf-8'); - await rename(tempPath, this.filePath); - } - - private isClaimable(job: QueueJob, now: Date): boolean { - if (job.status === 'queued') { - return job.scheduledAt <= now.toISOString(); - } - if (job.status === 'running') { - return !job.leaseExpiresAt || job.leaseExpiresAt <= now.toISOString(); - } - return false; - } -} diff --git a/vendor/bytelyst/queue/src/index.ts b/vendor/bytelyst/queue/src/index.ts deleted file mode 100644 index 0ed27dd..0000000 --- a/vendor/bytelyst/queue/src/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -export type { - EnqueueJobInput, - ListJobsOptions, - QueueHandler, - QueueJob, - QueueJobStatus, - QueueStore, - QueueWorkerOptions, - WorkerContext, -} from './types.js'; -export { MemoryQueueStore, isTerminalStatus } from './memory-store.js'; -export { FileQueueStore, type FileQueueStoreOptions } from './file-store.js'; -export { QueueWorker } from './worker.js'; diff --git a/vendor/bytelyst/queue/src/memory-store.ts b/vendor/bytelyst/queue/src/memory-store.ts deleted file mode 100644 index 4eb06d6..0000000 --- a/vendor/bytelyst/queue/src/memory-store.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { randomUUID } from 'node:crypto'; -import type { - EnqueueJobInput, - ListJobsOptions, - QueueJob, - QueueJobStatus, - QueueStore, -} from './types.js'; - -function cloneValue(value: T): T { - return JSON.parse(JSON.stringify(value)) as T; -} - -export class MemoryQueueStore implements QueueStore { - private queues = new Map>(); - - async enqueue( - queueName: string, - input: EnqueueJobInput - ): Promise> { - const queue = this.getQueue(queueName); - if (input.idempotencyKey) { - const existing = [...queue.values()].find(job => job.idempotencyKey === input.idempotencyKey); - if (existing) return cloneValue(existing) as QueueJob; - } - - const now = new Date(); - const job: QueueJob = { - id: input.id || randomUUID(), - queueName, - type: input.type, - payload: input.payload, - status: 'queued', - attempts: 0, - maxAttempts: input.maxAttempts ?? 3, - createdAt: now.toISOString(), - scheduledAt: new Date(now.getTime() + (input.delayMs ?? 0)).toISOString(), - progress: input.progress, - metadata: input.metadata, - idempotencyKey: input.idempotencyKey, - productId: input.productId, - userId: input.userId, - }; - queue.set(job.id, cloneValue(job)); - return cloneValue(job); - } - - async get( - queueName: string, - id: string - ): Promise | undefined> { - const job = this.getQueue(queueName).get(id); - return job ? (cloneValue(job) as QueueJob) : undefined; - } - - async list( - queueName: string, - options?: ListJobsOptions - ): Promise>> { - const jobs = [...this.getQueue(queueName).values()] - .filter(job => !options?.status || job.status === options.status) - .sort((a, b) => b.createdAt.localeCompare(a.createdAt)); - return cloneValue(jobs.slice(0, options?.limit ?? 50)) as Array>; - } - - async claimNext( - queueName: string, - workerId: string, - leaseMs: number, - now = new Date() - ): Promise | undefined> { - const queue = this.getQueue(queueName); - const candidates = [...queue.values()] - .filter(job => this.isClaimable(job, now)) - .sort( - (a, b) => - a.scheduledAt.localeCompare(b.scheduledAt) || a.createdAt.localeCompare(b.createdAt) - ); - const next = candidates[0]; - if (!next) return undefined; - - next.status = 'running'; - next.attempts += 1; - next.startedAt = next.startedAt || now.toISOString(); - next.leaseOwner = workerId; - next.leaseExpiresAt = new Date(now.getTime() + leaseMs).toISOString(); - queue.set(next.id, cloneValue(next)); - return cloneValue(next) as QueueJob; - } - - async patch( - queueName: string, - id: string, - patch: Partial> - ): Promise | undefined> { - const queue = this.getQueue(queueName); - const current = queue.get(id); - if (!current) return undefined; - - const merged = { - ...current, - ...patch, - }; - queue.set(id, cloneValue(merged)); - return cloneValue(merged) as QueueJob; - } - - async clear(queueName?: string): Promise { - if (queueName) { - this.queues.delete(queueName); - return; - } - this.queues.clear(); - } - - private getQueue(queueName: string): Map { - if (!this.queues.has(queueName)) { - this.queues.set(queueName, new Map()); - } - return this.queues.get(queueName)!; - } - - private isClaimable(job: QueueJob, now: Date): boolean { - if (job.status === 'queued') { - return job.scheduledAt <= now.toISOString(); - } - if (job.status === 'running') { - return !job.leaseExpiresAt || job.leaseExpiresAt <= now.toISOString(); - } - return false; - } -} - -export function isTerminalStatus(status: QueueJobStatus): boolean { - return ['succeeded', 'failed', 'dead_letter', 'cancelled'].includes(status); -} diff --git a/vendor/bytelyst/queue/src/queue.test.ts b/vendor/bytelyst/queue/src/queue.test.ts deleted file mode 100644 index a56c5c3..0000000 --- a/vendor/bytelyst/queue/src/queue.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { mkdtemp, readFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { describe, expect, it, vi } from 'vitest'; -import { FileQueueStore } from './file-store.js'; -import { MemoryQueueStore } from './memory-store.js'; -import { QueueWorker } from './worker.js'; - -describe('MemoryQueueStore', () => { - it('enqueues and retrieves jobs', async () => { - const store = new MemoryQueueStore(); - const job = await store.enqueue('test', { - type: 'demo', - payload: { value: 1 }, - }); - - const found = await store.get('test', job.id); - expect(found?.payload).toEqual({ value: 1 }); - expect(found?.status).toBe('queued'); - }); -}); - -describe('FileQueueStore', () => { - it('persists jobs across store instances', async () => { - const dir = await mkdtemp(join(tmpdir(), 'queue-store-')); - const filePath = join(dir, 'jobs.json'); - - const first = new FileQueueStore({ filePath }); - const job = await first.enqueue('persisted', { - type: 'demo', - payload: { ok: true }, - }); - - const second = new FileQueueStore({ filePath }); - const found = await second.get('persisted', job.id); - expect(found?.payload).toEqual({ ok: true }); - - const raw = JSON.parse(await readFile(filePath, 'utf-8')) as Record< - string, - Array<{ id: string }> - >; - expect(raw.persisted[0].id).toBe(job.id); - }); -}); - -describe('QueueWorker', () => { - it('processes queued jobs to completion', async () => { - const store = new MemoryQueueStore(); - const worker = new QueueWorker<{ value: number }, { doubled: number }>({ - queueName: 'work', - store, - pollIntervalMs: 5, - handler: async job => ({ doubled: job.payload.value * 2 }), - }); - - worker.start(); - const job = await store.enqueue('work', { - type: 'double', - payload: { value: 21 }, - }); - - await vi.waitFor(async () => { - const updated = await store.get<{ value: number }, { doubled: number }>('work', job.id); - expect(updated?.status).toBe('succeeded'); - expect(updated?.result).toEqual({ doubled: 42 }); - }); - - await worker.stop(); - }); -}); diff --git a/vendor/bytelyst/queue/src/types.ts b/vendor/bytelyst/queue/src/types.ts deleted file mode 100644 index cafc7dd..0000000 --- a/vendor/bytelyst/queue/src/types.ts +++ /dev/null @@ -1,95 +0,0 @@ -export type QueueJobStatus = - | 'queued' - | 'running' - | 'succeeded' - | 'failed' - | 'dead_letter' - | 'cancelled'; - -export interface QueueJob { - id: string; - queueName: string; - type: string; - payload: TPayload; - status: QueueJobStatus; - attempts: number; - maxAttempts: number; - createdAt: string; - scheduledAt: string; - startedAt?: string; - completedAt?: string; - lastError?: string; - result?: TResult; - progress?: Record; - metadata?: Record; - leaseOwner?: string; - leaseExpiresAt?: string; - idempotencyKey?: string; - productId?: string; - userId?: string; -} - -export interface EnqueueJobInput { - id?: string; - type: string; - payload: TPayload; - maxAttempts?: number; - delayMs?: number; - progress?: Record; - metadata?: Record; - idempotencyKey?: string; - productId?: string; - userId?: string; -} - -export interface ListJobsOptions { - limit?: number; - status?: QueueJobStatus; -} - -export interface QueueStore { - enqueue( - queueName: string, - input: EnqueueJobInput - ): Promise>; - get( - queueName: string, - id: string - ): Promise | undefined>; - list( - queueName: string, - options?: ListJobsOptions - ): Promise>>; - claimNext( - queueName: string, - workerId: string, - leaseMs: number, - now?: Date - ): Promise | undefined>; - patch( - queueName: string, - id: string, - patch: Partial> - ): Promise | undefined>; - clear(queueName?: string): Promise; -} - -export interface WorkerContext { - patch(patch: Partial>): Promise; - heartbeat(): Promise; -} - -export type QueueHandler = ( - job: QueueJob, - context: WorkerContext -) => Promise; - -export interface QueueWorkerOptions { - queueName: string; - store: QueueStore; - handler: QueueHandler; - workerId?: string; - pollIntervalMs?: number; - leaseMs?: number; - backoffMs?: number; -} diff --git a/vendor/bytelyst/queue/src/worker.ts b/vendor/bytelyst/queue/src/worker.ts deleted file mode 100644 index 7ed687c..0000000 --- a/vendor/bytelyst/queue/src/worker.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { randomUUID } from 'node:crypto'; -import type { QueueWorkerOptions, WorkerContext } from './types.js'; - -export class QueueWorker { - private readonly queueName: string; - private readonly store: QueueWorkerOptions['store']; - private readonly handler: QueueWorkerOptions['handler']; - private readonly workerId: string; - private readonly pollIntervalMs: number; - private readonly leaseMs: number; - private readonly backoffMs: number; - private timer?: ReturnType; - private running = false; - private inflight?: Promise; - - constructor(options: QueueWorkerOptions) { - this.queueName = options.queueName; - this.store = options.store; - this.handler = options.handler; - this.workerId = options.workerId || `worker_${randomUUID()}`; - this.pollIntervalMs = options.pollIntervalMs ?? 200; - this.leaseMs = options.leaseMs ?? 30_000; - this.backoffMs = options.backoffMs ?? 1_000; - } - - start(): void { - if (this.running) return; - this.running = true; - this.schedule(0); - } - - async stop(): Promise { - this.running = false; - if (this.timer) { - clearTimeout(this.timer); - this.timer = undefined; - } - await this.inflight; - } - - private schedule(delayMs: number): void { - if (!this.running) return; - this.timer = globalThis.setTimeout(() => { - this.inflight = this.tick().finally(() => { - this.inflight = undefined; - }); - }, delayMs); - } - - private async tick(): Promise { - const job = await this.store.claimNext( - this.queueName, - this.workerId, - this.leaseMs - ); - if (!job) { - this.schedule(this.pollIntervalMs); - return; - } - - const context: WorkerContext = { - patch: async patch => { - await this.store.patch(this.queueName, job.id, patch); - }, - heartbeat: async () => { - await this.store.patch(this.queueName, job.id, { - leaseOwner: this.workerId, - leaseExpiresAt: new Date(Date.now() + this.leaseMs).toISOString(), - }); - }, - }; - - try { - const result = await this.handler(job, context); - await this.store.patch(this.queueName, job.id, { - status: 'succeeded', - result, - completedAt: new Date().toISOString(), - leaseOwner: undefined, - leaseExpiresAt: undefined, - }); - } catch (err: unknown) { - const lastError = err instanceof Error ? err.message : String(err); - const finalStatus = job.attempts >= job.maxAttempts ? 'dead_letter' : 'queued'; - await this.store.patch(this.queueName, job.id, { - status: finalStatus, - lastError, - scheduledAt: - finalStatus === 'queued' - ? new Date(Date.now() + this.backoffMs * job.attempts).toISOString() - : job.scheduledAt, - completedAt: finalStatus === 'dead_letter' ? new Date().toISOString() : undefined, - leaseOwner: undefined, - leaseExpiresAt: undefined, - }); - } - - this.schedule(0); - } -} diff --git a/vendor/bytelyst/queue/tsconfig.json b/vendor/bytelyst/queue/tsconfig.json deleted file mode 100644 index 5edad81..0000000 --- a/vendor/bytelyst/queue/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/vendor/bytelyst/quick-actions/package.json b/vendor/bytelyst/quick-actions/package.json deleted file mode 100644 index 0237e0c..0000000 --- a/vendor/bytelyst/quick-actions/package.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "@bytelyst/quick-actions", - "version": "0.1.5", - "type": "module", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "scripts": { - "build": "tsc" - }, - "devDependencies": { - "typescript": "^5.7.3" - } -} diff --git a/vendor/bytelyst/quick-actions/src/client.test.ts b/vendor/bytelyst/quick-actions/src/client.test.ts deleted file mode 100644 index 56ebf00..0000000 --- a/vendor/bytelyst/quick-actions/src/client.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - getVisibleSections, - getAvailableActions, - pickSmartDefault, - MAX_VISIBLE_ITEMS, - MAX_VISIBLE_LIST, -} from './client.js'; -import type { ProgressiveSection, QuickAction, SmartDefault } from './types.js'; - -describe('getVisibleSections', () => { - const sections: ProgressiveSection[] = [ - { id: 'main', title: 'Main', defaultExpanded: true, priority: 'primary' }, - { id: 'stats', title: 'Stats', defaultExpanded: false, priority: 'secondary' }, - { id: 'details', title: 'Details', defaultExpanded: false, priority: 'detail' }, - { id: 'advanced', title: 'Advanced', defaultExpanded: false, priority: 'primary' }, - ]; - - it('should show primary and defaultExpanded sections', () => { - const visible = getVisibleSections(sections, new Set()); - expect(visible.map(s => s.id)).toEqual(['main', 'advanced']); - }); - - it('should include explicitly expanded sections', () => { - const visible = getVisibleSections(sections, new Set(['stats'])); - expect(visible.map(s => s.id)).toEqual(['main', 'stats', 'advanced']); - }); - - it('should return empty for no sections', () => { - expect(getVisibleSections([], new Set())).toHaveLength(0); - }); -}); - -describe('getAvailableActions', () => { - const actions: QuickAction[] = [ - { - id: 'start', - label: 'Start', - icon: 'play', - shortLabel: 'Start', - action: 'start', - requiresAuth: false, - }, - { - id: 'share', - label: 'Share', - icon: 'share', - shortLabel: 'Share', - action: 'share', - requiresAuth: true, - }, - { - id: 'stop', - label: 'Stop', - icon: 'stop', - shortLabel: 'Stop', - action: 'stop', - requiresAuth: false, - }, - ]; - - it('should filter out auth-required actions when not authenticated', () => { - const available = getAvailableActions(actions, { isAuthenticated: false }); - expect(available.map(a => a.id)).toEqual(['start', 'stop']); - }); - - it('should include all when authenticated', () => { - const available = getAvailableActions(actions, { isAuthenticated: true }); - expect(available).toHaveLength(3); - }); - - it('should include non-auth actions by default', () => { - const available = getAvailableActions(actions, {}); - expect(available.map(a => a.id)).toEqual(['start', 'stop']); - }); -}); - -describe('pickSmartDefault', () => { - it('should prefer last_used over others', () => { - const candidates: SmartDefault[] = [ - { key: 'protocol', value: 'OMAD', source: 'most_common' }, - { key: 'protocol', value: '16:8', source: 'last_used' }, - { key: 'protocol', value: '18:6', source: 'recommendation' }, - ]; - const result = pickSmartDefault(candidates); - expect(result?.value).toBe('16:8'); - }); - - it('should fall back to most_common', () => { - const candidates: SmartDefault[] = [ - { key: 'protocol', value: 'OMAD', source: 'most_common' }, - { key: 'protocol', value: '18:6', source: 'system' }, - ]; - const result = pickSmartDefault(candidates); - expect(result?.value).toBe('OMAD'); - }); - - it('should return null for empty array', () => { - expect(pickSmartDefault([])).toBeNull(); - }); - - it('should return first if no priority match', () => { - const candidates: SmartDefault[] = [{ key: 'x', value: 'a', source: 'system' }]; - const result = pickSmartDefault(candidates); - expect(result?.value).toBe('a'); - }); -}); - -describe('constants', () => { - it('should export MAX_VISIBLE_ITEMS', () => { - expect(MAX_VISIBLE_ITEMS).toBe(3); - }); - - it('should export MAX_VISIBLE_LIST', () => { - expect(MAX_VISIBLE_LIST).toBe(5); - }); -}); diff --git a/vendor/bytelyst/quick-actions/src/client.ts b/vendor/bytelyst/quick-actions/src/client.ts deleted file mode 100644 index 422b0be..0000000 --- a/vendor/bytelyst/quick-actions/src/client.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * Progressive disclosure system, smart defaults, quick action definitions. - * - * Reduces cognitive load by surfacing only relevant UI sections and actions. - * Pure client-side TS — no backend dependency. - */ - -import type { ProgressiveSection, QuickAction, SmartDefault } from './types.js'; - -export const MAX_VISIBLE_ITEMS = 3; -export const MAX_VISIBLE_LIST = 5; - -export function getVisibleSections( - sections: ProgressiveSection[], - expandedIds: Set -): ProgressiveSection[] { - return sections.filter( - s => s.defaultExpanded || expandedIds.has(s.id) || s.priority === 'primary' - ); -} - -export function getAvailableActions( - actions: QuickAction[], - context: { isActive?: boolean; isAuthenticated?: boolean } -): QuickAction[] { - return actions.filter(a => { - if (a.requiresAuth && !context.isAuthenticated) return false; - return true; - }); -} - -export function pickSmartDefault(candidates: SmartDefault[]): SmartDefault | null { - if (candidates.length === 0) return null; - - const priority: SmartDefault['source'][] = [ - 'last_used', - 'most_common', - 'recommendation', - 'system', - ]; - for (const source of priority) { - const match = candidates.find(c => c.source === source); - if (match) return match; - } - - return candidates[0]; -} diff --git a/vendor/bytelyst/quick-actions/src/index.ts b/vendor/bytelyst/quick-actions/src/index.ts deleted file mode 100644 index 08b63e9..0000000 --- a/vendor/bytelyst/quick-actions/src/index.ts +++ /dev/null @@ -1,27 +0,0 @@ -export interface QuickAction { - id: string; - label: string; - icon?: string; - requiresAuth?: boolean; - priority?: number; -} - -export const MAX_VISIBLE_ITEMS = 4; -export const MAX_VISIBLE_LIST = 8; - -export function getAvailableActions( - actions: QuickAction[], - opts?: { isAuthenticated?: boolean }, -): QuickAction[] { - const isAuthenticated = opts?.isAuthenticated ?? false; - return actions.filter( - (a) => !a.requiresAuth || isAuthenticated, - ); -} - -export function pickSmartDefault(defaults: QuickAction[]): QuickAction | null { - if (defaults.length === 0) { - return null; - } - return defaults[0] ?? null; -} diff --git a/vendor/bytelyst/quick-actions/src/types.ts b/vendor/bytelyst/quick-actions/src/types.ts deleted file mode 100644 index ce86fdd..0000000 --- a/vendor/bytelyst/quick-actions/src/types.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Types for @bytelyst/quick-actions. - * Pure client-side TS — no backend dependency. - */ - -export interface QuickAction { - id: string; - label: string; - icon: string; - shortLabel: string; - action: string; - requiresAuth: boolean; -} - -export interface ProgressiveSection { - id: string; - title: string; - defaultExpanded: boolean; - priority: 'primary' | 'secondary' | 'detail'; -} - -export interface SmartDefault { - key: string; - value: unknown; - source: 'last_used' | 'most_common' | 'recommendation' | 'system'; -} diff --git a/vendor/bytelyst/quick-actions/tsconfig.json b/vendor/bytelyst/quick-actions/tsconfig.json deleted file mode 100644 index 8c5e8c2..0000000 --- a/vendor/bytelyst/quick-actions/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "outDir": "dist", - "rootDir": "src", - "declaration": true, - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true - }, - "include": ["src"] -} diff --git a/vendor/bytelyst/react-auth/package.json b/vendor/bytelyst/react-auth/package.json deleted file mode 100644 index 0d04cc7..0000000 --- a/vendor/bytelyst/react-auth/package.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "name": "@bytelyst/react-auth", - "version": "0.1.6", - "type": "module", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks" - }, - "peerDependencies": { - "react": ">=18.0.0" - }, - "dependencies": { - "@bytelyst/api-client": "workspace:*" - }, - "devDependencies": { - "@testing-library/react": "^16.3.2", - "@types/react": "^19.2.14", - "@types/react-dom": "^19.2.3", - "happy-dom": "^18.0.1", - "react": "^19.2.4", - "react-dom": "^19.2.4" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/react-auth/src/__tests__/react-auth.test.tsx b/vendor/bytelyst/react-auth/src/__tests__/react-auth.test.tsx deleted file mode 100644 index 303f884..0000000 --- a/vendor/bytelyst/react-auth/src/__tests__/react-auth.test.tsx +++ /dev/null @@ -1,479 +0,0 @@ -// @vitest-environment happy-dom -import { describe, expect, it, vi, beforeEach } from 'vitest'; -import { render, screen, act, cleanup } from '@testing-library/react'; -import { createAuthProvider } from '../index.js'; - -// Minimal user type for testing -interface TestUser { - email: string; - name: string; - role: string; - [key: string]: unknown; -} - -// Mock fetch globally -const mockFetch = vi.fn(); -globalThis.fetch = mockFetch; - -// localStorage mock -const store: Record = {}; -const localStorageMock = { - getItem: vi.fn((key: string) => store[key] ?? null), - setItem: vi.fn((key: string, value: string) => { - store[key] = value; - }), - removeItem: vi.fn((key: string) => { - delete store[key]; - }), - clear: vi.fn(() => { - for (const key of Object.keys(store)) delete store[key]; - }), - length: 0, - key: vi.fn(), -}; -Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock }); - -function createTestAuth(overrides?: Partial>[0]>) { - return createAuthProvider({ - storagePrefix: 'test', - loginEndpoint: '/auth/login', - mapLoginResponse: (data: unknown) => { - const d = data as { user: TestUser; accessToken: string; refreshToken: string }; - return { user: d.user, accessToken: d.accessToken, refreshToken: d.refreshToken }; - }, - ...overrides, - }); -} - -describe('createAuthProvider', () => { - beforeEach(() => { - cleanup(); - localStorageMock.clear(); - vi.clearAllMocks(); - mockFetch.mockReset(); - }); - - it('returns AuthProvider and useAuth', () => { - const result = createTestAuth(); - expect(result.AuthProvider).toBeDefined(); - expect(result.useAuth).toBeDefined(); - expect(typeof result.AuthProvider).toBe('function'); - expect(typeof result.useAuth).toBe('function'); - }); - - it('renders children', () => { - const { AuthProvider } = createTestAuth(); - render( - -
Hello
-
- ); - expect(screen.getByTestId('child').textContent).toBe('Hello'); - }); - - it('starts unauthenticated with no stored user', () => { - const { AuthProvider, useAuth } = createTestAuth(); - function Display() { - const { user, isAuthenticated, isLoading } = useAuth(); - return ( -
- {String(isAuthenticated)} - {String(isLoading)} - {user ? user.email : 'none'} -
- ); - } - render( - - - - ); - expect(screen.getByTestId('auth').textContent).toBe('false'); - expect(screen.getByTestId('loading').textContent).toBe('false'); - expect(screen.getByTestId('user').textContent).toBe('none'); - }); - - it('restores user from localStorage on mount', () => { - const storedUser: TestUser = { email: 'a@b.com', name: 'Stored', role: 'user' }; - store['test_auth_user'] = JSON.stringify(storedUser); - - const { AuthProvider, useAuth } = createTestAuth(); - function Display() { - const { user, isAuthenticated } = useAuth(); - return ( -
- {String(isAuthenticated)} - {user?.email ?? 'none'} -
- ); - } - render( - - - - ); - expect(screen.getByTestId('auth').textContent).toBe('true'); - expect(screen.getByTestId('email').textContent).toBe('a@b.com'); - }); - - it('login stores user and tokens on success', async () => { - const apiResponse = { - user: { email: 'test@example.com', name: 'Test' }, - accessToken: 'at-123', - refreshToken: 'rt-456', - }; - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => apiResponse, - headers: new Headers({ 'content-type': 'application/json' }), - status: 200, - }); - - const { AuthProvider, useAuth } = createTestAuth(); - let loginFn: (email: string, password: string) => Promise; - - function LoginComponent() { - const { login, user, isAuthenticated } = useAuth(); - loginFn = login; - return ( -
- {String(isAuthenticated)} - {user?.email ?? 'none'} -
- ); - } - - render( - - - - ); - - expect(screen.getByTestId('auth').textContent).toBe('false'); - - let result: boolean = false; - await act(async () => { - result = await loginFn!('test@example.com', 'pass123'); - }); - - expect(result).toBe(true); - expect(screen.getByTestId('auth').textContent).toBe('true'); - expect(screen.getByTestId('email').textContent).toBe('test@example.com'); - expect(mockFetch).toHaveBeenCalledWith( - '/api/auth/login', - expect.objectContaining({ - method: 'POST', - body: JSON.stringify({ email: 'test@example.com', password: 'pass123' }), - }) - ); - expect(localStorageMock.setItem).toHaveBeenCalledWith( - 'test_auth_user', - expect.stringContaining('test@example.com') - ); - expect(localStorageMock.setItem).toHaveBeenCalledWith('test_access_token', 'at-123'); - expect(localStorageMock.setItem).toHaveBeenCalledWith('test_refresh_token', 'rt-456'); - }); - - it('login returns false on API failure', async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 401, - json: async () => ({ error: 'Unauthorized' }), - headers: new Headers({ 'content-type': 'application/json' }), - }); - - const { AuthProvider, useAuth } = createTestAuth(); - let loginFn: (email: string, password: string) => Promise; - - function LoginComponent() { - const { login, isAuthenticated, error } = useAuth(); - loginFn = login; - return ( -
- {String(isAuthenticated)} - {error ?? 'none'} -
- ); - } - - render( - - - - ); - - let result: boolean = false; - await act(async () => { - result = await loginFn!('bad@example.com', 'wrong'); - }); - - expect(result).toBe(false); - expect(screen.getByTestId('auth').textContent).toBe('false'); - expect(screen.getByTestId('error').textContent).toBe('Unauthorized'); - }); - - it('logout clears user and storage', async () => { - store['test_auth_user'] = JSON.stringify({ email: 'a@b.com', name: 'A', role: 'admin' }); - store['test_access_token'] = 'token'; - store['test_refresh_token'] = 'refresh'; - - const onLogout = vi.fn(); - const { AuthProvider, useAuth } = createTestAuth({ onLogout }); - let logoutFn: () => void; - - function Component() { - const { logout, isAuthenticated } = useAuth(); - logoutFn = logout; - return {String(isAuthenticated)}; - } - - render( - - - - ); - - expect(screen.getByTestId('auth').textContent).toBe('true'); - - act(() => { - logoutFn!(); - }); - - expect(screen.getByTestId('auth').textContent).toBe('false'); - expect(localStorageMock.removeItem).toHaveBeenCalledWith('test_auth_user'); - expect(localStorageMock.removeItem).toHaveBeenCalledWith('test_access_token'); - expect(localStorageMock.removeItem).toHaveBeenCalledWith('test_refresh_token'); - expect(onLogout).toHaveBeenCalledOnce(); - }); - - it('useAuth throws outside AuthProvider', () => { - const { useAuth } = createTestAuth(); - function Bad() { - useAuth(); - return null; - } - expect(() => render()).toThrow('useAuth must be used within an AuthProvider'); - }); - - it('calls onLoginFallback when API fails', async () => { - mockFetch.mockRejectedValueOnce(new Error('Network error')); - - const fallbackUser: TestUser = { email: 'mock@test.com', name: 'Mock', role: 'user' }; - const onLoginFallback = vi.fn().mockResolvedValue({ - user: fallbackUser, - accessToken: 'fallback-at', - refreshToken: 'fallback-rt', - }); - - const { AuthProvider, useAuth } = createTestAuth({ onLoginFallback }); - let loginFn: (email: string, password: string) => Promise; - - function Component() { - const { login, user } = useAuth(); - loginFn = login; - return {user?.email ?? 'none'}; - } - - render( - - - - ); - - let result = false; - await act(async () => { - result = await loginFn!('mock@test.com', 'pass'); - }); - - expect(result).toBe(true); - expect(onLoginFallback).toHaveBeenCalledWith('mock@test.com', 'pass', expect.any(String)); - expect(screen.getByTestId('email').textContent).toBe('mock@test.com'); - }); - - it('updateUser merges partial updates into user state', async () => { - const apiResponse = { - user: { email: 'test@example.com', name: 'Original', role: 'user' }, - accessToken: 'at-1', - refreshToken: 'rt-1', - }; - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => apiResponse, - headers: new Headers({ 'content-type': 'application/json' }), - status: 200, - }); - - const { AuthProvider, useAuth } = createTestAuth(); - let loginFn: (email: string, password: string) => Promise; - let updateUserFn: (updates: Partial) => void; - - function Component() { - const { login, updateUser, user } = useAuth(); - loginFn = login; - updateUserFn = updateUser; - return ( -
- {user?.name ?? 'none'} - {user?.role ?? 'none'} -
- ); - } - - render( - - - - ); - - await act(async () => { - await loginFn!('test@example.com', 'pass'); - }); - expect(screen.getByTestId('name').textContent).toBe('Original'); - - act(() => { - updateUserFn!({ name: 'Updated' }); - }); - - expect(screen.getByTestId('name').textContent).toBe('Updated'); - expect(screen.getByTestId('role').textContent).toBe('user'); - // Verify localStorage was updated too - const storedUser = JSON.parse(store['test_auth_user']); - expect(storedUser.name).toBe('Updated'); - expect(storedUser.role).toBe('user'); - }); - - it('updateUser is a no-op when no user is logged in', () => { - const { AuthProvider, useAuth } = createTestAuth(); - let updateUserFn: (updates: Partial) => void; - - function Component() { - const { updateUser, user } = useAuth(); - updateUserFn = updateUser; - return {user ? 'yes' : 'no'}; - } - - render( - - - - ); - - act(() => { - updateUserFn!({ name: 'Should not crash' }); - }); - - expect(screen.getByTestId('user').textContent).toBe('no'); - }); - - it('onInit provides initial session from external source', () => { - const initUser: TestUser = { email: 'sso@corp.com', name: 'SSO User', role: 'admin' }; - const { AuthProvider, useAuth } = createTestAuth({ - onInit: () => ({ - user: initUser, - accessToken: 'sso-at', - refreshToken: 'sso-rt', - }), - }); - - function Display() { - const { user, isAuthenticated } = useAuth(); - return ( -
- {String(isAuthenticated)} - {user?.email ?? 'none'} -
- ); - } - - render( - - - - ); - - expect(screen.getByTestId('auth').textContent).toBe('true'); - expect(screen.getByTestId('email').textContent).toBe('sso@corp.com'); - // Verify tokens were saved - expect(store['test_access_token']).toBe('sso-at'); - expect(store['test_refresh_token']).toBe('sso-rt'); - }); - - it('onInit returning null falls through to localStorage', () => { - store['test_auth_user'] = JSON.stringify({ - email: 'stored@local.com', - name: 'Local', - role: 'user', - }); - - const { AuthProvider, useAuth } = createTestAuth({ - onInit: () => null, - }); - - function Display() { - const { user } = useAuth(); - return {user?.email ?? 'none'}; - } - - render( - - - - ); - - expect(screen.getByTestId('email').textContent).toBe('stored@local.com'); - }); - - it('onInit takes priority over localStorage when it returns a session', () => { - store['test_auth_user'] = JSON.stringify({ - email: 'stored@local.com', - name: 'Local', - role: 'user', - }); - - const { AuthProvider, useAuth } = createTestAuth({ - onInit: () => ({ - user: { email: 'init@override.com', name: 'Init', role: 'admin' }, - accessToken: 'init-at', - refreshToken: 'init-rt', - }), - }); - - function Display() { - const { user } = useAuth(); - return {user?.email ?? 'none'}; - } - - render( - - - - ); - - expect(screen.getByTestId('email').textContent).toBe('init@override.com'); - }); - - it('uses correct storage prefix for keys', () => { - const storedUser: TestUser = { email: 'x@y.com', name: 'X', role: 'viewer' }; - store['custom_auth_user'] = JSON.stringify(storedUser); - - const { AuthProvider, useAuth } = createAuthProvider({ - storagePrefix: 'custom', - loginEndpoint: '/login', - mapLoginResponse: (d: unknown) => - d as { user: TestUser; accessToken: string; refreshToken: string }, - }); - - function Display() { - const { user } = useAuth(); - return {user?.email ?? 'none'}; - } - - render( - - - - ); - - expect(screen.getByTestId('email').textContent).toBe('x@y.com'); - }); -}); diff --git a/vendor/bytelyst/react-auth/src/__tests__/smartauth.test.tsx b/vendor/bytelyst/react-auth/src/__tests__/smartauth.test.tsx deleted file mode 100644 index 26c687e..0000000 --- a/vendor/bytelyst/react-auth/src/__tests__/smartauth.test.tsx +++ /dev/null @@ -1,339 +0,0 @@ -// @vitest-environment happy-dom -import { describe, expect, it, vi, beforeEach } from 'vitest'; -import { render, screen, act, cleanup } from '@testing-library/react'; -import { createAuthProvider } from '../index.js'; - -interface TestUser { - email: string; - name: string; - role: string; - [key: string]: unknown; -} - -const mockFetch = vi.fn(); -globalThis.fetch = mockFetch; - -const store: Record = {}; -const localStorageMock = { - getItem: vi.fn((key: string) => store[key] ?? null), - setItem: vi.fn((key: string, value: string) => { - store[key] = value; - }), - removeItem: vi.fn((key: string) => { - delete store[key]; - }), - clear: vi.fn(() => { - for (const key of Object.keys(store)) delete store[key]; - }), - length: 0, - key: vi.fn(), -}; -Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock }); - -function createTestAuth(overrides?: Partial>[0]>) { - return createAuthProvider({ - storagePrefix: 'sa', - loginEndpoint: '/auth/login', - mapLoginResponse: (data: unknown) => { - const d = data as { user: TestUser; accessToken: string; refreshToken: string }; - return { user: d.user, accessToken: d.accessToken, refreshToken: d.refreshToken }; - }, - ...overrides, - }); -} - -describe('react-auth SmartAuth features', () => { - beforeEach(() => { - cleanup(); - localStorageMock.clear(); - vi.clearAllMocks(); - mockFetch.mockReset(); - }); - - // ── Phase 1C: loginWithGoogle ────────────────────── - - it('loginWithGoogle sets user on success', async () => { - const apiResponse = { - user: { email: 'g@gmail.com', name: 'Google User', role: 'user' }, - accessToken: 'g-at', - refreshToken: 'g-rt', - }; - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => apiResponse, - headers: new Headers({ 'content-type': 'application/json' }), - status: 200, - }); - - const { AuthProvider, useAuth } = createTestAuth(); - let loginWithGoogleFn: (idToken: string) => Promise; - - function Component() { - const { loginWithGoogle, user, isAuthenticated } = useAuth(); - loginWithGoogleFn = loginWithGoogle; - return ( -
- {String(isAuthenticated)} - {user?.email ?? 'none'} -
- ); - } - - render( - - - - ); - - expect(screen.getByTestId('auth').textContent).toBe('false'); - - let result = false; - await act(async () => { - result = await loginWithGoogleFn!('google-id-token'); - }); - - expect(result).toBe(true); - expect(screen.getByTestId('auth').textContent).toBe('true'); - expect(screen.getByTestId('email').textContent).toBe('g@gmail.com'); - expect(localStorageMock.setItem).toHaveBeenCalledWith('sa_access_token', 'g-at'); - }); - - it('loginWithGoogle returns false on API failure', async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 401, - json: async () => ({ error: 'Invalid token' }), - headers: new Headers({ 'content-type': 'application/json' }), - }); - - const { AuthProvider, useAuth } = createTestAuth(); - let loginWithGoogleFn: (idToken: string) => Promise; - - function Component() { - const { loginWithGoogle, isAuthenticated, error } = useAuth(); - loginWithGoogleFn = loginWithGoogle; - return ( -
- {String(isAuthenticated)} - {error ?? 'none'} -
- ); - } - - render( - - - - ); - - let result = true; - await act(async () => { - result = await loginWithGoogleFn!('bad-token'); - }); - - expect(result).toBe(false); - expect(screen.getByTestId('auth').textContent).toBe('false'); - }); - - // ── Phase 2D: MFA challenge flow ────────────────── - - it('login triggers MFA state when mfaRequired returned', async () => { - const mfaResponse = { - mfaRequired: true, - mfaChallenge: 'challenge-xyz', - methods: ['totp'], - }; - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => mfaResponse, - headers: new Headers({ 'content-type': 'application/json' }), - status: 200, - }); - - const onMfaRequired = vi.fn(); - const { AuthProvider, useAuth } = createTestAuth({ onMfaRequired }); - let loginFn: (email: string, password: string) => Promise; - - function Component() { - const { login, mfaRequired, mfaChallenge, mfaMethods, isAuthenticated } = useAuth(); - loginFn = login; - return ( -
- {String(isAuthenticated)} - {String(mfaRequired)} - {mfaChallenge ?? 'none'} - {mfaMethods.join(',') || 'none'} -
- ); - } - - render( - - - - ); - - let result = true; - await act(async () => { - result = await loginFn!('user@test.com', 'pass'); - }); - - // Login returns false (MFA required, not yet authenticated) - expect(result).toBe(false); - expect(screen.getByTestId('auth').textContent).toBe('false'); - expect(screen.getByTestId('mfa').textContent).toBe('true'); - expect(screen.getByTestId('challenge').textContent).toBe('challenge-xyz'); - expect(screen.getByTestId('methods').textContent).toBe('totp'); - expect(onMfaRequired).toHaveBeenCalledWith('challenge-xyz', ['totp']); - }); - - it('verifyMfa completes login after MFA challenge', async () => { - // Step 1: login triggers MFA - const mfaResponse = { - mfaRequired: true, - mfaChallenge: 'challenge-abc', - methods: ['totp'], - }; - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => mfaResponse, - headers: new Headers({ 'content-type': 'application/json' }), - status: 200, - }); - - const { AuthProvider, useAuth } = createTestAuth(); - let loginFn: (email: string, password: string) => Promise; - let verifyMfaFn: (code: string, method: 'totp' | 'recovery') => Promise; - - function Component() { - const { login, verifyMfa, mfaRequired, isAuthenticated, user } = useAuth(); - loginFn = login; - verifyMfaFn = verifyMfa; - return ( -
- {String(isAuthenticated)} - {String(mfaRequired)} - {user?.email ?? 'none'} -
- ); - } - - render( - - - - ); - - await act(async () => { - await loginFn!('user@test.com', 'pass'); - }); - - expect(screen.getByTestId('mfa').textContent).toBe('true'); - expect(screen.getByTestId('auth').textContent).toBe('false'); - - // Step 2: verify MFA - const verifyResponse = { - user: { email: 'user@test.com', name: 'Test', role: 'user' }, - accessToken: 'mfa-at', - refreshToken: 'mfa-rt', - }; - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => verifyResponse, - headers: new Headers({ 'content-type': 'application/json' }), - status: 200, - }); - - let verifyResult = false; - await act(async () => { - verifyResult = await verifyMfaFn!('123456', 'totp'); - }); - - expect(verifyResult).toBe(true); - expect(screen.getByTestId('auth').textContent).toBe('true'); - expect(screen.getByTestId('mfa').textContent).toBe('false'); - expect(screen.getByTestId('email').textContent).toBe('user@test.com'); - expect(localStorageMock.setItem).toHaveBeenCalledWith('sa_access_token', 'mfa-at'); - }); - - // ── Phase 1C: Provider management ───────────────── - - it('providers starts empty and exposes link/unlink', () => { - const { AuthProvider, useAuth } = createTestAuth(); - - function Component() { - const { providers, linkProvider, unlinkProvider, refreshProviders } = useAuth(); - return ( -
- {providers.length} - {String(typeof linkProvider === 'function')} - {String(typeof unlinkProvider === 'function')} - {String(typeof refreshProviders === 'function')} -
- ); - } - - render( - - - - ); - - expect(screen.getByTestId('count').textContent).toBe('0'); - expect(screen.getByTestId('hasLink').textContent).toBe('true'); - expect(screen.getByTestId('hasUnlink').textContent).toBe('true'); - expect(screen.getByTestId('hasRefresh').textContent).toBe('true'); - }); - - // ── Logout clears SmartAuth state ───────────────── - - it('logout clears providers and MFA state', async () => { - // Login first - const loginResponse = { - user: { email: 'a@b.com', name: 'A', role: 'user' }, - accessToken: 'at', - refreshToken: 'rt', - }; - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => loginResponse, - headers: new Headers({ 'content-type': 'application/json' }), - status: 200, - }); - - const { AuthProvider, useAuth } = createTestAuth(); - let loginFn: (email: string, password: string) => Promise; - let logoutFn: () => void; - - function Component() { - const { login, logout, isAuthenticated, mfaRequired } = useAuth(); - loginFn = login; - logoutFn = logout; - return ( -
- {String(isAuthenticated)} - {String(mfaRequired)} -
- ); - } - - render( - - - - ); - - await act(async () => { - await loginFn!('a@b.com', 'pass'); - }); - - expect(screen.getByTestId('auth').textContent).toBe('true'); - - act(() => { - logoutFn!(); - }); - - expect(screen.getByTestId('auth').textContent).toBe('false'); - expect(screen.getByTestId('mfa').textContent).toBe('false'); - }); -}); diff --git a/vendor/bytelyst/react-auth/src/auth-context.tsx b/vendor/bytelyst/react-auth/src/auth-context.tsx deleted file mode 100644 index b2be03c..0000000 --- a/vendor/bytelyst/react-auth/src/auth-context.tsx +++ /dev/null @@ -1,551 +0,0 @@ -'use client'; - -import { - createContext, - useContext, - useState, - useCallback, - useEffect, - useRef, - type ReactNode, -} from 'react'; -import { createApiClient } from '@bytelyst/api-client'; -import type { AuthConfig, AuthContextValue, AuthProviderInfo, BaseUser } from './types.js'; - -/** - * Create a typed auth provider + hook for a specific user type. - * - * Supports the full auth lifecycle: login, register, forgot password, - * change password, delete account, and automatic token refresh. - * - * @example - * ```tsx - * const { AuthProvider, useAuth } = createAuthProvider({ - * storagePrefix: "admin", - * loginEndpoint: "/auth/login", - * registerEndpoint: "/auth/register", - * forgotPasswordEndpoint: "/auth/forgot-password", - * changePasswordEndpoint: "/auth/change-password", - * deleteAccountEndpoint: "/auth/delete-account", - * refreshEndpoint: "/auth/refresh", - * mapLoginResponse: (data) => ({ - * user: data.user, - * accessToken: data.accessToken, - * refreshToken: data.refreshToken, - * }), - * }); - * ``` - */ -export function createAuthProvider(config: AuthConfig) { - const { - baseUrl: configBaseUrl = '/api', - storagePrefix, - loginEndpoint, - registerEndpoint, - forgotPasswordEndpoint, - changePasswordEndpoint, - deleteAccountEndpoint, - refreshEndpoint, - refreshIntervalMs = 45 * 60 * 1000, - mapLoginResponse, - onLoginFallback, - onInit, - onLogout, - oauthEndpoint = '/auth/oauth', - providersEndpoint = '/auth/providers', - linkProviderEndpoint = '/auth/providers/link', - mfaVerifyEndpoint = '/auth/mfa/verify', - onMfaRequired, - productId: configProductId, - } = config; - - const USER_KEY = `${storagePrefix}_auth_user`; - const TOKEN_KEY = `${storagePrefix}_access_token`; - const REFRESH_KEY = `${storagePrefix}_refresh_token`; - - const AuthContext = createContext | null>(null); - - function getStoredUser(): TUser | null { - if (typeof window === 'undefined') return null; - try { - const stored = localStorage.getItem(USER_KEY); - return stored ? JSON.parse(stored) : null; - } catch { - return null; - } - } - - function saveSession(user: TUser, accessToken: string, refreshToken: string) { - localStorage.setItem(USER_KEY, JSON.stringify(user)); - localStorage.setItem(TOKEN_KEY, accessToken); - localStorage.setItem(REFRESH_KEY, refreshToken); - } - - function clearSession() { - localStorage.removeItem(USER_KEY); - localStorage.removeItem(TOKEN_KEY); - localStorage.removeItem(REFRESH_KEY); - } - - function AuthProvider({ children }: { children: ReactNode }) { - const [user, setUser] = useState(() => { - // Allow onInit to provide an initial session (e.g. from SSO cookies) - if (onInit) { - const initResult = onInit(); - if (initResult) { - saveSession(initResult.user, initResult.accessToken, initResult.refreshToken); - return initResult.user; - } - } - return getStoredUser(); - }); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - const [success, setSuccess] = useState(null); - const [providers, setProviders] = useState([]); - const [mfaRequired, setMfaRequired] = useState(false); - const [mfaMethods, setMfaMethods] = useState([]); - const [mfaChallenge, setMfaChallenge] = useState(null); - const refreshTimerRef = useRef | null>(null); - - const api = createApiClient({ - baseUrl: configBaseUrl, - getToken: () => (typeof window !== 'undefined' ? localStorage.getItem(TOKEN_KEY) : null), - }); - - const clearMessages = useCallback(() => { - setError(null); - setSuccess(null); - }, []); - - // ── Token refresh ────────────────────────────── - - const refreshAccessToken = useCallback(async () => { - if (!refreshEndpoint) return; - const rt = typeof window !== 'undefined' ? localStorage.getItem(REFRESH_KEY) : null; - if (!rt) return; - - try { - const data = await api.fetch<{ accessToken: string; refreshToken: string }>( - refreshEndpoint, - { method: 'POST', body: JSON.stringify({ refreshToken: rt }) } - ); - localStorage.setItem(TOKEN_KEY, data.accessToken); - localStorage.setItem(REFRESH_KEY, data.refreshToken); - } catch { - // Token expired — force logout - setUser(null); - clearSession(); - onLogout?.(); - } - }, [api]); - - useEffect(() => { - if (!user || !refreshEndpoint) return; - refreshTimerRef.current = setInterval(refreshAccessToken, refreshIntervalMs); - return () => { - if (refreshTimerRef.current) clearInterval(refreshTimerRef.current); - }; - }, [user, refreshAccessToken, refreshIntervalMs]); - - // ── MFA challenge helper ───────────────────────── - - function handleMfaChallenge(data: Record): boolean { - if (data && typeof data === 'object' && 'mfaRequired' in data && data.mfaRequired === true) { - const challenge = data.mfaChallenge as string; - const methods = data.methods as string[]; - setMfaRequired(true); - setMfaChallenge(challenge); - setMfaMethods(methods ?? []); - onMfaRequired?.(challenge, methods ?? []); - return true; - } - return false; - } - - // ── Login ────────────────────────────────────── - - const login = useCallback( - async (email: string, password: string) => { - setIsLoading(true); - setError(null); - setMfaRequired(false); - setMfaChallenge(null); - setMfaMethods([]); - try { - const { data, error: fetchError } = await api.safeFetch(loginEndpoint, { - method: 'POST', - body: JSON.stringify( - configProductId - ? { email, password, productId: configProductId } - : { email, password } - ), - }); - - if (data && !fetchError) { - if (handleMfaChallenge(data as Record)) { - return false; - } - const mapped = mapLoginResponse(data); - setUser(mapped.user); - saveSession(mapped.user, mapped.accessToken, mapped.refreshToken); - return true; - } - - if (fetchError && onLoginFallback) { - const fallback = await onLoginFallback(email, password, fetchError); - if (fallback) { - setUser(fallback.user); - saveSession(fallback.user, fallback.accessToken, fallback.refreshToken); - return true; - } - } - - setError(fetchError || 'Login failed'); - return false; - } finally { - setIsLoading(false); - } - }, - [api] - ); - - // ── Register ─────────────────────────────────── - - const register = useCallback( - async (email: string, password: string, displayName: string) => { - if (!registerEndpoint) { - setError('Registration not supported'); - return false; - } - setIsLoading(true); - setError(null); - try { - const { data, error: fetchError } = await api.safeFetch(registerEndpoint, { - method: 'POST', - body: JSON.stringify( - configProductId - ? { email, password, displayName, productId: configProductId } - : { email, password, displayName } - ), - }); - - if (data && !fetchError) { - const mapped = mapLoginResponse(data); - setUser(mapped.user); - saveSession(mapped.user, mapped.accessToken, mapped.refreshToken); - return true; - } - - setError(fetchError || 'Registration failed'); - return false; - } finally { - setIsLoading(false); - } - }, - [api] - ); - - // ── Social login (Phase 1C) ──────────────────── - - const loginWithOAuth = useCallback( - async (provider: string, idToken: string) => { - setIsLoading(true); - setError(null); - setMfaRequired(false); - setMfaChallenge(null); - setMfaMethods([]); - try { - const oauthBody: Record = { idToken }; - if (configProductId) oauthBody.productId = configProductId; - const { data, error: fetchError } = await api.safeFetch( - `${oauthEndpoint}/${provider}`, - { method: 'POST', body: JSON.stringify(oauthBody) } - ); - - if (data && !fetchError) { - if (handleMfaChallenge(data as Record)) { - return false; - } - const mapped = mapLoginResponse(data); - setUser(mapped.user); - saveSession(mapped.user, mapped.accessToken, mapped.refreshToken); - return true; - } - - setError(fetchError || `${provider} login failed`); - return false; - } finally { - setIsLoading(false); - } - }, - [api] - ); - - const loginWithGoogle = useCallback( - (idToken: string) => loginWithOAuth('google', idToken), - [loginWithOAuth] - ); - - const loginWithMicrosoft = useCallback( - (idToken: string) => loginWithOAuth('microsoft', idToken), - [loginWithOAuth] - ); - - const loginWithApple = useCallback( - (idToken: string) => loginWithOAuth('apple', idToken), - [loginWithOAuth] - ); - - // ── Provider management (Phase 1C) ──────────── - - const refreshProviders = useCallback(async () => { - try { - const data = await api.fetch<{ providers: AuthProviderInfo[] }>(providersEndpoint, { - method: 'GET', - }); - setProviders(data.providers ?? []); - } catch { - // non-fatal — providers list is supplementary - } - }, [api]); - - const linkProvider = useCallback( - async (provider: string, idToken: string) => { - setIsLoading(true); - setError(null); - try { - const { error: fetchError } = await api.safeFetch(linkProviderEndpoint, { - method: 'POST', - body: JSON.stringify({ provider, idToken }), - }); - if (fetchError) { - setError(fetchError); - return false; - } - await refreshProviders(); - return true; - } finally { - setIsLoading(false); - } - }, - [api, refreshProviders] - ); - - const unlinkProvider = useCallback( - async (provider: string) => { - setIsLoading(true); - setError(null); - try { - const { error: fetchError } = await api.safeFetch( - `${providersEndpoint}/${provider}`, - { method: 'DELETE' } - ); - if (fetchError) { - setError(fetchError); - return false; - } - await refreshProviders(); - return true; - } finally { - setIsLoading(false); - } - }, - [api, refreshProviders] - ); - - // ── MFA verify (Phase 2D) ───────────────────── - - const verifyMfa = useCallback( - async (code: string, method: 'totp' | 'recovery') => { - if (!mfaChallenge) { - setError('No MFA challenge in progress'); - return false; - } - setIsLoading(true); - setError(null); - try { - const { data, error: fetchError } = await api.safeFetch(mfaVerifyEndpoint, { - method: 'POST', - body: JSON.stringify({ challengeToken: mfaChallenge, code, method }), - }); - - if (data && !fetchError) { - const mapped = mapLoginResponse(data); - setUser(mapped.user); - saveSession(mapped.user, mapped.accessToken, mapped.refreshToken); - setMfaRequired(false); - setMfaChallenge(null); - setMfaMethods([]); - return true; - } - - setError(fetchError || 'MFA verification failed'); - return false; - } finally { - setIsLoading(false); - } - }, - [api, mfaChallenge] - ); - - // ── Logout ───────────────────────────────────── - - const logout = useCallback(() => { - setUser(null); - clearSession(); - setProviders([]); - setMfaRequired(false); - setMfaChallenge(null); - setMfaMethods([]); - if (refreshTimerRef.current) clearInterval(refreshTimerRef.current); - onLogout?.(); - }, []); - - // ── Forgot password ──────────────────────────── - - const forgotPassword = useCallback( - async (email: string) => { - if (!forgotPasswordEndpoint) { - setError('Forgot password not supported'); - return false; - } - setIsLoading(true); - setError(null); - setSuccess(null); - try { - const { error: fetchError } = await api.safeFetch<{ message: string }>( - forgotPasswordEndpoint, - { method: 'POST', body: JSON.stringify({ email }) } - ); - if (fetchError) { - setError(fetchError); - return false; - } - setSuccess('If that email exists, a reset link has been sent.'); - return true; - } finally { - setIsLoading(false); - } - }, - [api] - ); - - // ── Change password ──────────────────────────── - - const changePassword = useCallback( - async (currentPassword: string, newPassword: string) => { - if (!changePasswordEndpoint) { - setError('Change password not supported'); - return false; - } - setIsLoading(true); - setError(null); - setSuccess(null); - try { - const { error: fetchError } = await api.safeFetch<{ message: string }>( - changePasswordEndpoint, - { method: 'POST', body: JSON.stringify({ currentPassword, newPassword }) } - ); - if (fetchError) { - setError(fetchError); - return false; - } - setSuccess('Password changed successfully.'); - return true; - } finally { - setIsLoading(false); - } - }, - [api] - ); - - // ── Update user (local state + localStorage) ── - - const updateUser = useCallback((updates: Partial) => { - setUser(prev => { - if (!prev) return null; - const updated = { ...prev, ...updates }; - localStorage.setItem(USER_KEY, JSON.stringify(updated)); - return updated; - }); - }, []); - - // ── Delete account ───────────────────────────── - - const deleteAccount = useCallback( - async (password: string) => { - if (!deleteAccountEndpoint) { - setError('Account deletion not supported'); - return false; - } - setIsLoading(true); - setError(null); - try { - const { error: fetchError } = await api.safeFetch<{ message: string }>( - deleteAccountEndpoint, - { method: 'DELETE', body: JSON.stringify({ password }) } - ); - if (fetchError) { - setError(fetchError); - return false; - } - setUser(null); - clearSession(); - if (refreshTimerRef.current) clearInterval(refreshTimerRef.current); - onLogout?.(); - return true; - } finally { - setIsLoading(false); - } - }, - [api] - ); - - return ( - - {children} - - ); - } - - function useAuth(): AuthContextValue { - const ctx = useContext(AuthContext); - if (!ctx) { - throw new Error('useAuth must be used within an AuthProvider'); - } - return ctx; - } - - return { AuthProvider, useAuth }; -} diff --git a/vendor/bytelyst/react-auth/src/index.ts b/vendor/bytelyst/react-auth/src/index.ts deleted file mode 100644 index 424c649..0000000 --- a/vendor/bytelyst/react-auth/src/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { createAuthProvider } from './auth-context.js'; -export type { - AuthProviderInfo, - BaseUser, - AuthContextValue, - AuthConfig, - LoginResult, -} from './types.js'; diff --git a/vendor/bytelyst/react-auth/src/types.ts b/vendor/bytelyst/react-auth/src/types.ts deleted file mode 100644 index 029267d..0000000 --- a/vendor/bytelyst/react-auth/src/types.ts +++ /dev/null @@ -1,89 +0,0 @@ -export interface BaseUser { - email: string; - name: string; - role: string; - [key: string]: unknown; -} - -export interface AuthProviderInfo { - provider: string; - email: string; - linkedAt: string; - lastUsedAt: string | null; -} - -export interface AuthContextValue { - user: TUser | null; - isAuthenticated: boolean; - isLoading: boolean; - error: string | null; - success: string | null; - login: (email: string, password: string) => Promise; - register: (email: string, password: string, displayName: string) => Promise; - logout: () => void; - forgotPassword: (email: string) => Promise; - changePassword: (currentPassword: string, newPassword: string) => Promise; - deleteAccount: (password: string) => Promise; - updateUser: (updates: Partial) => void; - clearMessages: () => void; - - // ── SmartAuth: Social login (Phase 1C) ──────────── - loginWithGoogle: (idToken: string) => Promise; - loginWithMicrosoft: (idToken: string) => Promise; - loginWithApple: (idToken: string) => Promise; - - // ── SmartAuth: Provider management (Phase 1C) ───── - providers: AuthProviderInfo[]; - linkProvider: (provider: string, idToken: string) => Promise; - unlinkProvider: (provider: string) => Promise; - refreshProviders: () => Promise; - - // ── SmartAuth: MFA state (Phase 2D) ─────────────── - mfaRequired: boolean; - mfaMethods: string[]; - mfaChallenge: string | null; - verifyMfa: (code: string, method: 'totp' | 'recovery') => Promise; -} - -export interface LoginResult { - user: TUser; - accessToken: string; - refreshToken: string; -} - -export interface AuthConfig { - /** Base URL for auth API calls. Default: '/api'. */ - baseUrl?: string; - /** Product identifier sent with OAuth requests. */ - productId?: string; - storagePrefix: string; - loginEndpoint: string; - registerEndpoint?: string; - forgotPasswordEndpoint?: string; - changePasswordEndpoint?: string; - deleteAccountEndpoint?: string; - refreshEndpoint?: string; - /** Token refresh interval in ms. Default: 45 * 60 * 1000 (45 minutes). */ - refreshIntervalMs?: number; - mapLoginResponse: (data: unknown) => LoginResult; - onLoginFallback?: ( - email: string, - password: string, - error: string - ) => Promise | null>; - /** Called once on mount to provide an initial session (e.g. from SSO cookies). Return null to fall through to localStorage. */ - onInit?: () => LoginResult | null; - onLogout?: () => void; - - // ── SmartAuth endpoint config (Phase 1C+) ───────── - /** Endpoint for OAuth social login. Default: '/auth/oauth'. Provider appended as path segment. */ - oauthEndpoint?: string; - /** Endpoint for listing providers. Default: '/auth/providers'. */ - providersEndpoint?: string; - /** Endpoint for linking a provider. Default: '/auth/providers/link'. */ - linkProviderEndpoint?: string; - /** Endpoint for MFA verification. Default: '/auth/mfa/verify'. */ - mfaVerifyEndpoint?: string; - /** Callback when MFA is required after login. Receives challenge token and methods. */ - onMfaRequired?: (challenge: string, methods: string[]) => void; -} diff --git a/vendor/bytelyst/react-auth/tsconfig.json b/vendor/bytelyst/react-auth/tsconfig.json deleted file mode 100644 index 4447784..0000000 --- a/vendor/bytelyst/react-auth/tsconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src", - "lib": ["ES2022", "DOM"], - "jsx": "react-jsx" - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"] -} diff --git a/vendor/bytelyst/react-auth/vitest.config.ts b/vendor/bytelyst/react-auth/vitest.config.ts deleted file mode 100644 index 9eaeb03..0000000 --- a/vendor/bytelyst/react-auth/vitest.config.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - test: { - // Use happy-dom to avoid jsdom's heavy dependency chain and ESM/CJS edge cases. - // This package only needs a minimal DOM + localStorage for unit tests. - environment: 'happy-dom', - pool: 'forks', - }, -}); diff --git a/vendor/bytelyst/react-native-platform-sdk/node-compile-cache/v22.22.0-x64-9de703df-0/2f014b13 b/vendor/bytelyst/react-native-platform-sdk/node-compile-cache/v22.22.0-x64-9de703df-0/2f014b13 deleted file mode 100644 index 41a9cd1bff2a319276c77ae70177e990bb3ef23e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1322028 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£I-9hLe}kN% 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

5-tP(=w~Do&r7fc}^&-(8#sLqq7nNx)@NU3y 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

i%!opOEa*i8{ZI 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

CJ#QkM+ilQP@!LnlPMd|^oh+@Xa+)e47O@Ltm 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 T{ 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%0DB=;Xmv4DPO9eOrTi0M(4fq9QvKE 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 S#0V<_D{nmhr=C5*fByyUlAlNb diff --git a/vendor/bytelyst/react-native-platform-sdk/node-compile-cache/v22.22.0-x64-9de703df-0/b0598a2c b/vendor/bytelyst/react-native-platform-sdk/node-compile-cache/v22.22.0-x64-9de703df-0/b0598a2c deleted file mode 100644 index 7cff770e5d753706d2cd601572400a4eafc8c20d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1322076 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

q12zq?!Sgdit3iE}YWS8GML4m7$ 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%0Au#uiwf4Su7% zzk%f|*;*=RE7=?%O?$8ItV@n*U9)^UsthN zs%Ss1tm!zXvT=T{?$~0lGf$vOhFe9ZvsT6j-K}mzZE=7(kzsV_hmN~w_bXVAC|wTc z#Ty2APs`GeuDdMI_tOFaCClh*h})Ee#k0n{tM_ zqe05t>*PGgv6ikxTkAo?a&+O01f_5{3gb-L(`-eZdKR>tn5VGMG=MPDQn<~p3z(Lm zeimN5%QXxL(IMj*ie~%g8}>`qfGu!2D5zhmOBiP;acf*F3c6;M#mtoF)-lTfZ2-cn z;zhtV`!BwPJy&ZP=w+QJ;)2c+ht0mLOm?IxTK3X~}LWFpGlf=^6$ZIHs(@ zhLVOt)8WXZDeOdJ2E;~LHsEnP)*1-Vf9$ff+%YEArP_0aG3|z&QQ47=La_+v0)7GKuv#4d)38fy0|I7{O`=#hG)P{;W;J;J4Tax#+yu`s&ed>Pid3-@i`uhrdi;|jh{TmYLMOSnA!y|i zI0Kt9*b;|n*S6XAJxwdF)Thzv3A`BqWFKY}H`kk7G(GlGxOxP?;&2ZHm`$5Eth>fe z3|AjISdR*k&6n#kZ7bU<+uL0F#TpOGu@3T8#`qKgoDel*_s+0xWwH0)yVKn1gDk~f z#JNzxP%qDAyU-;9(FE0e+0VM3TV{X}4|!*Le%yIkJm^~VC8F2`x=(TjzfNPbJfnMf zkpa7dcxQsS5r^0oRsjd5ixE_wF%=JcGR$`M@d)wwK6p^P+vT^<@&w{A2$U-PLTsv2 zPOS;aps~3DQaMB;11`EO|2(!`A_Z=zmJmJ$zZwCy;x6+_mAOvbb^$`m)h5w?41>V` zga~jLwem-Gva_+XV(0rMIw$l`NF0Kt{d}dc)4vO`qX4N=^%t8b(baI3*PsF*;CBJw z9H>0?W_6%=JZ`lF%UGGfm@OJxILg?}(vaWi&2rs+6S=Pa;yW}j?Jzd{)L##NX% zgNAbw<$jcbYX;H)qlDXenXs(9xH^*DKc1^u(L!Nl)T#^o3M zZ(vjMQ-3s6(_bGwQOCYGgZ-qM;L%VTt@CyWXU#4O{?0M!&vQ1wnGC4f+L!aY)P5fN@vY_J2rABnZ%a}4{ zi8M;Rn@{P2&39#tPap1!FNOQ!HSuH^zR&4ztS&moi6DH0%&bVG-@1HCi;a927MAfP zp6kGgJu$KlTmVq!%#HRbnG~Lq(Wn^f|KN#c5u#|j8PQ;}VfCJaxt!U9$mB#k*&oj= zbDktv8PRD0j0Jb60M7d-TzAdl?3`&Tj zY6j3hdg!Yl@9zVBVJv7^Ld=6Q?hw#FxoGQcVY&9_Q%d4R65?%P*`NkLE8dBDZ(&R} zrM}q1LWT9C5m|b?%!`x$j4UG}yXcC{%Ag*IDD)^3CNqep;DsH^WQNSoE}85zBaL#P zJ6yo&OU>$A%Rbu=AIDtZ5`%Y@&E6VwVH;i+Re#baF{eId;MS)xN4E2jlJPwxZY(6; zwWR_-zN8WJ=8K}i%LhDjR-E~g>&#j49k4b6~H~C-b|-vG4nKEF#N@;>0U-JaryKcG(q~WvHu&sKYNf`I6ywNv5HQ zQ6?W`uDE69MjC$5TPV?b-24SWiOUcAs)s%bGW0TNeT+@Z;vEk9R}WnlWCH1}lIH9x zH7Nl4HxE4-N#q-5TT~L65R&PGggf z0DaR#e+jbiHN>5MoK4GmT?F(k58VXhd!(0-wP}_K#$6QjZ4dn=$V}HkUy+*dq9*BL zpzpY8VVTlkhmbEf*46sSB3>Lacil4cBaJzrFHqv{DEsYw@(eoYdmdW3kxyxf&3AmX zO-I=JlmLC-LnnefMY{7yo0gU-3HpJDeg-o3cg8)|rc2pzmjeCJLk|bJk@O}>%O<2W z=tpi^SmqE|?I!q2qioBt-6KY8mVwM4ZkYv7g?`$)~i#hBc=)%`0ZqyD8`}51k0|chZwb+j>i@Hv=vFUq}Z$^Ys7Z zQ))j2e^xqvOsni!n?okI`wZ|4I)eW686@7J&!M56k#r>JJRZ6o-Xi-bB){T2Ej<)o zc%$}w?OI+B9iB12k}(tL)?*Pj+c4P{v;v*aLze(qBQxlMBW!<0mi5-4^LyxMkOlA- z0AFi*Ka5-UWNko)yJ@Qh1{?7L_~WuRF{k7i+Crv)TL%72BQj-p#3w!7xAkUC)BE6poTa%8UU-Zxq zK>mbx7Wit7fF`tzjJp%)2sbS(qvgu4yqKG@%i2WC$TM_?OcA#X{Fz#yzoJAa{Q{OS z+3s}#UDQMW1hRh~&`Ul@z9e&_R~TCOb>;c zNcwfqFL~&xAX}6G-C&Fz_ltHaqd=E+)50>bU_(lRe<;fgTS;QHOf+Q5xnYHjCcPi2eLv9EJoHZ>%Txe;g)4BhYx@lpVVPGp&1;0f4z?6xwWfCA$ z%Pj+crYGpdlt`6+L1~*#1YO%h?*^Ht8fbHj?V(7027#{Qp{sy=LON-jP0Kt=0{yaw zj;NkrNyX+%Kf-7-O4L$T0keOQ`?$h*s7&n(Qksb=Vk%z7i@($@psdp8d9tOIxn--SI*D}A- z5u0z5yEbv%6VEUlGELkv@Mls%ucbsS&d*RESwBXAZt9`;fo#_av^moDIb7gHk%-s$2KXQIXdXZy5w{X+K zGL^udA%9x>Gb}Sw`>~K|>6U>%a})H*9*{Ul4~4Y!n8$%`<)N>Ge5EJo2J|A>a!7hS z=++)O24v~iL7$cWjI1ATfNtZV8-T134SF6uD{3OgWN(6Q>!I6$+}ay-Zs}Rc(rbcl z=ca{az5_cw7JQPdP1IhNf(ejm@0Ni-vmW$^{UDJ_55?U#O$6P+Lmvhi(jRm!IpZS7 z@smJz^w1?izCHl-diorUThfz3ck>mdIo9WQ5!q-XUO z=q_$rSf*40S`BQz4`i=RedHX!yWeUIDv$KKMeiZ*reuDP-c^GVo_kg5HK(W_-D&7a{B3 zGSK}z^ba5}y$$+j(u}*Pom20E?(d$w)cZZq32s_g<{sGn?}ESPE;F)Ju7pgYTLvD=W6LYy)nbvin2YcwUAlH5fy0i2-WXtg(=pi0@4@hkj=uMnMVZD~);q{=0dgy{6hiwL( zgYz?PdIRWT9(n=DsI8z+OTA_Oe*}8Cn--QC3%2ey@P*u#k=k#B%m}v({F&E48=pX; zmb~61&#(#fNDn<0}aTBgjJNj&c%s96UuQ!2y zX%{3`(Ti~RO`m}t@1a|P)V>D2m0kpC*#d6|{f37w26E6|(6eORlKvd@n;!ZtkW2P~ zo-F+t0<70NK${+V7sw*}K^GxSt4l9(C+G=oT3Dt5*zXR4FP+wvxGy0y(Jcdi=050d zhamA-UK5gS{8ykSdFb&V!@dQ5klqjVk>1!Y(33rMU68*X0eyvWv)7hw>Tb|eJamqu z`IT`eKz~3F#ZB)4{g#Jb3Ua_{(o%2PD(wY5)lCb_OavQ!7W_cjH&J_HwEaHFOmoY? zpP35!Z%WLfUqD)FzaR8;51sp5ex)TgUtKxlB1`-M&@(*raF7*#0KH1ixRkXuIS6{D zhi(aSKj|oXR@6l1(IL>YJoGh?cg}As8Kjya9G;$xTeckMLBHpr!$F?S3OWblre`I`O&36~bko8znL@*rwb*<` zWZy*XdHWT${}D2)+%oWIj(~nQCnTngvb_jdn|=bl+C%REIUo%54tc#vYJU;*`yP58 z$eFo7e=B_s*;mVrML1-ecFNbHa^F6C`IUIG1~hmHYxu@GpJJ_q{`=|x-x zz1~A-DjcrN!R8ysD@fEtruA3Q8$9$@kkwxVT}gUBvZnq9`XdkhD##_I^V73p+_DY3 z2705L7M3{*Hb)Wgh1_LE`uo=*v&k(3f2IQH!g$+_uk$$DFOV(S4bYoCbSsdFI_QU7 zZ9{$JEZy&*KladbK;|n+ZRGVPSsrhK-r}KO2Kf`|YhDlK7U-=W+E*%Ec?)l$^yQ#u zMNNv@IeHuPHa9IS!xv&dC=33qy9Ji>fOjDCiCYH#Ojx;aB?_BwFK1kM2I(>11^uaq zo(l45MbM|^`We}O+yniYhb~kpT)AHbbZ+VW$hhx=-tM6bR1H_oVe`$Cv@DMgK!5I` zAA)?Z25G4Y0k#K;~<=4E&j%pqDm>#9iqZ z$h!9w^d1j=24t2_pmWO=F*5FFp!a&{<{&F{0lk&J5;du9Yog-m1p7R6EXXlkL5Iru z8F@881HIovZvi=}JLp;RdXv05l@9a)H!Uo)1MIpO@T#2C+GOU zcl?sXD(w~v7WG>KWJoF-viGx9>${82gljR2eorj(QvcM3~`DNVFj(I?T@1Yxk zyfFgwKU{6Yc1E_qc|o7`&^bniD`m!l{!IEa(vJB+pL5f~GR?tG91p&=tW7+F)IL9C zesIgcpIHOC>jX&L9{M=Qg>QjwFXNWA zsUYYdJ@j^v=VpK&DC3rPECl)|4;?l$Tp7O@^eFmD=}>yB+nN*xebG$|%Pa&tb_w{; zExEej#TF^P<{7hXtt;IoK@z8xhuKp18FzH#zvaW-^>ZXNd zc7a{F0sL}znIXoOtOR6!b<4n?*$w*RO^_JHc@CykmavkbfAi4CKz_LybUCh{VOnJy zRtoer5B)R9Qd>b^kt=+qS6dqNbq^g0a?>`@?d6P%EWKqw-|*0FHGi{m4T%0lD%dI*e%yU(4bXqOXwtdZp_hWZ@*C)fa;8Z3O)rD~ z$3v&P7OrH!4!VepTbACspr5*FVVQbhTi*b`%RM@f+Sh~3Gq()Q9_k|fy}8_ zK#K+zKXSE=`a4R&EzoD>sug)ZNqx{Nn`3+=-3(;4KR{oTt8I$nC|kS+pfwNO6XfQ{ zpi9g78EKiupwoHi(;y!`1>H^35q8{7K>OUZuuR0Wa3ukoZ@t?xvJ^ChOnSEr{F#NI zH>4|owm^C)vNkmXoxwxz0XZfk=(D6*hUI$4=Ac77^b(MZvVeYv{tRiEuaTfLdg$#S zXJiFkPR{1Y-lhfUOdfh2$hI$l{zuN{5TMmtg3j!wg=PAKeV7CMDtB#?GOZw!#VrGW zCRfe^N~>Iu(B-U%)TcG*tR8v@$WID_-a69uXJpIK26U*0z6f%AVbHDRwJhn+v;&>Z zL!Sd#ECTdR&J?i~kp4`2&@XuC1|ZiK1>Ho_(&`;RXLr-WGJCeDWo`J8(&gr4ofy`0@v@X2}Ipfk9beMSH6JqcLhkyl0F9kmdfs+^Lgk(6$>a?D}inxy$IQ+z6LtKhpq+k%POF&$+%@N z*#mUAhrSQes0#WWIpb2v)}$xs0v>uA$UjM|(zB9r_X1teO$*DEs#ZW*fX$al_S!6! zF zDToFg;h}#5Ij5Tzh#6zD1*{e0^pXJ?9b?j9323^!m3(E`#dzk!9w`GX2 z6vRTNm|F(^OyM>Kl=;|v1EtT=)~4e?7x&PIKz45nI=7q^k@?yOwC~C=*gt%bIN+{2VLGx3(G73 zdxCr>Ij6_8%2qNNG8No1@MqM41(X5Ud{NRbkfY}m&{(J~KGH*34YFhc=vC7DsbQxz z6?7#J-45i8M9`bKeulla?3)IIuI!;d201ASbZ5CDM(RBTG?st|X|f)qk^;J?q@}Mk z6m(TLEi9vh?VSpKnA@L`GQ%KK%`F3eW)kRwgCUVB*8@v^Mu4vFp?yONC~L6!MoI6d zrfr!~plf*O!yumw1znf^3`>}ddo<{p9=hVN0?HI@z9{L>$eKC^bS)3P0py^Opzq3+ z$ujP-pliEnVVSqVb{Yr%imXl4NBWZEAXCRJ1Ak^D=u?zf&nrkQVUivX`ehIO2xO5r zKu_aU9(Ngj19V*v-2~*P6G2zvj0a3n)Fa)du@V##Y0a5nQJQO zy`&kp^k*l6uJ5LWWvYXHYX*2-T87UBkTq~JWE!|-;Lm&ny6$YAfopa-2QRgs0=l7x zjt6;pF6dhHBB+n_nBM~3$U|qGS3voC0qFGfXWVg51>M*~{|s`*V$cu0p4BwaO+57H zAWtm;{n)F?bkI%Rw6M%$uq~E>ALnjoWNn%OnPzSo_%jKhn=FS!HR+*9%ghAb+(So$ z?79MUPw8{W7I+ruNDrL?vg|6*ndItW0&Ib2gKpuWn}Q5q3wpg=KO_5sIiOp5=*A#> zuLFIY_cyVu%Q7<;bSpP4EHe)5p)KHZyL)Y^{XEFDcFVw@xdpnyCy*E>SHwumECAic zL$@XQDYk>s=a41-ZP0B!v<~tE(yF{)Th2wj1G=4uJ_9oO3(&i`l83Vp(sx@3y1j>9 z4YJ@4&>zUTAGzXV5$FzXT3Dtw*!RfSb^E~5FIWtjj&2$FGhc&lu@e&Am#u_%7&IJ#WL)N|bKzH}h z4?wowPuhExXC>&@Jak`>!w-PoG2Hf*I0eGmvw%^3_95tK4}A$_;ZvZ?$=Muf znf0J!Jaj#f!%5eYo|U9GfbQ+4g=OZ0JwpD2v}Pebf=sMi2L8-r(2dSOVidgyrd7@> zYy=(Wp+|$veu3I^{S0a8&ujwS$3s^Hx$8&Jl{kmOD<4w(&7k{w=qn(P{zO_{L6R-r z$Drdq^lgyIKZ719X&Ltx(EZ%Bu*_Vr%ddjpA^RqJp0d7fg-n094E&jkpzHk#i6(M> z=4HE7ZUa5QLr(^IiS!OhH@E3eKo9iLnSU#w47~=r8fSCZYfE40Q_u+>dLGEfH$b;% z+$`&|<@gMAqKD4^djVxVHeYGEHb+`!JLo}fT3F^ZSmhS@Zf+k~>hn2dlH4-zXCgrV zMTvMh&mqt71!%vA4!;fUvH1>q=TLTlPWI4+?i5f)VDtSfX;}((f==kW z#FClwH6k76iZm-!NJMm(u=(QUt=Ce8y`YD?X^}|ZGfd?8@IbarvdM5ac>5qT!Yu>W zWE|)_86dHPQx>G9(f5NMDe0Oa8{rvYuGCIKdiyscetn^R8u^M~tA;HSThBg?MBf3G z@Wisos*w&9TOvN;t$XwL7QBFPG+zG4r`$KSULc5La|OpUhJLUiMie0;s@BKYf=J3>E#(|fL{AaX7(1dl&s%`y6Oqjm$>a@_NG9xMG(l7!VOx+! zLZ2bZv38UTM3i%749q8yOe;d#bUsXP>_t@Nv37F~h7fL>DOi#-PHKmz7VtTF)xX~I zl$uyC+rcx1}&f2%bo?(LWIq`2PTihq%*V2b{^;s;*Twix@R8m;2{iyX%Pc+dc` zORv7Wgr2yC;ib5P@m#*Z+jm6y$Z)Yy6(rhb0_t!X+?bRsVE}B#7m56tor3`Bj6BC>4dTl0>3U+hB`YUP0v{>uiD>#S`*%Moz zEePmfzii<7XoU$+9PU+|UYHec5G$gk{VKQk;iEuy1;33~oD=nHc(Db)vMgBfkg>F) z{Onm%-VbCY-Uk_YaRE2l-*}_{4+&CDan}jc?ewIG=`Nv5|3UF>wh|;{*126>kN$ z-U2-^7Vn0!_3D>B!;5U3t$3b)BNcW3>txR4Ks;dD2A__3yKTXbVk*yyN2V|My&B@2 zVEb*Y{As|ZX|Q|+GhpAmalV5keII1N^A1C9hJ@4#Nv||)lukA!n95V|pc-tEX*3lr zP`}69^&1lDj)iP7r~riUyv&d#nSH}%h(Gve;dBE+Qq^G$?!%^j_~}>av;pZ4sG+$s z8X<@I(>r7je|m)c#-Fij$QS(W6!H~+y2`!F+#4Bkkv~I20(<$ecU}$6mobIMxQE#C zAvVO453$(CKIL9Jo-iLyh|}l8>Fpm|;1vHHAdx%Vo1}&u<8NOolMl+Q#82j`NLI+h ztcBC1ge0j3lg5!J%jxjdjSBFoVb%jh?Yfn09Xs<`;y0`>qVzh2Dp|EtF6684se6j`gD*6)UPec0>%4=vFXydsitDXb;vn_it}Sm& zxh4sz1B2vHHsi8B%*dv;Io?>B64fUb@_22#ohHT&rq|*e;*R_@N}6>JF#TpfyU%!n z-m@4mjff{stx|yX!2SLe7b2L-Rm-gn6HFhxMKvKM(4^RD#kn@5Ouf%|f?8o$!hA_J znC!tNJ5^?~@nRPFMsMs{NqU;tu!eOSyaCkiU|cZ>bKk31YdakjG+Ndxdn^savFRD^ ztYkKe*A2MU5&=X^ngq=*q z!!lu~L}+I0WG)q!89QY{vtTDn*|03wDIZGRvQ`Ysik-@#q1Xwn8WxJ3>Y>@NldWc0 zHtf_6eE~Z!yd3rdcIt&@$4>V8VcD_MFf<2tax@Ohft{wIIkA(od00;Dvf~s~$AUtr<5!`im z1o_Yr^rlJ_Q7?|&l}_uFJ_XJB%OQjCT{C24Xs*m0E%ReFu(@OdOUIt?>*`+o<0K9V znnn#sjS=gn@J{44y@q@ z_;%j(bJdJ1!_qHSGj0q|KQd#Aul@@UGG$4Z@k7E_W503K zez~70=4m|y-&@Qd**0Ivhk?W9&eOna5ydska_S|1UlQLxtVvpS&#PWI_D znLiwh$_d+VkUtXw`J-mVluozY1(kAnalXpaaXI%K$(aFi7V~B2wD>`e+A*EPB5mgSOrpghz2-xHahJT?Urk8?@)aAk0TxdN63y9}JxRW6<%Z3|xCU==Q=S zFbftX9ax-{Q>p*k;-qOilCr4v*X>Apw3~Ok{+L7VBL2~o6hmj`EyBkFA-kwOVY*bNjcT}Pp>DP zxW#{N-AX!kpMe|qlaBw%L%07-dgpKc^U2>yd&c^CXsrLx1V1ADeS&}dWd5^avVYrK z41D*N|Ne9aX3g+_GLM0K^Zch5`Tr+A+x$4`-EIEQcKD&{FFX9>cKb7^^;hopZ`$Y2tkyri&;Qmpgx7uJUv`v%ua5d> zobcn>=UYzr=bWU}*C+kE&-?Lo_Dkpev#;>bmsk8dZ~Ce8P5-3346M29U;lvOM<4hP zKcv*1hyEWQ^U#F9{0pD)A6$)8K6*BGt?TnHoQFBlvKgIyWYC(U%vmMmE0!DgvG12A z-Cjm>W93j1j`=ECg+FkI#wir5x9no&t2=Y%EMK>ozSxz)2X4g&^+Eap#-kTv}28^S*`iyp>q>+;FAj@<(tscePYejt4C z*go+I3GoSi+QzktPly%A$~P>3m`-8Gs38rK@fybX{kl59;{LYHXqMOzo7OEfnVvqG7*w(yhjS{Pgkm=EB)@JWo2Cj^|1LQhJ`k zdW_(IV!_NsEejS#i$@FocY5`cu-r7;a*6eELU*=LQqqM#Ffl=P(9~2-*UWU9rl&J~ z>9hl8dY`u6%#dEweOVsE;Z^c#@N~7j>EPn}viu3(Hba&_;M8W!w$IF(LHjz(qeZ^) z`*z3Pzn3}NUNbZV|2)J$d-+dRnOe;J&5mYa&4?{a*^Suhqr0#^*5Y^Rz#W3#rmU2^ zhG%~m(hitgd1Vq@O4ZU|&%^{Ap#J-*zFvd+>or9JYMCJ!G`)6qy^fhNMAKh3GiB8D zx@P7~nqJS$l3CMVF|%gT^!jFKR!whUW((EyhUN>|G`*3T{RK^LZ05+W=}pX>IW)bg z8J1Jio0+-7G`+c*JC~+Mnt5_-dJ8je9!+m)=F6+;t<3!SG`+PMo?p}3m<7T$y{%cW zfTp)I3l-G#_GaNin%=>Dv9P9hG$UTr^iF1x2u<&77A>ObUCd%dHT_kycri`yYU;%` zy_;D=*YxgY$r76Wnpvu(ruQ&Qm(ui}W|`8O-phQcjHbVCmVHUn4YOQXO^-6mm(%oU zvqE`Ik1;D&(DdGBrHYy!YgVqL>2YS2%9`HCtXf6W`X5&VhKFn;=Sks4_O`B-?2(wvJO&@7CZ>H&^%*f`NKH6*% zsp(_PmMt`Wtl6rirjIjQx6<_SW}DWU{)XAMji$e8wri{DrrExorcW?CwAb{BX2%Yi zKFRFVQPU@zojYmz6thcbO@GULwTq@tHM_p5>C?<^T{V5W*}a>l&oE!>uIV$)9Z$2-%-4Hq`drg^UDM~8QHG|^H>0C8eSsMht?6%@y<;@}9W%DKrY|(( zVl{n{*(XlZ7n^VW6hJXC@|S z`bu+9qNcAhlLl$}YSW*j>F=A#eobFvrX*|n2WD!Drmr;zr)v5-bI4##|Ii#dMAO%s z!-i`526Ol@P5;OoF=;eoYK|ML>D$cl z<23yf^NsPE{;B!q8=C%^X}+oH+sz54rhjfuoS^Ann3E=I`VMpQBu(FGPMNIfUz%@C z(e$s(sc&ieE_2#cP2X)!pQhQSxte~+oIg*~zcClg*Yv~Y+Y2=PTl1Z_HT{UW@EuJ*YA#x+>Br2)i!}YXxn!}X zpD>p$(e#t%vZb1S%6xa3rk^&KzpLqI%oWQu{X6r$6`KCNx$-?tKWna9sp;p;)vGl9 z2lM^antt9~^S-8EFh5wM=|7rlKhX4_%ynxu{i6BdI!*uCT>qh_UotnW*Ysb^k2Yxf zWpm?4ntsLHv{BQqnwvLi`mg54n>GD6bIZq?e$CvvMbodF+qP=@4fB(2n*O`_=_i_g z)BNmHO}}Ms|4h?wo1bsj^gHGkpKJPEbH^8&e$U*wL(}h@U+&cO2j*8_YWhQS*H@bU z$lSe4)BiBP-mU3>ntQ(1^vCAjJ(~WPxo@wg|84Hyr|D131N$}oAM@Y=O@C@0I;iQ- z%x?~H9hzz${szCQYJPhdznW?u`4+$FRP*Q&{Q6Y$*irnZSIy(c@S8z3PaMZ@h-#iZ zf!~a(dFmv7GpXk3Q~1rSnrBYqH;Zb1cLu*%RrCAr@EfX{XTQg9Hq|_L7QZj3<`3ua zn_V@}|A5~ds(Il&esikkj~DP8rkX$fh~HeQdGROw=2p$0FXA_kYF_#ozj;;jmrMA~ zr<#|4!Eb)mymA@8;i`G{3VsWy=C4=rTTnHB`xU>1RP)+z_${oO*RSFCMb*4<9lsH( z`TGt07E#TczvH*4YTmku-(sqH`xbtStLB~C_|;YO?j8J=P|bUH@mo?g@8833Db;*% zAHStl^Wg*hmQl?|5ApkwYX0#EzhzbP&p+^6PBkC@iQn?7`PXCoR#45q|H5xY)qL_d zek-ZwKTq&mSv8;jgWoEu`RplvtE!5rsLwF8(5oS+;{8hqR!307zyrY=2&Plf3n_X{ z1br%Y6ulOL=~W+tcy+RwK~2wK9Rx$v3=F=EU`92B!MX@$QZq7G55df8CI(+YFpHX* z!TJbhRkJYI0Krf-D}xOY%%+Ai*a*QF)NBkkMliej0)tHu%%NsyuqlE$)f@~qLoiIu z$zXE?bE#nrMk1J7&Bb5~1oNo58ElDQUNsMctq{zo=4G%og89{a47NcqT+Pp5TLcTJ z;S9Dzu%KFi!S)CiQVTNJ0l~s*AqG1l_@Y{v!A=N9s4p_u8NniI1cO}=EUFe^@Kpqh zsYMy=iePcI7=zsq)Yak)c1N&;sx$Z+f+f`w4E8{2vy z!76HH2KypdRjtBcJc8BKstoo+u)11}!Tt!=P^&XI0KuAS4F(4ySWB(RU;=`*)mjWD zB3MVQ&EOyeUsmfdn1oKq;_O*GJ>7eP7F>#u#4K6!M6~6RqevyR0O-KuQE6d!ES0-2B#y~ zUG2u;3s49-HZr`m(T*$DPhdonl&!PnJZ49-Q+P+wtJ%ivN3 z`>F8^F8hCodl#^(%JlzxKS+7XJW!dHrdCrnHPtjVV>3P*&XfPx%6A%cLSprU{$5CK6&Kv6j=cmzd7M8z|nQGxe!-+L|H?E3%T_xE1!d;Rxy z-TV99_xj$?X+7&%&pPb2pr4sfg~mZYH=hZOhkju`7rFpC!h9igA#|h}A#@S+OEXev z0(6x5Qs`pnS7wyZCD764E1`+dF=n*TBd z^R>_w&~MB*p(~-^ns0=rK>usL6`BeiZ~iNE6?B3bFEkDMotYqXHPqL9Co~=EXMBZb zK>dxM&`jt=<1cg#bds4UGz%JFCJD`k2ATk&YoU`(pwM;DATwF$dT6i-5}E@IF~LGN zKtoN4&|K&g6Dl+hI@L@Onh%|3rV8B%oo=QH-2|OsrVA~AhM5^cH$!KdFrizZ;bx}L zt# zIW*SH6BIc4c^_Xm-yqYLPL4PS(-w!{BBcE`Isy{RLfG>cS{575-3BZ%vVfn7<~DvN znFQC*WYdX=W4jh!Cp(7nS($5R(t zR+`RCc7&yvu1t4?r5Yb5Ji=C)yP5I`OEcY>^axvRdNAz~mTvB6;v+1>^knKIEYm#5 zRn`$&s+F zW&qQpu8%y|?UCm@UUhKYtI)jS=y*j~ks0Xd{&Qi)=C_V+kNmRxujNOH8RT&EDK)P< z@*Gb!w3M0OJMyJz`?EH4hn{aZ_lJDPQ|DW@nZXWL;C7Q}yz+*!e&2rNt2;M40?JL2 zkt2S`oZB3(WBzyUEN}!=m}GHZQnw!X|K(2YCjmRmQkFQLYU9{xhVUr6&JO=PDaTIp zH%CW$O{J031n;>{6Ue#q$7cTPpB(Npm^i%Oxcy#VgZX-Oi2J{>_x*0W=Y7+r1KM`J z4zCU}2Dd(~aE;Sw=jD<$pYGlPe%-yZrgir&#j3H>SQ9oCe>9fh=lg1R@8z&`Y`q^p z3gxTZFOctQe0B1(0n&&W|L)%Hv94GTtPj=?dj{)|4ZsFrgRvplP;59h3LA&{VZm4! z7LCPYNmweDh2>#|SUFaS)nN5l1J;O{iR6!U#d=_UuzuJxSbuB)HV7Mx4Z((D!?97= zIP8n*lmQIJ!mwy89!tVfu`DbPE5ypNN~{K}#~QFk%uJ#TSXZnE)@RbI6aw-L)*l;y z4Z;RvL$IOPaBLJd4)eo;u`nzei^r0%R4fb2!wRu-tP-oi>ahl_5iHDHaH2_%24 zE7k++gZ0Cn!TMtZutC^hYzQ_K8;*^_#$kR~FcyYIWARuLmWpLzc~~J<9w>dY5>|uN zONVTLG-75lMZmgZJtniD52PRV4AvhTfDOV1V?(f^*l=tVHV*T{g0V0x8jHu0uv9Dy z%fkw>a;y@o!RoOFtPwLo6aedr^}zaI{jg`S{@4I)5H=Vaf(^xnW23Nfm>(95g<;WH zJeGu|Vp&)oR*02jl~_#>m8^#}2x)|vU`l{>#d-uwOZ5rns~C;tUopY z8-xwUhG0Xn;n*l_9Oj1wV_{e{7LO%isaO`4hZSPwSY?RRu?AL;HDHaH38e&BSF8ut z2kVDD6Ntl-zk;zaEEahl_5i?UM1=bbof%U=qVb4q@s6S)?HV7Mx4Z((D!?97=ILr?V#=@{@ zEFMe3QYC>bNFG**m1C7y4OWjeV2zkzr_vtliuJ(yVEwRXu>RNpY!EgW8-fkRhGV0! zahM+#jD<}j!&pcHwj3MFkbN5KI-|SybJzfE5H@%Q!uRBBJin5#;WO@td$s$mtJ1@A zA8;WD`EUDlTGM zBow(_?s?!}5!UR}5j21F%=pn<=VnWi?qPqtO&E8r%U^JNLVcS}+JXHiFLvAbx6 z_M(>UTdr+N{yk8ad#LkK7FNpUEo%A0k8BpAz7KVUyITL1aSZLGedxP}S^6oJHydlkIk25_F4=C`K6>p= zS0kUt@7>c*@u&QfhUi0zyWLWBy;1#dq&N9ACFo-}gnR#an3z3oF|LM4KtJ#j<+#`C zKQhX#tsY%oBl*ubJUZ{h=(b)?N5^LT=B~&OWpkF#{@T$Ib)reB8@={~7Pf;ge(y)Z ztVdATe@x}**s<5j*!!8V*>d;8W7fE=gQwp85FhqF(faAtrVDpJd?1`qhqS-}Cbw>^*S}Q`abdj#Cx@n;PcNlGw2Bx5l%t z8Q=0`jB9Uh?e6Wq|6_9N>9$8UG9Da7op4nm?&mMz?)&>EkoWSG=Oew|hC4p5(U=;^qc|h5X68t)Zv4mHN+-bFN80t_qO)YNV^~LB7djp!x;s zA9<=r-rJLRIrMZ4LM*FFKGNU&Bfe<&BW)X!{^rL$9aAw+@e7DAd5S00^mHu8JjK66 zeA!cc2H~BDzc2n3;wzrwzak9y0r887uX>7$5nlNL@vjj#dWv7H?db@_Jj?tN;%lDb zB7~QJK zdpbs3AfSCG*H$K5{*zXbUL)plD~54SyDQH}n2P?K#01~YPD?gRd)#`RfFHNY1bL^6 zJso|oAdh}X_i?vg4)+G~CoDPV?de)rrOe(Zng z|B-~B@+i1A=W&wV+^H9T)$hnb*6ohAHz%ThCgf@NvfRP{oZs%;%Ta=PKhqnzG`&42 za;DbtjHmoO(t%x(uXo9By+Yw#gA~JPWHcl#5#hsZv_4;@}GIiuOj{Dy~wlf@sR%w z`E#D~V5HAKAeFnr)!@>4a)=`<{_jLQZ$(%gMcaD_0U13B3hQ7!>dBt<{(}hpa}FuE zdKNpO3Z%<=Ay2=@lH1d*eloexZnJJMx1WUd5dkl@3SjgYg7l=wO*>0&r#}q&uRZ0z?$^uF|0(3Z zf54L4jsG$7mptWvM>^X7ep~t{$eo_@50Jk8Gvp7WmQBMx`uP{?0q$zoR{a6m z+E?i954628Oa@i?EIj#j#^tZM(YfyO*OME+?$E}$yz%S$z;SqjCy%R{JdO?C(JndB zdtf(Lqss7Mk2b@}=w&OT0QX+k>D6A2y_okYYbs+m(|tn3kM_bDibAXCVMLzjeMXbw}xX^)ej^8$J<-bYtH?5O&9TkluxfINMKrd^@W0ztaA-`=^Xb#MFA|3rF zafU^$>EY@FQ0GzQ|v<W4TN&h`tH7?@NxI1vA9U7N@WLyU?XT_0m0Y{~i zJK816*)%@hFFs7Z?z@=D)Ws$?nm$^pBV;$l&HC9)wH6QbKbi6srM^d0aTLt1mhpsX0i<>FVkZ9`g zh!@XqkrtA}ZCPOp2>DB^I?&yxApP?^YQSj}*`vwl+S+e+uKPy2%+oH9>)ZRi_9Q8* z)IN=}kR;!=lC%oJEN>C%-8Y{iWc5Jresr3Eeu#S!?m+%J(hQLg>}i!)a+MMOHlvqdxd!&Y32gt{qMZBJv*(CBtrh)Iz$_BCjp- zYcD0@y;eoygkKQS!bQk0bw@63-rLGx8S;O4%1+q~9bW_v>|Qw`Y&oDMWl=MYwme zc?4{fpt>JfyFEK%6%il0=i%B=_9D*9pqp`)j0cLkKDCFw*?#gH#;7wVzu9nFJDgYA zqC|(UwcI<^i`3<`p*`11BlVB0)F)a6Vz+vblz);C6aQK%b=D1MhnnULw{K)|c0k6OD&JA+Ngzi1`b_SuX0=spRM z!$kM=3kVp|Du8`R*ZsX57clQp_gVW>dmkl#n;+>Z_o?pX*m3~*AcI_5-`azP{ z5%STkad%buRZH97xm0*M1*-;$5ZyCZx8Zut>oH9$%s?06L8vx0O>+@`g;lZ#tN`1 z&GF7QwELRS_qYSS`w&uv{#*A9TqD|f#81+5bfRrvBihyc?NZT+vP zwT~tCk>G!=1npL&1ivLQr`LnDVpr=Z#~$Vn5HQ}o1g?4c`-uCrCty?$>mbVR#|H_R z&?~Y^>nR_L^rJ4w$4X(O{p>O4 zFmgXjZnZb1nS=JrZs>cqakY@FqUWtHaD)*5?}aQz-hlDUnJaCqeLq4SYL7xDdX}lZ z&x4GPhxpo>~#J%nzAiXUCvL)K<>^K1dtpeEXKZ|s>${#^41E;;8 zsz)B^DbGau_wJ;g+Rc*N>+1ya$)56mBTc^-d0uZzZuf_i$b&rPyO0L-5V>{EM9O1l zaEgdv&xm5wWe*VXWLqW*GW8y4wdQH$A+6-vJh%0H&@t^HsesHZWT$DburmaNwhCZ3 zn~L;^$O|6un24N3KE+es=3)9L=A9+Qkx7(&5OoguR8M&o(uLfO>D|AJCATM~4alc? z%JYyOc~lD1_Evwh_qykanC=;I`(qC}o_?H&yZbV!knU;E(k>vM(Mqn(^C!f2{g{Bk zeF%`=V^4}M5)jrZfSLCTNYg~#*wvET8`ve}Gd<tLmCPr=n>L#2ohs*B)XL z`m}$thmf;SlDd3S=Z#;GFrLG_wF%=hQ^$K5XGQAx)D7dk*k^P&)W@&GpLneeI&bqB zvklq#+B<8x5n-;Ci#_+%hTTmbu^&*jrZ$AQ&);q*B-XuPa+!TK(%Xh1FYSq3)`LCB zwM9P9Q~o5<+J7OB?(W)fB*Jd1c0|ngjA%yv(r_X=J`|yESuC-#Xw{o@$>OdFl&Z$AMsJ!=4yPU>B(YVg+IH$V z=>4A}9^g;8o@jex_{-YV4o6ng_}R_UMsdyKqg%#vekGx0e7NrfJac^~TvIaHZ^9b? z37nP5_n&aWe*(3Ln>b;^B+)oJ#JLjkPPNV&$QgEfckwU_65tD-+azSLbVhoCo*em7nEWaKBZr!!by$b_>Y zQgFY}3Av#YX#QiN6Anz9K&wxgJ|Qt|0`)Elo6t0GLYp?!sjtJAAU@{a$d6>_DxXy| zCoBw?(AD7+)<;R@tD+_}&eK+C*PDT;;67?26FU1C?{|q5W?Z`mT?;Hb1G#&&X4{)Jue{v_iC_%ayoA zs5?Xx5q1x|P8nhB;m(OX#Z%rFX@$s-pq9k#N#+35sh;Z7D8rYae#aZR`?2B6$X9vF z3z2@8g#1Nrq0-dllookWTcd_=f_O+YeBG2@c ze}FV}9r6M%JZ65C&W(m0jgh}aT&TG$F zen&uds{rtKE4^> zH5Y!z5bfv?(*B+I`#9Bm>&x4;CG`%RY_=z)e<1Y@R_d)b#z#qe%2CRb)bZB7&7QFQ ziKtxnGRden5B1DrM7-FM<7}ydJi`8LXCU1o z^1te8gm7C zd|ONYlvUrqBj4&N--I;qI`XNqJC;q|?)~o}FLal?`p-hd(QSKkb%2{2Wkty+daAFP zJ>A#HezKi)FQ53m+snmVN!6aI|ASPEtW@1+WXU8st$lCW#kzhiZP|q+q#wLbP;u)Z z2IU0g2^|S}u^q#{=h}G*BNpP=8~DtYZVp@^fq z5%8YdMM!)9E6?OCZxzEpo{u`XI}v@jdsefM>)z6y!;x2b$`gA{vDelY=y!Q0{{+%^pF#eJ z?6hRFwP&Iukne6K*Y+8UI9mez?sttl5@Ywek;Lq2T_lF`-N^6%DIw3uj!5)&jYpy1 z`#t@uXbXOhzF%kbGBvRqbTs4SAEC(w81&m;OJ<_q)qoTkiXa|2~*4S4Mt&u6H2L&r~h&(=AuF zSkI65t_NMsDLW#2((^SLR9hL?rz>?Oj3N0(%zJ8I>qyL=`Fu;rfmUVWaO1H*_I4ce zm9b=|Y_d9mtw``Qb$MB#Qzyu>`~;hmCC`W6lXH>!|rm|TF*gzPpGu9+bbS!%OOcPY4%*!Zm~bNMTSe&h5QaWt2{DDYG0IDTU8GqA zoM;um+2cHa%CcXgeEh{vt5x{kHjkyPt%uAFmqopv3(;z&Y2-UaPl1BE1PkGoMMe8!5j z@7K|Wi6q%A>nO`q`|fHxWG*3R-OF+ZbD(ob1J)z2x9`}oN8VWE=RDqs+2KGrUcEssZj!Bbv^G;tI1 z@z#k#$)GQdCR@q^A}+Ry(DtcCf4+d2I=RW!eLAs_fJ?0cIMBX#b8p8Vw;&(-AacXL z;1Mf*d4l+5Px;qKzuhjWce7Hr)LxvNuOvUGA7FIECtBRS6U53Zc@4exaOjB3nrSm#S;x@M6!VeH4|k#TnAJihVq

*?o;gXhRX}s(BoWM>J;_vOPLkYOfSfG6++GE2&Yr}hp&ZVGVE9@U zF9nkKDJ=#r99BB3c9N;7R~mF;5>MAsx)_*pMrkUr>a6Bb2_#=tZy^wNL+J`&Rg*k3 z#aRk$YgT#+IN72Fp5hx|u3;27+%LfRO$?9%#{oNn0;EQlfQ=ymhI2$Uz?muP4VoHY z=1)^?HZXC9rmzqQ4_CShh=^301I(W7qQHz8rRl)Mxti|O*Z{LDPSd>t4y);7EaHF=carnO3OY zWFT^{(sUsA0Ms}ufrMk~-2hxZt~9!yqGPmK?1=yqeM)H-kldiO97wvLv=qp`rt~0i z)-O;RHOfEG#0LgS3dKNRkkV8jH&p2fpd}&Dw7K26aB-m7yhOccfjKLc<^vZ~mCjie zXfo53Rsz+lm0kl@WhgBLR%a=#0K(TPO#wo4lqLatHz>UZG~{a`XKoBMwVT!JyCu+^ zDpv2*l0Y+iyL#6F+2u+P0Q)PHHUs`uO5=eU`&?Qe;gqJZ5hysL^f++hoYK&SKvQ%c zt#O_J(k^P)PGEA2(#1fC|72;iG~f(R{eeaVPBtwVbnfKIW>%2W6d);hvNUxmP!p_Dj;3%7n3Jz`J+NVu(i6bK%}NV_sx3+b zwvt$p(nG-ZVx`xB=CaAMIW6Bd*`)4J?_QvGr_#7xlg)}srTc+3)k^Du(gRAHfNh7A zUIA{@C|z=RvdK82bO$i=sAiE4Oh2Y|O9OTupDbH!GqB;Ldba_Q4H~u-2)U><4cK*A zX%jI2s?u~Ib8C<^>Mme!vC`|n!4jpxr9q}?n>=30xnz5gi6~c^4m9ml!IE7;CU>9G z8o>XM(s@8ajnZNu4mgY>=tHq=Kl+L1x|+rJH~SGlHdLdx6H8 z!Lp85ga?~rvx23m!yKY~tcoTLx@SQ14k_T8h#PV96?_ zJAlZw!8|R`SprmUAvZ6l|JGnrSEz!?MZqS#ELfU)1#o zR0W%i16tsnz|2Fc$N;V#QyP6d*j%Z1#hnN?3(hND3(RO#ng%RuQd$N?`h`eC86_-|a)v1lq=dsLt_eIzh5MmOeUVCo#D3xN1orRl)VMXEgoTxWcv_zosKq{)A? zGdexB_zt-16+|!d_w-?3Nj4nI$PV1T&pVmD{&j1we@CpBH51X9(K+_|Urq1J-wL{0 zhgbF-`J*meU1&8`;P7e&(zW4C6z-jU^K7Tzf)F!fkv3r(khMhH&EXZKvvUWh3h%d$ zt?bJ?!$`WxO4>f#$r1Yz625abWofeipkq%wI9-_;*s~X{!SnhLHk1J zXGFAkM!bRg(}hH2^|a1L+SBCEk?XxBKzF?!r&qp`5r3E=)wRyf8I~6mhM1aDA?7wm zXE~-$#-$(gM%btTi{B^$c`Z>gmYp>lj3Uw4{9GPm~j+CGT+sxSB@V9$`WO4Rcr6&6}DNCx@Dap`qGC zy&m^<#JOTP^Dh6;k+YUR!Xst)jwHRTGT4W}oVngff_=BLdY!CKQV2r z7+0x@cnS4)g+zq4;l$|eRNh{p6Ons+%9D^jx()d|as$}y9Mbl)N;C=iZJzSikVaJ_ z?{UALc5Qd_a5Dh;?Uvj=5TiJ0h({eDAoeZ-WMXf3$6x~5S^@5j?vMK0Lqzm^%!)wh zI_8J8YgHnStmY#9%~|9_tg9t*7~k89m_kH*&xn7aZqGwIyr1mLQMnvE^s(eqk$3Qv z{|M<$kq6(4T)KhXpwp0d^ptmN=<5i=ya&o1R8rv=t<$ETRRrB@1-Zr`PGmj!*M}T0{*4IJ%Q`%Nh?2;9PD9?K zbsp&dgf{d8^zm{pwOoLb!BzC>=2`MM*C2nuv-AUzmVJVJ{5_W39v`xh_iQEC2D*f}+i(KDuudh^j2^~n3U$=l?4S&#Ji)ottp^^qRcG4IoO`svlCr{S3%U3wV#*@qpV*>oVo+v}~{ zzUIvtPq&Se7ihfe0laol-gCk-c?qEfy!C{a7}xtb*6tUbd9ui3?LHFcV=m_tF6R?Ck;t$A^8GVW zJnd3E?NU6AVoax>wR@s7FJ642-I&hg{dBv(ONz3{(&x&ruRBwOAVV2SL^{cIUM=p= z|F@B(`m`%mBLCi*_l!KjyCS6)Pg9HkU7r8@NJ{jCt3*$`8by+el2qT4Vv2Ky=`_W( zdrfNe1X)iJ@2gTVX%(wtH-FvzbeA`%&)rXS`MmR7fjANgDTG8tX_hl~+|Afb5xt1;7bVJU&-i&*B zQ#Z2Dw=N9LB7o%(0k9yvIn9b#8n!g!4g6D9J z93lT^kNM)>UftZ5y*6_67&{iX=6gAJ%KBSrzd6voo-CRt=gT86^}kim zHwXSuJ@YRfEbmDCz`^futi|)dhJC~@+JIN!-M6~iJGbkyH^+W1ZBE;5?t5EPSM}Z0 z|KTgwVhPi>yYG(AiY3iA2j0}UmXYSPXa4%Sr1|E+TWMB#zqL!!bkny^lemv(kKQA3 zyn1iUzs9-BH?7OgV+?!_c|{*C&HLJ?7iLJ_gGPTp@1nVX+$3oX8htD8zWxvXLh^Rg zx60dl=nGQq=QsE7Ci9|~dBwM@E6DeUW8_ueqw&kC_~t+THp}Y0rEx8l_!VI#<&w=0 z#p2fZ=Y5yGB}I2>?37tX{8#TaAC$f7z%RZWGwufxk^IlUbwYM1?ix)*o+@%Q^gr1> zSoY?~5&x?sSnA5F?8@)-&coK)*5wHt{&}h09^2MEiwHbE_OPm-k&F z?eIgfxHUc|r*?_7gL|wlql|q&y|3ABhaXBq^55O=uV?Iba8E<~<((hB;noh{OF}fi zF8s}lZtdWngcL*D;l7qgeR`1eeQQ{2)eusSf9&7>q4ZBTjW&ej(XHvj=Om9Giq$-b zkMzC0QqB!onwx7v((Z7*Q*TLI-`44tvn*RYGq|CHz3gU`t8EXI{cXRrjl9v*Elk_} zug?znMSE+RTbS1O*FCfj@%;&;G<>ASuXFocesY~TPa9=zj|x`A0*y2l>cyi z#-rA3xQJcw1wqAj^L9(^or4-Tpjs_(!&%pIYvZ z_xxpn>~3xjpq4<>9NzVJ5+?&_t2pYE{p!K-H^-b}30 z_503<&qj>=&xo7J%d(=QBOjKw81VV$u9?T11Nr4DZ}XV{T-SE`N<)4>bo6I8m;L6) zk4=*_d01wvg+8@;+nxJ;VHzm(1C38s?|uXTNKhpOT~fwqbrsp7#5O`6;>DZye^Q z`Z8arE}}>5ao* z9BahUXOd4J9Dz5}=o{qI7e|OVz81wpQ+yu6G3{m=56|#<7{^T0$*W1r@yINnM{q=n zL(B1_D4!qUh!KaD>2O)5-g~mgC9QK2PGv5QmoICu@9uf+JfTT8^jI`8Lay(n&^DK_Cn`!)PyU)*XRG3cfu4p-) z+v)Qhj@{zWay-A+=Xo4e;?Q#Ze811naU2kbmg5(Pe13uBusF0F{g3$c$8q##8ZR98 zc>%`>acDVydCKRPIL_Qm<5%Z=eud+_>D1+{Dqg(k^CFJR;?P`vebwjJIIfvaasO1s zOE-L8!qF@a6!KmJC;ijM$;-zbS6aM`>6H5tN?9;~b0j_kTnld31y;n%j&{c9W!HiZ zEO4|$xFTM0++loPaV@yhwZLdnuR6LIpI2QAy0M`5!D(K)V4#C{ISq6zxQ7KTEmysD z!EYS*8lT^|7Tl)`T3ic$>v+KU{MNOgm-~X(9KDUtYpw--S>X7Njalk3$nmi88RS~< zBjf1g?5*zKIUX}UzjL`CN5A198=dH1cRXQyUUx0{iRsv8g^Z#9lV^Qy|4dFPIUG;= zbDrZa^JUvb-#o2nDdbU~|KGeq{QrD9TEp+X`!q-PIo(~$H4X>xm?CF(}Jk9#+F=NFqA6fQkbhk;e z=N&ZS<6&P7Ep@y7=|1D6o-%#-!-&tvjr>2f|F^al3jRNM*>3(s*Y?sTP`8)6+AjLAH+7NE z5B78Xo{!4iK)>TQFSzM2Af7tGC+>E8?ljs8euRZa#>j3EAL%$k6bi^3@ z)x26za;BR-{`rdk$b{52$-O0zZuaM+@25%ppif5d$nrl9qjQXqRjmC7zr;_QPJKA^j9(C{g9YY>+h|QSgdg7pj zzq+%HBaeVKtpd1Y@)*L`G4DDbOD^p!mj&{X_jNmutj(zRE06i~+(vNHy)!}1PUWm z+X$SE3X?XQ935sZ#i)12oG=qJS5sIGq{J$%02U-_3c0}kB=ueZ!dIv_87SKx#`M?Q z=~o_RGAl^fI1d8T_bXii>^h)v4M1}Z@2uvAulU0h{sL(j=gNy=X69Ai;bxr6fci#F z+3y+xjOtIlPAQtTn7e_Pi8G}owgSodGfl^K&T?SR#+jnc2WD+@34r58Go_-T#WPKO z4b(Ubfx=1Q=Ds%0%fPXqaA`}w;Ba#VBZb8wcxS11PDHqw7p2}3;Orcw33CxFRzdWV zaC2g*(%@y`CT6(`DuB}~!=;e(Q=l7F5VR@W9NB``IHzq5H`@zUJGCg>RFtcr8OS`O zf+}FoVZ7Xp1=Q53Ht1-$X}~CC#IbNQ>73FeAgDoU22gZEGoH~DZWi+rJjzf2GzHI+ zc9;_~%anx9k`(-=%rb#fmF576(^OjuBacCB0IOS6 z;p-b=T0$etJzma~DG_FRM1)i$9jJ?qkjjP3i!h7lM@WtK067cPdjyD0j*wDr0y37X z_Ye@bO6eBh@M@*O=@G_nV}#xvHW%Db;NpA1^35u#2l7fZll_2SnbJAH;VR8!*1iaH zw-yDxGvK!koe={LJeSCaOhgI&ffdq`Al8 zoV6s<%t(%uimn5K(j#SqSp!_mgz~C_HIXKEozi@uI5$%2d>JU*7-{a}$ms;wv|_fo z+uPe2zjC&jx_Y)0b2U(!Ia^Xb2VCC(HO{%Yv(2u`+0w>mfaa>%lF9sivrTo)Y)RSw z@N6oo-r(BVrV2w4aAdZbc}!^zPDjB zI7+H{30ROHWjf#P+yTtrtlmoC%vPn5g;6HD3~HR~fy!;_4c#7PW|nJWOM!+8^-kUq zWr}vH_XMzWmwL|u3wNtGA4sl>Vyt7N0uSw1#SI|kpwbdx(II);q;n$>S)*|oKv1pH zg}}KZP~!})i!!G$sxkX$l-Y7j1t)=klTp%976FZ?lxCccGIK9zSROFXKU!*(2c%An zmgd<7EL|E+k7(;G1hZE~%Ro~Pq-UsN4=^>0IQDM9v9(I4tcx}|IcT|Q9!M*VmX1^o zly8fcE^`UEvNu}F6I&T=7FTIvg+TQ_r4jq1O=h*yN}%XcG>5LvOF(e5dJ};a!7(}t zYz4z3W6a%cj57snh>J0O*hd7%$C$lKW6T3ITnkvYGDZp-ni69oQe&iog}|XzN}GWl zX)(r|naO#uK3(0B88K!nhPV#!&rCFJ3cQ@N!HTRzY4Hm{MQ)<$N{(m1om&$nm-E29 zA`}cPz_k*k%Ssa&^ORNt>$fRA0yOQ^u=rhx=43t8I73g60&nSTYn(a2o>@tzqnGm% za5XAPvWSmPG9h!7rUI1{_bYpk+yB#R{bx zfk`V7F!lr2Ty#-Nk|{`2!G2(Bev;f$yB^rLQMK2A;LV!uN?^|x)!qP>m8qZ%h_6su z2sH0fx@dQj*|taN1z=B=(!hO5CjWwFaRHclNxdmR!WE?j!1P9?Nx-UWNxX=gP6jSs zm@NHm8!$ULS*n}?EMEq7IJX0VDN0uYnVBls51hM-s)F$GXjTS65ON&X(IJW~UOEuvGK-^AE zZ6}bhTfN(X>OD%Q?_J70o=RJQjB2H4fM&mCQnbbX%S_^Ar6s_|Af@MkBf(08LYA51 z7}~(lW#-B}r7`oDnal*G)xgF?sBxYIW-V85KCmTA=~3Whw$gyL%S`kp)#d^R3e+32 znZ&jztpuvdT-t5ROiQ`al@-fO-7c4R_cF6`ztSSWzfS2AAnK^nEMWEVWzuHlz>#{T zO~8&5DmVcIoms|f+?|QQ>~ku}0``V1mz6vvbh$Y;W4TmlaoBPb8Uf`GMZmU5r42w- zjM8+VX0FnSvCEC$Jf+Kkmbm4zW;5cKb7!*BEFe8o1v`KR>y>T-=58Qk_OU?j7WM82 z$_t^!c@fB~R_|#b{*cmjz>Zp_*MR0zN@GtiH(3{z9tA>tS4h!QfE`nmUIpe(Q@S1q zk5-xvOq{DU8Q3sSX+3axzS5|;6(%!YX%(<&fzl%2@DimrfZXLuYk~R|N+VaUFbOG2 zD}ZHbO7nr8Ym_ztlh&?~tzjV$vtfm_!xmsit_m&zdkS0>SiME*ejv6`X*Q5rth5qX zP@;4_kYB2_1}H33dLB5kO=$~Iv|Z^LAhTTQVW41_7Uv)^w{C@O(>XxX2`Dp#lPk>j z77EEA53KZCDeYSdTnkVd8@SRe3tcH|emih=-bzVf!TgmbC{6_{fzpLaj{}zzSDL;| z%aT@_iObcy5Qtm3Qrck?uqaJwF_4_$qQH^Dm8Rou&Z$K!P2L_2I}FUPS}FU|JYY*L zWngCoEWeDvn`btmy{;)71`alJ922F~nL8n!>xOg)e)O}zmKsZ(zfaN?-a&||4) zQ#}GE7Qp^9N++L9HR%^ruoJj;N$G;i6!MDF0-)-urrQYAwxn{D(#9F;yUJX~sONmY zRkW(oMqqD{(%|4#CVh(1lfax=O1A(TB9ztxyCanb&R%6I;#Nu7F9CUr)O!S2kf5{( zh+V8SA4n};CG+tE!0K%pb`WUXjlekL_N+1|_o_C!a+R4>rF02UyiWz^fwTM7JFj|` zi94*`tw4IMdTW5?N0n{`GEXb51eTmpx)V6IC{4D=u!Jl`hzrW_$~jE(V%Q)1;y1 zl%<(t`_iP>&e)%3rW{c3Qee+fXdCAhpy{}V&8tr{#V3>=0dh|wFwR;a`8?FiSqvP$ zs40YAN;8`;tF{ivx}tOskbG4ITY;=brTc;1*PzCE71(uMz0E*WlhRGVUfm z^jo*u1Qw94aV`N?Z&7U}aI#Qyn_9Hm1Q)A!B`|5%YMBBj0ILqD_Yg3#Mri^Nd06Rs z;Mh5(a~f8g>Pwo%#LKHq^p(}J=GOze1Jk9NO+fYxrAL9iGnHNgwuCD^1#FL0dI{J& zFJ0;uGe6xdUXU(LQU-)BQkn`RC7?CVG9YiUdJh7@iJC$(uq{dHIbh*ZrCWfsq*c>_WyR{< z3iy_)HV!D*rt|=?Yr9gv@(fe8M`<&#yDCH0>}B9`jiwNHIKxyPb(IXLKBn3mz>Jeh zlYr<`O4k7XXH=U6%xzG*5x8((1>qMm%t?%*hhEGu<(Dj1yBOsVl);M8iR0qL1$O@`7+pm>dHuLEbZGvz37?%GTfxL#=za4ARWf(@A_d6Uwe z!0OFfhH4;ttJ34Zv?8Usz|~@<(IuJYK&gf`19{ujTL-KzS6TxE?ogTzEZU{C2$;V| zX#o&ZsdOulzE5c_5LB({t_J2DRBt{IQ=@b(;8&}30dV|?(y%(Jc~t4_W0|G~qsAe} zGtHKIrDuT6CslA9NIk8z8rXDJX#=pYL1{Bkc){fbiZ3ZW3#47i)U$rOz?Mc$c;2;4 zlXXMsK45-}igSP#-!)Q*<$h~80lh~0Mj22VpwusLjqwdqx*A9eQCbh2nWA*z)HNny zy3#6Of0)wgGuN2VSxS?D+z8F01_+E&nhNZWUL&1yV$2$I9V6rDIcrS*T&2~($ygPH z&Rb(*p4{T3R+63%dtki$W8dII5Va-6s3ia*+g0hsR0hzf_{(cW+<*9ck zux-;CJ%N83oKv($4sq83o2#M5c@~&+P!rAt4mPhb9dCCw0;m16^t8^5iCJdnq%2AK zA`lpuCHss;K-6TV>wy_T2zVeXa3NS}Oh}fQ7OHe1u+c^L0cleRW1s_qrYT(jFVSYS?1z7Rm^V4GAA%fIpchmS#ceK!&w2uHfzE; zK>p-xDew^>HdtvsaB*6;goRDdHbt|PUIv<@vZei^qO;BX7`(<=1RPkXUf)I8CU8l% zjKK-O%01bpi@D9Y13bD{6+xBRX6}Ban}Et1P53IXw-zt|SO+SOsGtcrctX9`fQVD7 z%>b61R&Ob=@2q-#&t;oQP1$ljYYDJv(puTYp8<9Rua$HgflJfYNW?-eCMpytuhKcv_QC*(|6%ovvQH9u??t8P&#GtT9dX^X%&#P0?K>}*t&kL z^t)3)bgl|=fbu-@Fdk!e|JGjp5KY_qFC!Jhp z&RtOL^o#3E;3cKYfud_l8-TPX)$RZewX8GuGT1eNd;Hf+68;m{oB6ZXtGgJSJ!icX za~*JIp3>>_*PGP~l$HZa7eSeC0^1Wvg4qLbzI46m-o`n%Y`s~vO|{j)|L5dH85|Fdba|HYScUF@Oo2ItKJqMtWIeL5YVJF5jfehUN(bezBvrkIa1#9 z!2BSkg+OYs(jCBx5T&(%?^LB}K+T*SIYoU9Sh_aH+|$Nc44hq$z&Jy4a?FyAIa0S` zpnj9mz=9l;y*WoRJ_tk>YGUhwkYc5YfM1Eyc%ZOUX+03OO=&7HvD_s9X6($7EhYol zvs=B_fi-)T9sm|sD_svvtXH}iC_16^G*EQXMS*~GN>>0|&MQ3%EgIPUSX(_O8k>x=FpwKt)Kd@iETJ!1d5vsb>6?T+ zC`suyVC$+}*=f`Q3s-9jTYzKhRS=YuYc}jtdJKp;q=H;v{^49%S(|}tbxLE7=9-ja zDkuXIE~>W**mz0ld7$Jf)Ho+M=9<`R>dgbrwkVzAn`aJT)WXj%&s@Twv;FhT)QL)0 z0P&NQt^=kAC`|!^1C=fTnp`wya-ON3hPRE=Z+f2DgOS)JplXKF>p<1aJh_q-7M{mF z3QC)R&Cz*sD(xf?JSR`8xey3Q$}_zbclY~rqbQ?pmK0hRe2Wo?wYH3N&HHi~vP zP#(Kc%5V;tJAb2;EDM;oKKCa#>pr&3kZUz$1D%}hCUVw5q59D3e#A<=4M)hU@;rols zgKs$Vfr&LmQigP3Yi*H)T>zSnKv{2fMP}Yn1YB4J0_ux&CYTN$Z77l%&y#mB6ES4>KHL!9?v8|)b}3z_*~@OsTI6_}f=;&njYW=-udaBz!e zcLUh8O)FOmgB8kYA-_DI9q@n zmsD^9Xt@k^IOkm{Hq)*uT?U+V(J76^#{ZgnmjIVtH0*k@iMXNOOrW$$X%jFjxI_|L z1w@A^T?gcZDy;-|O@$ig6(DDtdaHqjVI{JPwgS;J)w>cn;i8kmOH9lx^`-+0Bb2TK zZbT}Ln_XfK$0!Y*Q)1@MRay$1p09LT9O*7nng<+9P#U|q#563?jMEcKOzhGU-Ryw_ zD@tT5xdha$RPDr+5>taww<)P5<|qaYT2;b@W~I~8O3dmEQZUYaKuM;0uL6hGm6*Gj zOa-kkF%>yvM?V4LH>hF@FfSL%R0#;qQ*SD;sz7Ne5VloIxePc_RHC!vImIPrOPMO_ zfIZukUIj|GD{TPs%azsx%@ta^g*!^jk)4{F->wpK38UR-?JhAPdsMp$ShH8@At0ej z1>1nb2h{6-u*CS+s&^64RHttK*2e@ToDDfT~xtoVER=JTL~Pw zrrxR7S#y4+(mWYJjDM-L@CIP}6!l&P3Zn3`RRhzrOJ#L!2l6)}V9E@nZ^3JvyMYti zR1mbi)I{u3nh7-SE|paqv!~P?+*_(mIk~dbgjB0L1=w_mJlZ%<1Nn!Q)&iGnwLI}h zO3jKoO>GZQUa#JBKD&5>O^pS_hm9EfYoTlrl4AdYRNY1tW8J4nIo%|PE9K_Iq90)VIVe3>3X0kTN7Kj7A+=Se_fe5k3kdGmznH5 z)m8!r^OgE-EHj%i1ogngLcGSA2~<`n4ctK?_b6QfOsZ6x2qaf&*nXg?zRYy?a)zHE zZ2xv{0O{$x0d7CAUE1V4kaI}sAt0l6yJWWuh&!TmGq9pg=>eefIMnbIHnblunH=H`xoIJh=|IvZ`EKH8QQ7 z^8+Nt+@Pr3_?D@59P-Qb1}WVIREI*j>IY;^Q*AjAF%4IJ34+UZ*=Oz~EwSAozH zr5QkTsnQi?6((_;hV24Ux2yLMu%TS(72s@z(vTgbTd6dts=}01D?J5N9Z(v4u)>_H zacK`%nD|~XjD2+Z}VJ=>R8fWRjtj-AAgdM~Iq)VqWvBqWN2KnQh&gh0J_302g)^qu$JUFmAM&hPvF z&;LHp>Kx5IbLO4d+1c58ugTPA;j(DMf>mg~co|2zOfAf>h*JA2WYz&lbzoWu@OQjI z>-Y%Z!l5fPf^-9lJ+4rv?ExJ3WNkBGDv;@+!&k&PEXdXZI1LEzUp! z9BnFy#sRWiDk!pMKxkmKxJP1ssp5rghi=I35RzGkSm%@l2}$l}W4} z2Sgm@)FQySRL<51a7tsE2$&^`-BNnB$j@Wi3vju>4hew75>BlHES54oQdTXJ%9%C; z&Qvq)1Ju-Uwkg1T6VtfnY7yDWf+|39JJV^vNH+@td#dq{%4+Hxoq&Zw2!uX(s2XQW zYN(qO0%9C%D9BR4CEps-E&?nAIW-bc8Ozinu13tnGmT295y3~8mH@ibn7XCc2=6?m zX@Jq<8fw&l3pL_c8K+hPhAuOWzEUH~n^-Uo@ND7KVn9h7(;-0iEDJ^eN8D@4tN@Uc z11j`AfVx~xb;zp~wnep6i?aa#3oIxE_*`U~4VWo~KwyK)|s&s!;}Dtf`J}l?Z4CZDoOHTb&4OXPOHb>;T0rPn~tb z?JBb2Xc90v!GcIU_c>-!PeCMD){Dz_^&~L2uNReWOkEGvizE-G?SLv@rnY|dVj&C^ zGnVjru^7pz2~qW8B9&89kJXEd$JuujFmj3o-Wm16Ef*A5LIH01oO%W@Ucxl^V!cQ! z^S^_9&si*VFbAY5d)>Z>bEgEtC7Mwx(WN2ZH_6K)L@ za0ehWw1K9FgMivFq~ayO;SFLW8mTydj%g5Xah#eBm`&u=q@)JnagEL?2HKeo0a8m?+X1kx;L?r(QmQz$5fD_zG#B98!VV>XoPMOU6uBpuwgYBPvcUgTqqvg64nu(2(@e(zZf97K04P5zrviGiIo0c2qd1kr zsl9-UdF*SEkH`v`S{62n00C~c8f+AP z*H~~45Np*$b*lqJyEReeO8~9zph7|ilW0l=#o4c< zCUNK}(-c6(G0rvxs7Yh(D4^y9(`kVFDW*w)@e0I%vw)RNV!E1BJ!+az3#Ksumjs)T@BDJf;r$EyBHk=@~#l5z|RP;$>vRfDS0BW(Uif7ICzrMcjss z4`AKT+E_r!0MknV&q1b{fQBKai-3#~rUQVvDK-n6ZV}ltoZ1TLoo5=h&?2JkTdB*O z12np}a+A2ViVGe{#gPu6&byUb$Iho!bYMZMm2a!)_h;%6&?*iGwTk;!qq71_GF#~k zOr?RW2f;w^fV`aB9?GtEe2~REzOe zQ9Q|X9*{7@v=(q>mbF%Mt-{*5jkb~$K$UA7HIJ)Xo4AnEM(3^u0p=wT==5=b;D$Cj z*;WQsbP8+VBg3z8Zg|%W?s$hqP>-=QyYpHVBcxL$RKME z4YiBxQBcfV0TE-InhltoW3A_WyYO4!)C|CwOq~`H4i>azAE4H}gXkC_&Z`6GH8J!6 zEqyzv*^dCS{g}1^s{L7P9nc|~0-4SMMnXGi#RY|Rh{@Ovai^sBi|Y`b37{Bmj&z9l zWY!h}3W_`E+Q8H?8k8fNQ?pbc?U2 zU$=<#2gNPPfV_ZiieVCPK9FfYped}Ie67NfEvlQhr|51m69+S#)Qv}KB5U1~xqD z7zT`cb84thkFXD6ngh5H)kCFq1DcL;s(o6IFhA2n6$%E}l&~NXaIKyNz70L1tc&S1 zV5|pPp+9uBM`T^&)JA~A1k*@B+$=ks2l!fDCErs3bGxfla4_Jw7pK+$V*EHYA8;m- zQ`-R9DNMToXHT-A4N#NCbRN)pfvHEyRWVz3l`0>7>8iMTnhVEfb|d;kqEG!=F~_)>H??M1LiDxIbO?NF<=jhi_8wa;*b-krU1@3GwlWV zcrwifI34Y!`^S?3Q7N2S253$1rPaLv2+!cud_YDP(-y$^Ii`L&y&|VjP6dnNk>KbL*xkg*zRkTNrLULTx(Q%A!1+=Dp@U3I0F7fTm;scGGwlal zo?$u)Xtd~~GOR57M6*>Nnau%4Z2PDo-0k{Ay$4cp_nc>+@bTu<J!D0T*Me4=@_Th1LD$ITL=is;?x|#iE~Wb0M~L@5O%y8B>~^6eLXeoWH=zJW|n0^)<2UIbJ|_ftzQ0M5m8Y8&A2 z1y0QbEMDoSUYl6aFR~kuiidpwEp43Y+}AaC+7eho0z5o!ukg&;5@+e5YvEb1EP9@ z1-6p|Vq}_)J!b|)0~SO%3&@{k+756J8l=<|KxsHAZn6Qmp5WAU!1!sVo>_w;{4CQd zK2rKYIGz}y*i!%_RYPLc&A2i$B+6P@Fax;K$#fCWah0i8?~w55 zV|oT)KFKr+;5m^zkp%!&>i|rKt|9oMOFtmn?6kEEII*=Cx@wEi&Mj*IAfT? z832stFbzIGEd27ArUJ6_WdY#i#bK&YEuf;D9fkl6S6E zE*5kEx&}GbV`x|$A7R=FI5fsI9bjWILVYy^&|)(}k-6B8h$@E>s>L***a-s6U;$;W zOzqu9L~=9)tMugni&##L0Q6vigJ;}`u#0D!0BBBN!3-cfk!d@iJ)Nn|@e$FB1u+~t zF(Q&OIJFDlcbXll0bysEW&^5nna%(fFEWiQ9l?P%(=b486^GsduxV$rL_l&E(@Ow1 zmr<%)B4E;cl=@tdT^0L4iJz?oxA8vt23ENBMU=W}W?pr(W!<^hd8qjZ)g=<29& zAL7)rfQ!~+lxkr!hTj1iBVRA)F;V5g)YfxM^aO$8S-s#fF%ZV7ZsB91EQ0AUAR?Y= z1zNqhbuFituj`+DT;cq_9Z3bwU=_0`1 zdYmdB4yeI`%8vj#T$wt%fnq_b)1h(vF3dO$r5%7nA)J~3NQ-1z1&D}ddK%CtQ~Q{4 z(ToKSi+~fcOv?e$DWG@>3LyU&r?vvZ(wXJ}S~HkhWsZy3(@ZY`s?H$>4AD8`q7e)F zgWLIWG4C}Y*4(0x^PUjqJ`YC=?JOi)YC0_H9<^(&naVV5Roi_HXt zUO_h8tpxC^Vww)9sb)F}I9JcK6;RU1bQoaY!ZaK(+RDCOZ4)?~3M%vifJ^?9biHsG z;2kwdMI-~PQkaGVs?sLKw{Y-12J}5KNjDH>0;*54Vir)5!PFviQk*%&76Yef#U&q}5_UmT6hl0~A&v#9fWic(1AxxsOl?n0i4!MTdl8VB zF-0r68W5bxsRe-AvrMhBr$kyWvYF_|0DU7K5TQEpmgH+5}IFYavX%LZ?M| zB(!L1z^Q0XtphA3aq5xeX>lZtQ)>WS*_>*5Zd!C=K{l71X<=}$0VV!SuK}(EFdYKa1v3o|nGuK5W~f`A04!wB2;7#G zcy0!lhh}Kf)CQQ$XRS}cj7TVCS^`Kb;TY-xN2@rs5^%hlQ?CF58aee8z`mbp48VVe z=@~%y9Mcj&q`v3cTqR!_BN{J;4g= zld~cn3)-Xx5PfQvR@_BEQYq6KK;>l?SYDYG7M<*t1aRo$)HFcyFw+LW#WD7s0@Rw% z(KKffP-!*&MQ(Ex!x*5`m#Kx{oJjL$S_$wBn4=n{1IA<; z7C0v|V>z`2&|1W1&c$=Wu9RsuVBiuL98*3gaw^%Z4^Ugh*~S53^_*G(NN8kw0g%zp zf_gyq)Eu7|Yyx)8bF%HioUnDAr=3|0Al`MJdRh^{#vK$l1>vB z%e)BeV(mFV!!Q@&HZm`U=QuTVeqKaaFHkt=0W&sC!)+IYqaD)(z?d^Ao&t7R5RI7& zV$BVD`_l{JT;2i&ISS}4UJ#G1#bn^Zg7EBH5KrSKc|iI&E9wDOGYb@Z5}?&`k=HVy z)M}ARwy<6l9yXwu?E+HIEz&iVdccwMpr|q+rV!bLz8YXtiB!C03J~1L4*7uUCZ@K{ zi^9BPk-AJ0;A{_<)&UrwfB=`;CKpB80&A-Q4VLB-Vpsq~x|&N^1Eqj|e@^uZFqhn7 z%_YQ;0T@VP>Xd9Q1srE>2EgV7v_hW%7|8{d^e%bO=Ceaofw`1b%(NbmdyyTw0Jc-+ z64usHz?78*6%lQ1AywF0P&l>@7E+!!(=I@YzlF3GN9`AZIhC9|2sqck`I-SoTR62G z&^*d?5l}V8bP5n_ZAr$ZfGS&1oKOJF`EY8mucg!#!_+O-QW}n98We9S^&GLJ(CibD zdXiH;PFYG${Y>Kk4g;Wg`xl^ihUqmx=u0bxb<}BRx!Au3AZUgH{w|D4_BhQ}bafsTB)+Ek~>* zPg`p$_!J<|-I}z`fKX3sTHj@WJTGerd$TdXln(@WD#zDaN(p6J4mcLZf=hr?C#@ws zl~M=jI0awalAB>I#bq+B19)aJO#_@b%cXSzj^-d$=r01&&a-bHz@kvr0vw8%<^gg_ znRWxlFEI@)x0ZUUm)z}b;glK~YjOcw$5u1u}mY^7;GTUr4T{2bge78DVjV<-6+utP4u`~s)O0eniCW&_-6L2=78z^)F0SX)JC25tuACNF&FX3jrJir-C z2NE;^T5KGsnijSWQtdHEN;OY&lrE+-y$0|&;YfnxfUp8iEdq=cGW9QVl;*J@TX?af zG+x5mn2V0mWT_($pP^;o<(z!@vZEB+$l_u^XA>x1oYw3pHFSeYdZ!*osj8O+mVJ&= zT0hfTK-MTb3;>F(oG9B6pxhc1qm7M| z21smRS_km4bf$QZ1E#&0#(O(USw2i#0du}gBmJDEqUbBhz3f7b(Y$X&0cvpXn&zWB}6!Kzxu3#ajRv4RMh$rwj;nk*333s9XDnyGZR3 zEbxkSktSj|TTHBrG!@6xFWyC}&tZD_yo+R6$TSyFQOtA#FmZutOo@wRbA@RFAf=jV z6~L>WX(phkiA(DO47a#QxJ}r*)kW&Q$^x%m7ioR~6j$vBU8M8#oH_?MYUN5pVhte7 zhG`xk-JWR`Al(fVs|7IR?n(~69bYzf+)i ziUAOv<3UF+y0vT9)31jZe#q;WN_ub58qrGp}!gdcEu=N=vA@;#ofN! z=_TIu3AOeUqn>_rHd}W?o18{}#kYLT9#{SiNcR(#=ltjZG7}J-1B#;8g}%A0=RAl} zuO!(x`-@0de>#TC0kpgOQ`P4I?Y^A42xyD~#S_cX{vsxUQ+4aG_MS$UpmwGoVx*6M zi@!)5^`}CM0r_}^B(?LpLiuaFbli6v-YU(XEA9cp%_l$(&|EiVSl8oz0b(Fj`7S(E z$$_%|D*4|2u=HcBxLc9velkEboeH2b<^X3iI7fSCfQZZqpbB*X(#~^g8DJroQ$z9s zgl|66Gk_z-OfLe`OW3R$P+q~Q!+@DePW7&Wpq6PVptvr8PHFT4Qkq$t4~Xnw!6|@u zC#R+Xth+ch2;kGr^d#WKC=1E}p<|qy1311AKnJ_-v4J8oK9Fj07Lat5X(7PkIMWEg z)vQ4AOc2U0tD<_6*gS}gD{2ow#4Os$KMdVvM@C4sp8IFO!m3~LS)32lLN zVxb5y-4RG3`B-dP5cu8WY$Vm}YAh10o3OZ#x)5)w|~!~z^5!pZk2AU=v|1t2vgoU-)*MoxuO@A1tD7eVJC z5O@YYT%5>*0R1FCTv!#e_7uRQgdNTUvdh9L^s9jA+Hk67DWIqcskk`?VB5plG6Bv5 zoSFhS#{uWFg2XL&VO$Szv^I#|WDrspB!&l}z#UgZK_V#}8xOV^fXlI9 zTDJ)R-*n!j>H+;Z!L%a?JRgiJ9Gt4V;rAQj9TBARS!Ou*t0n?zxTYUGq)&f(kjO+3 zlBB19-=jZQ)^>!2t)1I#E52Lr z`~C(J;bR*dBJ8pGqkrhu;m(!+?ISk%?m3`{A|^w`I2KH^1Ew&7U?DnNyG!JlUo@ptdYJtaS}FYpAFW3>7z-=)EICMOHK%aNZx#6w7A1wXtW+<$UsA`bUpNp5oAMj((VsK5+pU zYv9H0KP0EoUud`wQh9?l7}78hVPxh$Y}n3?wV@&v-=SY|pQL=$uPNWuP^h?qVd^#$ zeD|0+uXlA_mRW|03-}KG3O^LRV$(oQ!2tyiveT>Gi?Bbo3%C-_JcdP2#IOsVIUOTr$52xV zLyx)qojMz}I2|iW@j`Lb?7HuQ`eQ`^UJA~;2*r~&Gx{Vu$B8R=OE{WLCw;ZDyx6`Y z!W!>BCQr#6-(j<&A6KEzXlAm=&B4oI@lz6j7QAVewv4r*^1f`vCTQcHB6>Vhs0=sH z6x_#>!mYEeCEk*~=m>x+OrQAJRMD22N@ZFcOU2I$Fg*%LyZ|a8l33Ciw#(7dG+~Z6 zK2n^tn>74xJmQ2HjXoiNNBL0JlR)|e&EU;tc!wC)ux{Nu2_uxI=fxaP0zNO{sKZe8KLLT0vgtsHn%DL@Vdv)d}EAvs^?o^V(J6z%xs6 zt4c#n|G-%>fVb69e_3k|gvPpA~cs#JcFV1aq+x_A0$Fbws$Tr$LCTJo_@LFOh7JWZwELZ z=F}9x$t+GS2ZUsEY91iu9Mf}v<{YNx=W|7DV=ncBW;5$$#ID5AUN{W^=pnf@YfK!CkhF*->Vc8+Nsz`BTiV*#OMoLUI5t>7X~ z0^IP*7}}V1Cm+HozY)VJ_)h^Ek)IE|UF$V30AL;H1K7$nfvs9*kHul_^Bi8@64MHl1cuu9En=7I0j zVLvJ8g7CpxW$0q8PWQdFG`u~c_#N*m6`tLtRIfO|G+uvzRiRtAUYBgn<-8#ag|+X0 zMwC4E1Eu2dkunly0OQG*62m5JJ9rglfmM0H9Tt(_r&b0()A1&p2r#nWrqWuoc4 ztj#SG?s;rh1_&=;S^&r_DxregSm#F?H-O6Dh--trlSAg10N+ z=o`>~h+nke>V63?CS`g7U|Ga80+4(05{2FYu)EBuv4Bdv@PQg(?P;BI{#a>))fisp z`Z4jOxu>~Lg*f6{K|{&~Kzd>YMOzPONM>qrv;s%uOh*B2c@^YpkzXOIN;!1_kXysF z2jJVtGy_oG1B%RYZpXqmETnTCh`!B)97gR-?_#D6?1{6>20BTbp zz)hN|RifY|Qt=WLK;(JWUIGl}RZ-kOD|u#U z9*{H&dV{_SkUP)XK0u>gHI1y}02^O^@;n}J7!TPa2098}ShqYb$lC!=#MGUaZ)ue% zLh;l~)MFm%Bea9Nmk2%+Rd|vb4@x6wo%D>J)98=kj1mMv#syEF?3`ZibsSdH=)+yHoGXt80 zd{Zf6Jh>BZCFrCcqtiXIo>D=UKXl5Oj!)@RSJx#xJG*FalnfZclT7p^7=CwGcUyA{ zhCh7n>CiLMA9MQiBl;tM&_;YZm$u`=z+N$#&`Zaj!AE+99iC(18Kj#sDKV^DYjCd^ zhL-BKs&Hk!vjPTiB4~i?Mp?znzI)B(;&kPwhQx`SA*$O2K*o8dmjMA6m?i^4OPHns zE|d>ZpJ)S|yv(U}fQc)d>Q*r%O0gh_ZorXBPAvd9;x>AuMgaz~Q14u;+YpLaw)!57 zqL{D@*Ig6V4cF+~BLJrkrZIq|o@1^;bH1y+1>j}L zG#%jM#54=wc!=+oj|DjUF^vLv;ii0QymhBTX&>_j;#>JK$G`II)3DuphOdcSl&Wk4 zybax_WNb_w7MZxKpTDz)xAHN}f2C#=@8TI<&X0blPPSd=NAaV4V>Do>Syv$SQB^y1 zVoV%4HAYov0TkhWZ=6oi;rfXDF)Y)+d`uMMe($B24YmSrIllbek9 z?AM?4RcDlM&_>qwQ^FB93oqq2SP#CTGr}=qhWc9sU+0tR=5hzA@|wz%Gq%_6h~0r1KqK zO@MUVCq@C^ni@*u*ngCQe1>hM))Brj$a>UP8Xo7{h5RRMr4B5J-g(kinww%8Fl~!l zA()2G*-AZFAc&c_m1-84&H~!3`1Tt+YdfjhmZ^iCopjosX&1l>Hyv@Sd?!2{^?1s6 z=gUegGa>epZ6x2P;~i};jpFVd67pw*JsmWgD+?##&+RH%fhISu^HzTYm-a<^{EBQo zq)#Y2#GBKf-gF=RGA#WL?v|~k_*GcRYa>?wo7jrK?mYw@w-*e!F?c2@v@87cz z|9iXhPuS8-fZS|xKIBQoO?L9wvh^GANWo&_zD6UrgZw*Mc?+bMydl41y&|WBWOBEM z{JRG7ReI&8ou7w5{%Pk=gSUe7=fPEvtvM(@ApSEf_<^=lYo%9LKfCs+)qB_OSpAo^ zZ>-K0YpuoVY_Zl{tbTdzFIWF*?W?OlU%P9yGp9I=X8V*MZ${+bdedwo@Ap4^;=gHMH~qwnetz6^ zCw^WWc@W>=L0trO4z=tLckbGOUoT%OhkS$IE!I8$Uo;w9{+a0a(gHTGS7H4o)7>SWStX>qq-R!1CU>oRW|heecdf<`Pp`de z%`>akth;ONGpp9#eAkW7th({myVgCkYTfO3-So_=o9?_d3BmtA{Bn5sgDHc|HRw9%m7rfnrJ+H1+(`jla}z8&3q z8+}g=E<$tAt>0e#_PS>&N$IXPtKF5RLG-Kee|Xnn8`-_I&-4T8qn!Wxwz@Ux|E12q zL0#!%F4y_yHIb$gC&-}@)z;M!bzN;xS7oTFf`gYoZSyyI`|^nLG&0jR?}Q8+47Pc3 z^Pw$UPe3|E^29ZY{1HX&4Egu8@;d@H>R!fTQhTc+e^`;bK>n0gZUgBPA&~#|HbqYN ze^4;4kbhs1J1RrvA(Pi0-TDFK_l04oL^pkNU)Kcf5&!%A&=X=n|HoR-Q)m8AqT{UCL-j}l2=u^&>e=?niXE|Tefb1OBXp|v|4z| zyo4rK`^@(4*>_;eZd2X~?A*4??ClSC?%%v`AMX!zI*&uBfzXkZe1qV-R_l99jP+{iW!Qy3<&wU1q+u zBa(Kea=UE&V*9S0+uu5{@8I?W2ltsR+p6tCQt}Oj?@e0Y4tVCr$Z`K{ckmRA%(CyR zJ3ltt^~F28_nW?NhOUG)@TS?P2Y2o>+o2Yrvo4AyXgV6v4J10N0N|VIQ(wW`X#{YT%6nvpVhfXFSo<%dv@>Iv)y#p3kP|) zVP`nugE*-VtL+d0-&?i5t?)b*FZ)hdT$qGLi4(qWneE$Yx@+e@P0{rX_LNHfNlNiX zg50L{y#!B_1iAjNSJ{3{oARFBn|7LhuzSz`13S0B$$i^w2a?tGMH2Fhg5T|0zX#!U z%MsbH>*bL1^u64kTXw&1hJEdJvzJVF?b?QIPwlrPA-`z&-J$h+99}0BzwmdqU7*I_ zdvO1UyiQc7*Z1t8eXCMmb^XM^?@p~>DZJjn&_U~a#PRKJs?6R!dyUy?>l+KdyR?2T z@S0CT8$SLW-$UC>bgOkV(9y^k2l)?_k?}sBLSp#1GkK%#e^Mbo_%P(FbvN)iV3Nl} z{zI+&HAuaVLH<7vC~}%LkURnMA8F-DkUo0?@^@D$a+*w${0QVf*2>?7H0LDb(+|lb z>1y2?-FhWQB62*vA_vcao1ovAi9EII4!NV-uI6Elibz6+pRCA$iS8aqU(bTv;ugpy zT$%Zv;-3upPqp$dAWb|2`7@6y@(qgoDCEy*<*krzJWmyPK#~7Mk*7fZGp+mpq<0iT z?)?qOXv{gL!1 zp#O!|zKf*g@}Tj^i&n+Z=sw%If9HYCrrST z*4emubY!pfr}^|7ruz`|0keIZ%(m|Wr@>l@pRSNn{2B1SSL?qU-WRH5 z|D?S?`p{VX#=POFjXx9q_i6ptR&UfbVWFMjnW4ZM+6uMtBji1ZSeYU$`97k=eHy;^ zYki-u*{JKUmE->Bp%1@Db2hE7+TL&N{9v~!?Qo%?kJ2u*JyE-4!T%dt|6kQ%XWJnA zccnS}nD+Qz=Wo#DWUu-^1xo&B;Qvjn|80#Mb(@;yc6s_`=U(dUTL0I~4t%(0$8z*a z+|&ig_bhxL(E7T=)1gg{`=yQFUT3UbWM6I6OL431k`4cFY5gnV{gV#a|Eo7{Dy5A> z8#j(L_wyWQk6~|I^8cX{|2g=7TkHQHc>lRe_W$gWbw`c)|Hf?TB*ThIDD9*A=fM9# zt$z%>-{_J3CrYC4Fy{Z(|DFGN_&=oePl0#iRrs%e^L?NDcAlUy{<`*|n{3 zBIZFKuc!p{Ny|Hm&BW4#%y~m&s#a|s4^5Fll z*8d{B?;nusu`a6SG!0VM)k9s+#tK=k2c2J`{+f?+9?_Qb5z0s#l*{>>`FrNyHB!!& z-8;-a|C%x&SKEsY3aOj|l=G;zoV7z6b-S<0?RBTgp&Vn|1&^bcH1EV**G!p68ThN) zMIrnj)B2x)ciFJ)U-iu&EqtB-n`X+?@9X`G;QzSR|K1U-?@{<;9R1sgqYtb{VA}QZ z5@zW%?o&O;URlr7q|`37&wE1an+?x*$K|*k@AOYGHqIGr&#E;wi`wq$ICla5Pip-G z;GHxf$9=2oU@tV+#Z9KaKWMh^3#CnzxYhk`3G#eL>pKe1j7iz|v!KY&jE$e<`|3{Y z6HRxqv*N3cpBLf#U9ImdJS(PTUn%cCItO`Oyf5tAXZnRfOK9RPMV<{>-+QJv>T0mi z`pfwF$bMtKZy(%?<7H*#Ew``Q4rR#mJ+1FUGaGedSjhK=oZO@9jl}!Az0@mAcd6Ez zesKxDPicLBGK=}rob21wzI#30A*$_fZ<_5tpnBqKwLj27CiS;+_s z75M*9>;ECVH|RF8|9k(4ZTgz+WXH~xO-=S{yQteq1^j=c_5T>&e-QA;dMthBncc?P zg%2AI8eG%wDv{^MT3>&7{&JP#+*HPiuWc;2CZL zU+m}Je__L?RJpJBePRE8oU+DRH8=^q)Gp5`Y&KpCw!$nh6{HnwxUk#cB0 zy@NNN*UUcIX0}h$ziZ(8Q?2h0)@;(5tc5T7%X2S!erRmG#=I9NLq9azzw@8)$99Rb z>7)3o`-58eKcn^kExgy?D97#L>U`6Bt6%!Ki?w9?ds<_NM~ZSaldA|cducCDD6R;1of8& z_&%%k4TY!0t+MYUcfS~HY`j*+>ZOjT_^RV|BYgiy>w6iV)3?d(al?mZZN|3Cx6N>R z6}xl;e}uZU9@Xu#3I5M%{olNOlg{}L*}t@P;sx3$wC!QU|E;~IyVZU|N7mFX&G7$) z)_)w{d3VbG;rEKSjg4Es+x8I#trvIh-m!D{2XB}jz$nHiJC*iP$E_Cl|5EFJ?_Ha8 zy;!K9zf$|Be;aEb-k)7>ug0&oPb>U?rS<>u-J5iS_rM?H)=2Bt4~>m4inVg3X{p`S z@udyE&ue|(xpxyLadO-a#^&eh@u|F_F4sWuSLf%Q@P9$;9|7+- z9+dqT{Mu}c#jjpEU!;l z??$a}GCXyU%Dx}%^LiDU>$a!Y&HiC%tNN<@{~qMor1kwXJPRI^+hgM9d)66i@3&1q zz$HO-Yh5lv)81F%zgg?Q{_#z^7oL#)tG@Nm-y3Uh8n~ABbj$W?{AzpC9QY-ze*nC{ zdQ$d(CE@!|8}nbWKl*zAKKTDe>wgj6FK&?i2VH~0>Ew!b98-@U46LDA+Ft2Umd3Gu z_`j_6w}bcaQ}9QB`N5CJXwH6}KepFB|4<8A_Ep#G0P<|n`u_6!n{?m!f$aOB>rV^T z8}Z#_whL#zmZMhIr+VBo2;Wzq=k_1T zzBjz)?qqDc`NN)_JCDr!=+ogAI*EM775&NGN zp=_P=%Byv^A5 zc>PF)mc~bkU)>%j;Qyx9|4;CC_=VgqKC9R5G}d3<-D$SVup?^v3td=yOY2(-&)8qe zzNvYS8a6I^T3BZitqd2;PTBnN;&HJxEYl52W>evC?o0xIsW&% z&pl)8c=?6>%Ox4CNA-Al7QTPf`cA;}+Z*AF?dNazrVJRHzrBSsiTiQgLSwJCyE=cH zga4nj{vW}+Ym@9>cUwccvEvC`R(<{8%3VE$ullRULG$o`SL^@X&6{*{SZKd65twn- zSi9hSw5d85GVoQ~WdXkLX??f6v`J_F8`<}=Q}0v8eAVrTC*#ZUs=kZx{j=6L2cG6H z!xtAti@n2Y=nATKJmizM%YHP()A*+Y`HR-?BD|)z$nE>j7iNtful>Ok*Q{~9nobGx zJz_AVkJ9hd_Qfl6b$`|Rzw*i^-G<-F{#}i3@y7bca<5xig5t0C4+;K%)B1l5@0(te z+qv%Jqf>O}hPFMnyl>Q(4rQoaR>Al0THjaU`N`|D@4inzy4{%Xrak}F*95*>wZ3Wa ztofbn`-FI>4w~!Y{Ua{-88)`k4(k4QHS(BheIIyZlP>c2vhVKgdEYVC-(K3YZ$-e0 zuiD>kfbTY~Zy!9jza{&Qoc;Yjjl~P!ExQezHSw-Np6y!Scz8O#qqM^}?K{54_jiV? zPUE{4d3I=htKoV05Ael!6#rPqT4Un{o#ffM8#mAV_x^k%{LQrf?eLEJqa3$wV+-~9 z>(<|k*u8vgV6KUK9rC=d^&N!g@juDFbq_jr8QZ_SX@-l01`VzFs`ICt;QN8rcNU(% zeoyv&|NaM`#h7wkytG#_2v^;%)&1ek@cmHhYY)$qKP$c|zyE=;c;EWR&I8*GoHg;@ z0^gll-#K_b|5w@fi4Xp?xZX&-ubOVdjC#jX$cnEzpS~5oA8CDU;JNj0vhT=^X|B** z*A8!gi247L>(cn9Zg;mK&&OKd2zd7XUG{x%BCOTecK7zaFAQ9leAVslcKGhn`u<|; zCf%ede9@0zcN*PcY}{1GWrIs+sMV6cI&R(p|4+33Z*1G7duqGv|7JtvtHzG!jQGF0 zbN>Ogf2hZccfxXv^SF&URZiRg=)C{_z33;H;NYG|!zQQhqHafYBNWX;9zZ$&(3W%Cew-m+fL={Bbz_P?LjNLjIv(U?etsl|6J?e1#g?rWdE6wH{LY1USN%mj96<)KQJ_z4`YJKP6`S=&IZ}{SYFk|~4jjtN7 zy1#q~zW>tteh5$df6Bh!3{8(RHlEAA7+ntTH$!z)Uv)fR58r=leaqn)^Do&q{yEQZ zWAQHa7r6k{SB>{!_{1C^s~p|YoYa>gJ+Z_e6b&X({0;h#E4M)sZfqjZb0c9t!_ zww=ENUu&&zC_Jrg<@Wf@Ea%V0#-q)j(-k1oUGjLnbVrHW9_o1XUHIE*{Y&8eYkN8F zuPzid8oR#t%Kp!nPu?tVe>BCQadZQGZMD8$@Vv=UiTjK1%}^)RjzjQ$%WM~J+uySv z7qXU%Q2L2_9`Sqdx6}In3Emf+WdA>G+AwddpTHlNQ+KcMMi^?lsQu(A_}gp!?|0s$ zo5w=^@IkW@%dhdjE_NgS--o}0)_;r3Cfy!ax!oT$pQ~7pjPLK+w{-XEmfeVcX@KH8 zP@mRpKRUx%~$WDkX_wuic3{~>&xw7wt1)5Bf%m8Qp? zjcqsZUD~M__^RX6kKpU9^{s|yq=)QV^}|c%#*UW^eQASK+Ce>D`Z0W6w7#S8yzB{I ztiK=Z&7L=QJ{7+5smT>lFZrwIQ=f*vtJeQMuT8o}EHqxf_tCZOUlTVr`Q@ikG;#j~ zzHVCI?|N_2J?A6)_U@iNVr+Y)`P1^=a4BAOd;BSU4{3e>1JBLAvae&!zvv$M>$b;D zX78IG+;w2dS`+Ux@O9VvI>U3TpX|G#=O;eUT<7~Tp20YPr|(o_#aBJf`x)|hXnn)r zdD>t0jc(5>Hr9_fn&NO*HC^r(YCrxtd_A?kj|bp7X&`*jFX}#c_Z?&XLOu7myyt@X zvcK9do`t`c);|T_&4=Mn^VEln9gUq|e(OWbPCqo=y>tV(rXZz#)bq>#gL1sJR7XcoDb9&$CSbvtslSV-WPgaY zx^?04gY^FR-q`q13en_Y6ZLVL8+5cwr3&g%!CxsAbdewExE+seE}(Kx)}n%MxbJhM z%=mPAdP){vT+AUrl1#GzsotD=8qgZWbPC`d%`^?r5$j29F%QU&l<=R*uIcOn7?n+M@MIm3ae|d0wfE`p_po{<0BO z`FkB!@bEX3EHtE1mcJs)uMJo5%|2VU{tHseddTm&RU`i!Ex>xyD~Y?>UVcuU|9wKAjw;&uk~QhC!}oD!_fzlQuZrTFZpW7iGoDzb4nb zeYgQ=$3%tE9aJwHYk-p|YUdn6biWT-=#eej@llqKk!6cvd^h89GY3c?orgT^LC9C> zmY=8E1^Fv0r>zF6qW*E(OXkN7F&Ejw@=$47}YH^q&Aa`gZ8Ddq9#ZKP74 zQvM}8>Epa=v+|TOJvn+4Qs^n=PY_Jtiop@Fha*cKg>v)nO;yWwzH>u>?NZ(G1FGyMj4`H^7qp+_tCKpwP0&YSB!5Wh%~o2XWQCWDFNM}1hfwsLbVxw1M&NuA%`_>($ZH7G%`*4OAe@Z1#2DO9($Plj05CyR#RtY`-|J9Q%+sXaFp5K~%0aPNS zq-r$TLFA9n=D&=*Uz%*zt!XvE1D1v@@EPP$TDdKx&#jh=d#S#7lw#Xw_Q{^lubL5CznPbaVQF7yv^~>4 zoVJ%QAph-(4FGHOyO7$ET&VKL6**m#dv%4JdrTC>x2!>iJ8n??X^u_)|3Ze>R%Gzn zvUL-rFWwA!*0&WoJr_gre?$JdR&ECAhFc(i_%TJU4iH~L{yVMw9Y}|7h5Vf-)dw_9 zmd3BIkmC()j(cz0tns+1fYAC=hm-$6{(G%F5z=4X4!PMX$Z0rH-|Aux`J0N| zaKQc#)SZ;0@=0Ze^MX=gOXPTKMFHMhw%&8cX5HslOitae$mzZxY9A}e-`2`QAbtH# z$XoAG;(0=mTSNYiR=yw7T#`S2`||j%R@eqP{-Dj#4)x1-A;%}e!;`xYR2+{j$RD-x ze?xlD-H=~=M3K|Dy0>(M{LfmscwjRIM#wjRYq|TV%|rLF{Y9I@ z9qQ7DkVD*u83o1juo90m+pDH{2LqAphG6IXBN?h#zM%*5@GoBgt!TR{Wvj=63_xs+Avv^ynkx{w>8_-F6Q_ZmN}+Li*ujkYBxj zxp~wa?#Q9O&E-CtuiuP#9H5?}9N%51OmNiIQZhit74`-(Taf{i>q1E1dJ=MnN0oT!ge7%PI)Z=SP%cky z;vw$&F~)0}&XJZrXPUhvs}?UhEQkhlwR_QTa_Mfc{EIn%Z?ffXxqaxdX|==oqJR&S z0xXppBcg9Sjfj4Nh(djQx&P2P^dA}qB)@OU8rm;^N)^6clOYHhcCN^P`QKSc zxBd+BZ>)u!I-ENC1VjFjRvr%N4}T8%^dlO12;}@K2!zM0SDEfYylWHM=dm?&AJC>w zpClV^X%Y+CC&1QQ>b3Xg_AxQn>00phijUslKa*)?R9cOxj=I@}qTpRhoK}Vdt1pTz z-GW%tO|WaA7bmC_)o5h-WJT35nOcO@<1NT5?^NV!OUFRITPx3j^rwG^{9U2Q)p0l$ z@;zGlR!A?HLjJHQ)0ipF62l-yDydP5g?U2tu1UXG&*DLaP$UoJ}k3qWU1IPy+ zU*7Q4+9n{!z7;um%W#1H2Ol9%)Hkqapdyyu{D2JmS7g9`|96m9lUzIwx#7T?2>Ai6 zd=}D@k0Cd^Pm!y$iX_MnYUP8F?%M_V!8M9p9VU|@|4b|Qh4kZ3Ag|Q!l#fFG53Sq| z(tCD8{;6`*LgA~2nJLKe`HCFeJU@m0HOljer0jQ~;}w<)^2Le_n6`wHd@tlX@8SK< z@{Z&f7iG{}H6G`9Xfm8bz)~ln1%9R&D|5 z`vH)b-=fIXDP}(8E?T)gq;VmTuYO#St3yu#h_@%fT4igeon0h_zo~V z3OF{%^a>z!h-p4xVwkDNh>sL7$MiJdtc5R?)&@wmWLgi1vt@b#kZuPm>FWTOU3{tF zIl!PRrv|$DO7WhYdJ%BQi?gKy#$@W|?JN2E%Bg_ROs1KDqBBf~0kPRk&jD%*n9c#J z3z@nU`ATPsnKlD_2ACcPcnvd62V9#56?)GZm@RNPmjNzTesqp17SLqv$DspCU6}R* znuC~{2m46{A%4^jgMg!)C++6B&K%Bphub7r}#-3SYXx! z@XX|F$$+LTS#ZWrax3zqbJWKGhbtiv`b&VAMivwSdfGXgdxxJifdytBoqkexFQ;1d z`AM1Am^K4ECs=SCP&mW13(zsobOA7HWbH4F+JWlyf%g7VsWa0NK%W~^&qMyw zfG1OTFMsKxH`5`&316o50AGKm#{mg}Op5?nK}@>19B2J!dNi#0+t2J|J}gsY2fem@*Hb7y>K;qzr4O)qsn3OosuBj!Z+G0;D-C&;~gN zNXK26)&P1uL51GND?qCD4xrGd024ke2=EP%0{l2N15n`4sbc{5K&Gbv;n7Ub0U~3V z<^$qlnO*{%jAPmgD2!)14G2wUng<9u%0*-Y`i=!qU-wB1kV?~;9y%T%6`x@35MbaW zQ;$;tQaKh>$RPuvX9h@;PH%rYK&m*ybOBI)mIc#*;%ufZfTVo(Ee3QHa>3q30n(+5 zEVu?JyCi1=JB}hAOMOXKyy0b?*eQA5 zZYmu!k=Pf=5liZfwN4L0QT;I6mA5dO{P{Jfl`kzr#korO1S|{y8#7XF3E(J;Agb z;B|^=I$$`HsrTtXY3eM~pzJ`Y>>SexfYW)VnE>BBrsn}87eVm@Fr|S~^QA!QP!{EZ zQbh#|h5(^efz)5l0ouDc)vYH`x;nJ!3d`_(e3=}Z+EIcfg zmM|Rv1k^D-5Af(>nggi01}gM+!-u7c38v$KlfFR|Lj$1JpQ%MakaR7OY0%*ysUbFq zj(}a_f}}1isPQ6T>O9l5+#qSLh-qALkYv@s*&+eHjhtEmXqrH(&@TY4O>*jyDVX^M zOE*b+5C32(J|LKUivaOKOfLXZ&wvVj7r-MYm>M+|5L*&V@m>a;stcALq^&7fIyVz6 z;V0n604?*NI=$Nhve|}^whCbH0xI-TfKwiv+5iYU%Cr;^k{UuqGy^W4WI6`OIvql0 zZGh;rAr#~lKtTa#8vk?N{KDD;5p5XrTf zX$jzP2h(OiMjz98K+Oo#$WdgQVrn;y(C0X^VnC;5C8+&uyA zE=SGj)y$mC9n7P5>6-nMNmsO1?=ubCAo+aB?f3(E7$xFYE69KJvVKFD`)4(S>^Gcpe$NS7g8m ztxb@w{Q=~!-3B@NtLJ5MA@|YBpM=zwT0dSsUnF%DXvK#>B*I9aQ_zR0I?jiv|vs(c5ZgcklQSHmp z%r8LnhX+viw~7c5F=;Ftf!b0Sli`cwJRe1t5VX-J*hS1%M#^i4R5J6_U^lO{PjmuEG3~idM@nij8kgkJ-Px! z)MYNI6fo^(=>nt=v1|t{$5`e9@#8G#fx!uuNs|GheU_!wT!6T^!gXx{@-|s61Lf|4 zVz*F_0O4MN)O0mK=|z@nFy?E zWkgb-2ukDBdcYzhP+X56`TzzqAu$`y3KWZ2FdRo_2a1ebE_f1fEoE5%c$KrP0&?p( zvo4^xp5+vf*35<;V7Q&7Ye%54?F^(*JQ^tMW^WHr+rx4i=353iHwD6v{T`g?)8O zSKM5tO&&TKD9UCy<8Humi7V_4R4sFAA7HhHR2;2<)-9we)$@RrMUW5zCpSSN?kuEI zT?8apu`B~-Y#V`-ZoBpNTnpj6KS4Ryg{$2RpGkk=5*O$ijwK;kFlfWReA z%>yDXVS+X1NB;O|bNw3>Ci9EQ^80S(Zz{&=O1MoSDa z7-N|NjIOafyN-sj3@1Z4P;CpTRIdRG4xH-e7%t3RSVjXi9xRuD^FAyifwTTv1JD@6 zauM*;uq*-mVzd%4OJEra;>TOv-dnu>7l!c4j3QoNO z1XgltCJf3EVPz-wufq;1S)&S>{SSA4*$t(j>B1A?8 z%Vr=ei{%8cRlqr*XGU&K+Q@y}o?OuHqR>qA%R_jSN$yEs>fTd~MmMVY z;(5$2cU-#}Am3c^LztfwCLN>rk<@*so>xRY@`6J7suGuHUs7629q{Zxgjm9YR&f}N z5G_M2CxNglT>C0uXqcttNQ8(RV_679j!uNto*GKE_tFTj5SAH0ayZLoz&VO#8qk)&(lk*c;?h_)0@3L#n}Ded&etYW zBLed|wF0m!WElfYl(E67Tq7=5vs?w-+t`o}WOlOb2C8~kE&=BT*c%MkT;bFRz-*Xh z0FW@jhJN7O3a3T^t`?EheUgF5vyrr#4M1otrxpMnah#d}bfmI$Pm2_l87$|3tUQ+e zz-9@{kkUvIRK>CwNNQ!-3S{@O>;TM0v|hk^j%7X&xx%s?n6!Nwa%2@hA6rNC5t z6z>`dQDQSSigp5*v?!64A4O}?3IrCip#bnN;jn7pY&pwBVEqzH|B5JF&1BgGWY)19 z2Q)3516}mW#gCLeEbj&yjhV&--JU+GD!O+`vut@s2sorr_{%%OMX68YZs!Dgv;B|J zqwjPzrWrYXrdp%KNE;Wp4mftQi~?-CSt|HtV|Ws2w?Qc#hwz}DREp`fLTXSz;ae!= zzg@5XpxA0pl$h`3?9TN?iQX}mD}com%T%CvmK)A&E=rWHvYY`-%%Z80LxG;N(bTf$ z=c2{M0G2s`RVd3eU{DiHo9S$1w8)QUsn~5p=Oij)x}&PwMBS)-pj3J5Yn4~M{emLz zn6`!JoR2=SvC$$Tj>otbHJdGu|s&W@cS*K{OAS6&tg%CiR(Xx z5J+?1(UNH4T+Z2r0GF#+t^l3gEN6kULo9=UrBRk{W6{E9nPm*%yv2=`09>+&p@Y^m zkl`644hr=IQ0NsytvLV`d&7X=$p-2ISxy2Lp)5lHr$m;CKzbU>E+96WWi?=$A4A(C z2$(F5p>fNmEJoyF!LBd@Bwc0$daw3+Ev+lM9-VvBr|d$E2zTM^GJzD-4;8{s&K%O2 zG2s8IZ*`t(ll%D8Av_c*_wfVTEvUWn@HU!uU)&1{mECsfMlq~>fY^asgdtMQL4KAA z_qcxnYZTc{Z;g& z@2(FwwBH4*6WP<{y3-qa=-{>o_6S3JBCM&2u;*W|J+4s!Fml9R2#sC@-=^$`e>Mp* z-zssVm;&Sj;dKaz)D6&Hs(3W{1x4Am7Zu7*90nUbmDFdie?r+HJG&7d#L^2YNA zg6CM)09NyChytQEIkf^9c8%rv6ky>FiQ^rx;>|MfVys9AU|9lO2x4zC&=Jb18$fRu z%XPq46H7h12xyFAIStswvdjhA;@ImDA1h*#S+)R6nX%$V{Hk$QthihjOXpWk<*}l; z2B`}57*N;1(zG#FOti4{ZjBZ5?Z`o?4(f;%@m*X*JK)&^Db%Gv)mW_9qf}dr$BLo} zE^QjPxX3aIs90jT1f*=RtOuGmwN(ck%;KnsD8Shql5SnbiGp)1F9WOAEIn-EM9F!U zirujqjC1_K#^;anfYa4WfbpZTq8t^&ZmCejNd`c5_#UYX$i3`(bb%f_ENz5&O`C*KWqkw{(7om>9iIi|XD3x92@CE0k46PWy_D z{^}i?9ZkJ-})+OS_gh z;nK=7A1G~OIR$uiv&;lmdstrRjT3H`@l-@SkZ%`H+qD-s=fpAqxOjnO2H@+$G7Io= zji>FF4di%pYCACO$MT$iyy(G#%+`R_V3t84@nSZa<%N`ZT$p270NCWR%mq9vSf&Ca zOEoH`9m*0Hp! zPY}6H3DhLBz;ZjMx^yIn0W8=oww(#Wx0_`e5I)R?1|W2VWfM>{$wgRAC5W~qZXJi^ z1hHV2NDbv~o+vyl5~*fcfV*EJ_dXywfK%Imkx)*(5SAz$(l}KExaF~|0KD@PY31vH zu`2euR40n_bu5#C*+DMNaVSw-o?x%(WTFUNV0j5xTw@uu4zFbr)$GFABvIs(MESM@ zWdTW4$#I}KkW(jsbHSV%4s=JbG}k1FsaTewaY@2CfxSsURyNB4ptP7}571W1@RE;YIn6Aa0N)mtNx-I7`n4tr&n`~Q0xEmC;ANm@AW7hd?Pr17Ax@nD z3WiyZ0gGcC<~p7v)+adCdlFTjX6Z7MB$92CXOj<0(;{UQ^b5V8yss=aNUwqr-6xv6k21y#uQQ2!m0Cs=@e3xYHy%(mJMq_ z-g1h#MX4SIY%Nl0Tf_n*cBz!ZMf+5-1kyVog?bIh7~>*Z zfz=r{_|K+_x(!rEsU8PPH#v0>2;5>>3~XAYQ6>E>)9{p38m&+R;A6$9*?^NZ%UGb; zhUEa@Y0I(zShi#7ZJ#C@oLSBRO&8K=gqa04+|p>}z1-78xF;KOfP5b=Z4t=wWA7+1 z6wJ~rBu%V@a+qgWnwW~&`VfYdZj z?FRaCSX$+#3-1z^nSg5r%PgRylI1+G)s`-9LJsZe!gGK#%LhWQu%Q^}7-zWx#Lls7 z0&Eu2#ZG*cITb8lkLu^a{B9ay#j4Nfdqfmsih zo}L*Z+J|L1kn6{C1c(V@Sp^h^up9%D<5<=MMX4+&feYm<6M!q#EM00c#AYo^|GEs( z+Q8DRF#~fPmR8LfBDhma#ycT_%kQL%@z1(vupz{CbG;0 z3X(Xi8?a1a=?~RR1IQ>~Lo47@$}$Nst6=E| z%v7?pugVlzH5}Fstk$vguFn)34Qz01#2RBk`&I*)O`O^UL^fyAu$~84w{Tb}VAsm2 zk-&vEmQjFtI~zQJF|9Q1$P`Uju(?Np>`s;~fJGOV<^?45a1KR4!!V~#0!0%n+kx~c zmW@EyEZ1xk=viR71XP%1Q8yX@vd*%+4A`H~q7{k(l3h5p2B`C8IR@DIvkV3*0R<)MUVAl4TZPy23I7NLpiW9S~@h zLj~spD^5979S`Rm(dW%_6DaXvIS9o0v8)5i16W=GnuA!*0+&KrjsnHuEW3f32$r^* z98nj^atg?bV%Y|SMzbscmSb4j$L5G}EU1oST#g8iXITlDCUSkFfPo~Ij>$PVhhy0e zI26OHR3`z}HJq9NOti6dYtIoDquNv;WSwOdu(ruEXe$SMPcAjd0x;v1OLegK&K1GF zEE|EQ7?xW=Um45u<+&oDf@Lv~+rY91NbF$Q1WeAdbeThDOS!arRRYuNoO*F1R}5I> zQNeDOd7}I*%LO3Nie)1(Ym-M|-skg#jU&rcAj_2veSn=;9!;oYfHa>xx^+ARlm_O} zjz0{HgyhlW`+R5~?pUzbCLvD*C+5*=W&kNEoNqmln9lhQ04v#S@XW~*Ir%L60GAS$ z1wiE`mWx18JIivQuAAi*pl^gF%@Z}b3HtlE#vf1Vs*Xrs?9!d~{pDVTB0=66r=?3! zctltH>=nh^v)C6su`jwR?~wMzrTIKjY@JV&_+h}#5fWz_z}dijYT7U$HHlL@0IxEZ zX@F)72Bo?fu%6=7NWf=?WiGI#m0`2_!g+yH6M?EFmNUSWO_o+$`C`PffC@f;wm@uN zVCmyhATr%pjsVFXEW3cDiv?7(9$>?#fHrTGZ-Kbv$8r)V2`r#(It$oEus0W|*Rb3K z3ZvOO4)`astO8uq*pLa7WO3>hz$=F{%LGdEICTWbD`amk&{@MZTLH!!IrV%~foN>y z)H$HJljR!F-NkYf$nIm=1_TVS%mdC3v5W%3hgp^Y$)hamfQSi}seL-iw!Jo8w-U~C(C4@xR2!kFxb!1YM@XwVZmzh z{|6ndUsWjIxbU?H$Ks1oK6`ldi+pzRFUnETJS^5H@fh? zURAjL7%TOY3;*YNX{CZ2ibQ!c=iLo-wsDE3?L}e)3s%aiqez%_v5WvbF0)JoT)MB; zqX1J0?ML_7-T6fO7m=Mj?e{oZ1m@T%go0MKElO!iSX-8g z!E>dwKe}3#ihNs6odEJ2N-2jaz|WawG2rCNG7acLws}98#%n2cqXnX@fNY-SZr_32ZH~bX_hL zt>$H9*aV7g%4jVnfLJ?7e7_DTcHq=rpwPLDo|(G>XgoQr1Q_ySY2{re%zfA!4h;Hg z^9Az!S@r|1!7MF8%7jlOdkcZ)0xoS6m@eeh;G!}ST3beIQ4Xxfm6HsPFBd@xNkV>_E(c5r(~@>=EEQ|by!k>}N`+f1Z<1tSpbcAHsWxlJrtV-_3xr_9qEN*yx*@K} zlX9U`KlhL1i-dl|r6Lns9rfhD)8F<<&d^I`A`oqP^>4SjAE&p9(5EM|Tr8uF>2G@? zwQ0KlDu3dB^=J_5c}Xn#@>~$;}rf!Fe1?;bx(*C;lQTeMCcqHea*jxLtsOspI2s96tZ^cF+BujS}-wXRHtUf8Q=idW6 zeXHUY6iUOn2KH<{JKyb3gZZg61U$E!^H*Jc5o06*a`XZ)kRFHiZ|SffzD}x+JmN&b zo@;0ihSehzcBj3Poi-*_CmQxVLwgFWXLDe8)j1x?-#&>!M807}5!{7&h**$c=OEi- zVJ|SW|1$qI#SaT$x4sRJ;?RI8-&&4?y-?52&GUPh)#V6ilV2nxZ!!9*@FKkc+=)I8 z>pPcV&%RxH)CVqJ-vo$aL;Ig$T_Sr3Rlixff`%~dBGptb-tOu%K|L$Jtag>Lp6-a}{Ge;JugvdjW9rnul9z;>Eb!+?Z2mKDI(3ZzmUuv#Hj*SHAx^$Ky$ zq>@%X5EwP9q?O+QHqWxeH!yCYYigq=X4sI2u;8QSe|7&+b?wy-N;~iho`Yn;KAMFs zQYmeby=ti&y5ohc*2)i!;Gu7LscyaxgD(H~Cj58Db=&C$ilLeoJ9>d4-dc#a#D0la z+@8_?3cEC8{BROmbQz24t|(kd&^luJ{cW)#HYx_Mp^o zy;AXR9J?K_E84KAzCCnp#Fht)UPM&scGta#Sb+Pwy@+^{Ub|$3w@Apnn|_(O+R*-U zSUF!$hi-|UTbKdgZ0rHVP7}^JMEc=q}&H# zuhX;huKeFHZ`^`_r*7lgUpp2IA)sC_0LPj;4!({Tr^CJ?JzPdb$iwdyL^K#igu(st zZHTy;zGkO8Rt&@5XlVa4tnb|p`&|cMr-PY%^caD?$%Z=R{h?j38^JCQb)&F1 zOLnOnqyCZbmfZ<|?>$oX@`QUF{uaILxqSv-9|!EmSh9$8I(rC7y6LtH^6ZfR(D?Y-EX{82qe z=Rvd=$-Q$LxwlKXn@FpS8vPMD``?Qi&Fn=0ZGd}_0d=@J1a#%`B_E;Y5zwg@fCJPwupWE}_HF;>#m5VX=rW9W3hvB@5%DD^2vi|?4_JizvcVdl zgf@7$Jqo|+J}E%%aVrSumIAIFGVh1C_Hp>1+$H(t6?N$m!=GZ^7v!g*mGLdw_Zymi>T-EgRy25xZ*YqMLxPV>NZgSYXAKy>4#R zcvP3A3*h3xh6JF(lfwpq0WVIq^sW}OSdf|R#cC1c!!ir-^ko?jg!^;BCBV4=P7MQ0 z16le2r5ZL|0WL+doCQpyxrhK@C61+Ee6^S_su6p!a_+@7!l9&wN=^lW%4%peGJyGd zPIYOh5$76NMgwP?So#3jt?X?DO51Cw{rZ52PL_FqO*hM6z_*vfasi({mKlIsKg&2k zGr+PMm>FbgKU5>yhgr@6CX*~Z0q-f6S%BpX%Wxojo@FQCw8%06C|G7W0hp|^^aj?~ zSo*Ekh`245RX~tQEww{2P-4n*0O&Mlxeg>-uxtlRENiK6_yF$bI5i2dv0@ntY-**i zb*;#;;nd53(|MK&K&mavR=~xMWg0NAm9F-+q7DnHGY$ATY7Ib^6U!E$--YEm;NZqG z1(?@LJNH`AXzfkY=UJJusX@od#YB9O|v{dQ!A=xSxy28^DJut=S7zB!2A+R&t|Q4m7N?+yKg)>#5BwF4T(^ERY^9^`gd&ydLprNp!9j*Y`t1K@A=gb-?H5Qn$VCiJpAR5oIoCCtFS=Iq9 zwk#KcEA}kS9U4Ru7Ua+XoONUw31m63><7GESQY>!t}J_iWjB^S?hT^HgXJ3F>cuh- zNb_Y`18ixf#t-@Wb7~t99l)|2Ci?R8IP(bvP=UmCuviY8^mfx1N9w`%m$I1#i_Nxg*=wYKzSj{d0@Sm<;4=L zMJY?qvIY@T&ax1wuV%RbEY+}du7$*cMs=;jS~Rk30J54`b^&d2SZ@&arF;_hB8>vx)>>5S2 zBd1OPCN3;}fe_b5+Q}+_wjlN{0G%P6x(alMvUe4*j%cJ=WEkM8VVMkUU_rrQk&VJH zio@~%t7uM*0J`F|nE`7FEIkq%MQ{?!0>Cnrr5~`Wl^4?*MPVkV4gxLNEX{Hn#TXXU z*D|+JxD;{`;Xp%iBYj6}25>KB84JWWHi|p9(erwZ!eOCNJb+pPl~_>WQNVSPr3NU| z%5K1JiOYxp>aoB(4+Jka(ljUw=*5E6Rlt9RWd_izl`DYnDyJp^tymB?1GukoY9bK1 z&N2_UxX~z7I5kTFJFvjK0C;V(i~~ltxUtPmnnb>76Rmy+FlW|8o7Ta+Nm!p{=?4^9 zHBr_2fr~cmO#@=ivupvHUE#&u7Plrb=*QCDze%`jn&>h1B%m^ib65ppVmYiH=#FP; zozNt5l2~2_%riL$Zy+{{!L#sE;$pZH*2Ow zGcj)#8(1K{ESg2`IhHNJnsqZZ+69|tVQt5$5x{^)GiA03j0ZN0J5hJfpl0C`!sblC zHk@T1kRHjh7buHvrj0lYw8XMBi)+U3DK*p1Qv@Uya>k{=LKTO3RyT{{IxhJNu+qS( zUX9K8GChZ70hfDOTJ$xGm@CcnBv%Wd8D(!H;4#iJ6NsK@l)1B<0h+zoumN20;d~u@TSS*1%XOgFpEL6eXc18J4`y`+!lEX%UglLw{Ky5sG=KzmnmPvq53LD~pk#tTq%V-fTS)958Oce6E zoiAz;<7I5HEk`OAw2u9y7BPqgYfQh*qtOIL(j|8KY-;cX%K!HH{*$c#*7@+iP1Qae zKvz2y${#tO_&qB;sAGazueEmi3dGygQ9G9d363mFfdD6#$v~GEdpChrOv2&SURzKe zaQ>Js5Yb1of&=$@1Fb6mU;an6a=Z3AIDI!lzOQ%y*Dwa9Ydw2?wnuu&7FE^l(icNzy94Z34DCy>{`v~p4;k1UVIMZMAA{AK>__*(PM2!2W?UI3*hdWQ z)v*4281}l`B)j}@k2CC}hW3wOjUjvXcF8W^Si1oGn4!HCR<{w@U)6cPw0v>b1rg)A z5!%Z}gYawiyroe7eaHIWJ5YDJsWQ^mB68ZfuMPmU9V|^cTZD5r%S2#VD+793@KiE) z@mj!Sl%)&cHpVgv@S5b^Hx9^{<{X-U)ftwqvn?Wgk!1nkzsxcnST$> z*mcH$a!W|OnF>gh|3>G^ESEw{Ro$ z5Mt=U_hZO}x@HVwCZ!nZwHEY)V+AmOxE}#W=_^NcHS|#_AOQhWQh-zh{ATbzaufVb z`?Wh2)l0q_pM;QUDMY?}kC<2BuRlOBhuySq5SP1XDgtJtfP>e5HE{#x+iydc(7l`B z7TYSeu%K-s;#!4A0=Kqer@yw9@HJ{7k-T63ORj1El zeyhkTdSLv%fv z`e{Av%aUE{cF5iY-c!GXKl?uRU;EvwM)+6svgfVf2lK`MM8I|TTvoo zjMHhb@Bb}A9(oWrjcBXMYup0=nqC>OKL+cbr(hpBB-!QLYOS!Ze=qwV!~4k7@DI`t zz|d}k8ydXI?eK5t`K5a_aL2uXe(~Ur_0N9In-4dct=dGoO&bkG?LhSTHX-PMXQHsN zk}UsMsD5(?SNGap+lk^grQ-LhbVrkR6n^kMR5?QG%2aFlPS-TTwxlrWnMHc3>f`VS ze1MpnwLd*#boGZ^))2t=S^%jO_&@&RTZ)wbfqzB*?GgFtxBr=Z&AieH-2Dp`S;PyV(}(Muzr>VLknSV2`*FcDfZS+v)d2 zRFYk{g?!-N`6VLa@6&cO-4PTgZi;PsWngE24DN-ms7l(O;8I<^-D?ECuXWmlS zVNn&+gC~^xAv6c=C@R=@7}_&n{qAenPsl$=N>4kGeH-jM4eegAif>^5fS#M7tt;og z9rj&@_J?3S_-EMT>Gw;?4j1RX17f$Ho%h0LVE*S{5m5fXwY^ZT(N4td(Tl;cA^>jJ ze<5Pm1DJ5o`ra!Qu?zOShV}y{Zz+C(MO7vLzNowcyJ5dh-+t|89`#|>%>BB*RQVVl z!+7cRapMo2k=+Ou`iVtx@2+p~k_~!9gZ@U-Jz8t}(9OF&C?kft2tL{#iZ!^=6hoUC zs?i4t>_QHO>R3+U`WDa)$MR?w&Z6!L+$ZzYp9zi$gt;YrBKF1H~9wZHC zsv{5n+qHPu9AgI-ReX#zd4O4a#}OIE>Qx#~fb_ungeC0P?UUvW@>bdh`#$|j!~X`n zCRXrUZiAn0)5=G<8{yxtU*G3G9D{Y(nrfqzY9k*;Z-V_sJv(=t8*JWETz4J;5jyWk zf|c9x0K`ptF}P>?L%1KbMZ{g(r6aX`7x-q_4;b2?gY`SIcik-64@tS-0{hK|_KUEF zIKl38H|)CI@*wQD=-RbocOJ}7UqmmWUmAM?S<}Zlx?QYe!Pp%T(=J?NS*8P5;@WBM zcp<)BeeA~1ugYWNttj=NRH}t;Upk30f8~RUe!J&dUy`3c zxf@Zp>eUsS@)Nkf4n$4&xm^8jVM1QJm?>%(f+nv1=w)rYaL}NW3wgU8LRPm)SxM#N znCO`Mmg1**D18?_*-gzP`|pMScHN@s=ab)p^_hIwzoVbaq~TdUB;E)69lCaHtG);G z>9The%6p9H7PFCZyY@lwewR++#ZEEf!>P``ouUT| z3{HNXVh#(WqkpF;2!hv0-3j!CcG4qjCSjeT84EVp0+1HYvJ;rqu#AlC6ykJD>_9{Eeckt2Y{Fcu0sbf*@iHo zj%@G5kEgJ780Zwmo5%rgq5yo)cF~K1%Yn*T$nENW!1i*N*uFy@2xN74(M+%jxHN!N zT;~D&2jNwy^ME0(bQNlgo5g6b&SpUZz%W1nSK>brrC&yiDFWpzSOhR)HxS zPIWzhSzNT`)Dj@hfm0g+Qzw=h!1n?&!)0M0$b)4IkP`rDq#gx|!Z>vS2-83+)fK>6 zB=S|N!=o;X+&C^`3`jWFEp}{IcLDzAyQ#hnK))TNQte>hEg~H_wGyy%>ZaDo0P0;J z@oHpX!Ie`3+`2^%78urm8jo)3Ff%}qC+AQAEPAnY_wE+Gek^VMyTxb*>LAp1ncbo> zyPH~P6>!gGLm{wIgUp25t+rcSYUmcX2=xq**~EqkV6wfNn#838IrMbX<+n+ocmQ6U z%>w14T%8eM!?TCZEquIsME%7c3Y!JyeS4_BE`B{C&7V`dfVO}hs*Yt~k7&mNxdsdc zv2+RU5gS;LstM^4b)hWhfR>0JYCcmqzmGOcHn1MmN7s_WqWkd9YZ!z&FsD!0<@bpjgt{20Due-d`vKQlgelb}Kt^*P zedA*Q7;1-9s%<*@#0D0ud}wDM-n+nI&X@Z{L3bZjXB0>qU~d;-Kg@=7z;BYnYJsd7 zuGui)KhLrSs95Rax6c48tDKqhTAvs&>!;FIflc#%D#FvEUnHLEr*&%q3amJF4Cu6G zX>QXm#<9TbalT(T*m4djK!hF3Y9QC1bdZJX9a9LEg^Ugd7)UUCe22w0|=jaD4&a#XGLb03ETJ2Pr`BP@f18yHn(7r;kgz!lh4)pkt8-^EaMJOMlV>{;Ky;?sL0(+$~4n>q+3= zz#jUW{Kb%VbjG$^?ca8r;F$c6TXE2&`K5eK^L`vO@01RjdyS2Brv)B3Xr`CHgD+V8 z;M#eLJT2IVx#nHE2U|MP>Vx~;ONcmezjUT0+xNr%14FwdtN~T9_x@0_%SZkjVZYnZ zUJL6-)v$ket7Mn2Q{4pnAzi!n$bS*$#|Clae`3e_=dVf)*c9E5pA+Ivt^vy9xr@#N zC5bFofy`7$rFsCU&EeE_AnFqOuo7RR(flr4AvdwST~trqhg#7r{7EFy)cpWzb&pgl z8{LL2L!F)<#frE}CxA3blFv!+LDapv>q1xDzJR-iB1Y(xMRzKBFYNb8c5NB^@YREL zc<-3PnjdidSo))4qq$!swDr>n&X=dIR@_>9iP`At&zRhgJnxsnHX50K8%3Fx^>pJzcH|`#(ziCX4|k5D%&Y}e)KhtFK1;hLZIz!O>W5O4v{7&- z&VF3u+;ML6py0vlxh}`7VVqi)u?Y7W0v7qf*fMMGKv0bJ1 zw;K>8&I2@vOatpl1LB~OIxcxY*r#!7E>P78DbRPfHl@K-d8hN$*Uu)!42a1%{fNfasnm(!03q`-zq;R`4$$X%e?a*BSn-*dT5)6a_7ZAZTtJq;BDKilsj9W*!ITG zDAl%;JJ)w_vrz7|x^Y{Qa%b7jZH~&Fod?Nb@#r>N<<5b->90LEkRE3bj-y%U=J8^haO5P2i12r0Y?1-{zCd`~0bwXz$y+bnMi3Z=9xo zUpe;8XLR?5p3r^%)~(yGgXh@sq#ezCCID`gdP^ zbowOB_$cp${mnA^GWGv8_Qez57@s+&4WjRh^og%uq^HKeR~{8d<-UCE3uDS3>;JPS z?<}DDf6ps@r-au3vAo?c)5!Pnu~V-ZfBUuZA5I>B{TtlnqImn$KfZ~+7^7{!YpW;U z`J(##va;!Vx_*u_c&(rMr>o*G^CGqV4zXm&Z|y*FQVb$yD{EHjlL77jTJSngA*SyX* zep2xP#p|u}?|75*T2r0>`KQ0#P3O=bpFZ`?u@k4h{==!0-#zukM<I%!+t z#HmkpLhwg?Xxsb#?twqyNUgIDUqAbok57Jk90AW5e{u{x@x>D#ojG>qJIV_&=xa9S z6FEQD#u&8qo7`H*xw+o{%=pyHr%!3uGye8aKMfBV>vuI7AyI~m661j5fv(XjPf zNPH>g|A&0;L#i3wzkcQzDGQ9^^r`;*s6ut%+U=xFq{sXRM)-X@)<2>>o<5CT10rT< zKwQ5|&Hq=;#HuD@RVNetD zad6XQg0?id--kQhz zv1v9)#}^;-L2?w^+}Q^1I5_Wk{wP-dqsNWlr`phvLd|d-{u~4U zoA730q2Z_cM&$@~pfflu9Q)e%>0=*%X8cq9Un*Aa1;)rG*C3l6FC0}Ic@f!QbRCU< z@-u04HrSiBZHMe=UN5)NC&(_(AUh9aK;y@F_&TfqVBKjw)KPP~n=r zL(6oKmxft+82-a2+R=vAg_=bEzR_pMuD~F>Bd;7)%)W-gZ<7m`8~g;^g$C}IUO%cx zeh2QmwC){Ozisz9+(icNeMgTf>fYB@b^F50pG)n8(f%3ZFO5$f$5Hl#JW5MdmD}kH zWLIpE-Qf?8Dt_@t6n>A~`)DVT?|q&|M2T($@80jgKT0u&Xd#U(|wujNbsv~lf(DtUI3+)1bLdF$38EbdQ`;3n& z{`3hd@?;#&jL1*J0r~#|f2FP;Gw>KVD`^n^4Y{dv9+BMN!CfV}X~exQ4m&OHiK_Jb zRnc+i&Z_)4^ec20RaYFg0BZS9sx-}1Of2f8oyB3e04YUP8fr{ZvXR`pWc zLDidBK8VAAj3K_j@@*U*T~L`qSz~d+;)x{$OFWizEX8E6qJNs`pI-WB1nDzaR_H&< z_=Bol@ensd+y!y%F!{TaM-^XUQ61fN?c9yFCH0%Xp^gvho{R0pxo!a5>lER1hm-cT z5wiX7us>pGzx4|ohfl#?CBN1|9t!>e`;QFmX|S4o1^cr`$eqRk+5S)1A2qbQ!}{16 z*qi7b6J)2^CDr|3usW?2k)!`I#LAc*1Pwf`BKD5I|D}I?hp*>=5u%!~Bb3{gW%~ z-_c!svj1GN+r$1Jl3lm0T;N{zG#IMd;)ldyqPDF>n$$3W>b4uSJ%~mgc`$ZF&QC}= z>pg+U~K=s{_kBVSx{LC6uk%Fu17?H7+K{(uETkTm5Y3$3dg{6CZY+Kvso z8LV&k!2S^Z(g?Ms+%)d6|J>026|8eXus``A?6d;%!O{cvUl`hN4?e0$34{H}4J(L-p|Ky;c+?db>qZ@%X*f%muZ z@PBuQR2})47y$o&e$Rgb-o!-s_wRt8)>rQPLGb_9u=;hd-kA#fiu5aKbU>0jQ7|IZ zdJ)`0Pr?608e)9tx7KMDT7w_@ARUD9{T3T58Y;_@9yd@^v!=++BQBQCf-s@85jJ`Ba^i%lTIpzn_#83{%K)_470ovKy49rKjzpMT6?^YJU9Zi)@7K-e4%xb+IGEV4nfbOqndgT!mI zfH;$3y1dv2)R{x##VkPV*ydHyPq1?{#|v?-IxsM z?y{}O`c)}wxySJK??l#b>_>%e`yoPT*pyFVI}!4lUTt{#a0cefZ=pVTC&QP2LwQOp z7vhG6Zz6Aoa-hCrnC|~A0ps1g8Ekuog>Bz3-I$*<*yZsg37|NDqZnsyl$rXiin^a#Vb#p?ydGDDnO=`6=1@yzhR+m|Z z0dYON1zG{m0i+6bHjqBZ63-!ho?vEzto^0zuR>*a$F+9*8IoyiUqD6Qkt!-T_C6d9 zx>3tV|ALz0x3g#pOP|8Y5fM49-R3(V9@Qq|UzO)B>CrxU4nZ%nJ1XTWPY}6*-$ItX z-=W2x+%1L3N3p#S?@A%koDbF5_1AY52Tf3oJDrSaPo_`z)`)O58Rao#w+Scx+kaFK zJ%BQ3BVrwSWv@pW?@49Ik1b<5^c4zFo1s!ov>tTilP97-K*0M_z}30jzhHgZ9QMPv z!%n?Q?j(1^{(;^)&{XjYcv~&u|8BRo{i#awf$ScH{L!$46}Y#~BBEG+pz)Bj2KU1L zKYDg<*89)Bt2lv0wQ-X)8<%gSUrM!q0CxF)q_#Ox89!L($o}1J*AA03no#%r zAtH_&7I44KyNbJ<5MfHclR^&?%4d8J!fvc*=jM42=13O=Jb9}W@Ea-rhY;|IUI02> zBdo7@!2T7@U3JIphhhKJ&~69oa~ENErLRL!b>x}WBd~vFX#WP*k9=Uyz8iLGO?jsK zBiK(E+Rb6z=L>u9znMimiirPX81Z|!>--RrP7^t*{t;K33K$A)3Rrl*h4!me?&G5(dVzP%R-kg3rRmD3 z$iO0SgAy=ZW0?cEZj91cmj@)8kI@XG7g#?xM&4knG5pxn7)=}2fG8grjMUXYgD)g@ zS-&yi?9VbEhzn%d4474oi5*7jWMHacjK1&S(l{oPI#|{Kv%_O_YzrP4!!I{-nEA|@ zD8qv4SkI1$rsXj@4z2=gn{0^M8WYV{<7BY39v3-w<5Y)9z##xqsMCRzP)K}<2e1tv zr?tog(o@Fi8$qprOZqtFTLnyJjnmUo?%CtQuZ%Nm1Y#OFwHC;2;?jD7#WtiW)q(Bf z;(Qm&VxX@F2Bq4vcU)}rvDddBr44f(x`DniP7N3z$JaVo<^dk-Tv`@jvV}@2)oFmG z?Sv3Qodmc#PEfuXfVnHAP?rIlZk+1lJ|SALVBMC0Ab&O#0>QzYS_HI(AQgxCow@k2 zUV4Vdf9Jj70u1-pXbzz}YCp<5U9SH6Zco{m*sSEH#tZ5m+^6{L>O~J6V&n)+a1OY9 ze3SIwiKKCnj8;Wm6uTqKu9{STM?a?7lYGVEarEOaq<*}2S?c?kqc5Wq7ry=;hLXFl z4L>306TPS+j>KZwFqD|ok)(}zZobc~eV2-ughKaMJhduLC7dj_|7zj@}w z8QkSOedar)VmQ9@jrSD4!lH6Y#F>P4%BFqJ+8iauQ_lEk2T1InauL@f(OG_%)E@kY z9zTEC_|Gq%IAu%$iVt5vrtf2++YX^${q4=&NHp-!rFz{GEl^^DUWu63bRmxy-$n&r zx(Rj~qU3XZOW03KO(LH=BH-^ZTfc(Q0M#Q)J5ts$!!s~t< ze$Sh)^)$H<7liyt3Xz)?F(vT-(HJq0-heqWt+jkn$`b*9mIAaL1@?c!n)(UsUp*jA zspa!uFWCQLXm5nI_;c95Q^8Ir<8qsN!~UIQmrqZS{|L;_e1(7ywn_QR^F2QV{8b9j zyX5#s*exmK7?nY5AfL~KA>?m*Rl;1r1J;2vu>V5aM%4A_W)5w+a0L8a3XnDgB39w{ z{}vHVH%L=(vXEZ`|34(Zyb=hggZa1rKtRY{2%s@So;<`L;Gc#ydJop^Ca~XpNXlQn zN)rqFzrI(W!|;A&0spJ?I2hGm9)jZGH_`X=!Pf@v`@9hmK@qxF$`TP_s#hUQPJRw+ z1KDrZ`KlldH?+=4h%hsZSb%#i2oc}X_j_o5BJa(~u$${w2_-xl{GQ@eDEuyxUA_*K z3cH1V?y7(XK1_hO6pK6Tx)grR&Nz>;p36w z_Y@1Ium?zQucWn*N8rndu+v`)g#5VdJ;hczLeh6)-b(%_r4oAJw>K=|flKcx+ACpS zp;8A8<@z84q}+vMc~n(pc62n;9&_GN*-AKFeSr zwTNW{kXg#I7jUcOI-~&htt|5Zr!JPMz(5bn4Irm)nkMa?z}f(>MZn;+$Q@$Y4O|%E z)l2{)$2qkP2$|&4N`Z_e7?kQE;ELIdxY0;$Z9XIXJ!a@j9e4_IYi(4swH2uueehwM zz`#HoO`!B1QSC;?zbYT;>0Ii&@|ZJ>o^nCzDSMNoJr-YxI*x8KjYXAB(;148uRA`7 zGkq7`zC|xjKJ(K1ijQ7~{mENkr@d7kp&o+WRnN|S*AeEM{(t~W`o#>|IpnL94qdtVXv2K=ucmgXk%-u*cI z-jZK$1Z;qP@=b(H96$)Z7JGFHcLX69^=gC}-|cU`ulV$B*x%eK+2t|+XR!Mi+MQti z?j6{N;ilDviQDt%5Wf2LNA4c5K6;dLmpTz`V|gaX@ z&|kvtZ)iUPtJf*mEqB3AC;9SR>{qY{NcOW*kKjHO3HxKG5%Lnve5eoYlh)xGgaqo9 zfZ5(Fuzvm}?A8Y)yF6Sz3ww~E-3r!dvVTFpH$W{U&nABld$6Is7FOf0VBaO(f~5M~ zE7j+}5D{V+@lUw-{|OOq-hqh#wWoa1>c3$RmF&_AqyCS=8%X}_eFhVU=iv|2&0ag) zXTkjC-!a_NgD~(_#t{T#Ba!lKHS zE?ZOE$oDZ`LQsTmRp_q7INVQLBBJtGB2HX#)tKc(;v{85r$=hz$q^KM53n9A1|$wR}*dkBb@0<4TxS2T+su$iUfuwMxz797d_OJ~IuERd#Uv!bABmM+571J*O}3UvUm zzBo&lsPGb)+=u-Y8WUV%@JFt+m`Ic){TCk-{#W~ts*5IRbR|!&_a~@#qEzpzzi^-( z7f|WWa;$5z&g)s^Fl&S*>9r0gA^--{e~!67t1`2KwEE z?;nr;L4wY@h2Rl#=beBX==Tu(IUU^a7sUTn0>Au4Hb^%fK4i?-J~sHTG58gSE}4@z zY+2;8%ZJ2~#Y0l%*6xiapvG~Z)TydzK;K#><|B9fo^9UaL89jP1 z0?-2le_=eho1hyl1aAx1-MkT?A0+tC#)I!8=<+WS+%B<+sF)F({D9y;*THrEllc1y zyYzO#hux%8V@N|M;RkehLthk!Y6zbFRW?b{@5Yo~j8;8tbz)pA`r%5#Z(77Qr$gz7 zgUR}TZ14-cfiZ7v&`IEkd)Od*w%))nL_cDK7biCO3W3{V*q}kqdK2So*p_G5;H2Kb z9~kry^uOW+zwELT<0}v%*8f59mvnH$GLoP_5cstvY_RAiHV{oVrtMiacv)}osLol% zsNWHMZL;+7*+3D|h;#?o>Xk86F_!oiq5nr(Or5F65(ZT#2tGI-{JNC9Dd+efJ8u~m zdi;XmuZ{=*Olsbg0{+MF^l{i^B`f(X@qaMn((4*MlSXe;a?w*K7*k9 z1pJ&4*cf{7GQm%c0ayHamcX}Vvq6V`zD{s__6pm)p|>%nf%#!F;iu=YO_wYw2K(68 z+2EIB#~|q^VQ+F0KKFWkA;uWP8-)MW7@`QCK+vBGc-&1oxRK2KlHfz*!A)g(Q!Xnf zc;6)i7b!C8pT8pbn-i#+Mc8XA2;V4M=caIrjDU#Ly-E0Ak3p|kG@rm%R!V~)78xe* z*KG5fF>M$TEg!? z)_t77tJdaCQKyVW6INay{^o4R?$c)_s{Qcovmxt;)!W1GoDKP}->SE7^0M)F>Mi$i zOAI*Xg$BK%9c&kVO9I}}2^f>jKE&={=YRAYYa@ot{E1C|JBDV4SRWCz_*+8p7IjyW ziCv$0=>3rA9)4feZr|nQg-7^y%00up{NY>g%gHAjHoPD5=GON^rr{pegC3fqz3!3i zO%edg>zChtKjduF`!eXw8{ZFkgV3J}xo3($7{~Ez7$b=3H8kKX$@`s7o^b{m)&5Zu z_nU8XJZ-WsT~y2nlBG6uCU`tjLAvbmyeZyI1UFw!a0w8M5XVIDcXe>hs%Ufe144iA z335QvTah1mJ<;=iNL#NO_moSIXy3_SoD5oBFiiR-#Qb|5vtb;RzVs69yeCOa;|;p| z6DX0NX>9U`F~rEhf3buO*-3bdIAa;BAU4ttNe2&77dL^Q|1lfX2c5lTjNsF3 zb9PJ@ibelT_@DhmxSvr}_QZQ%l>s>Ja}@E$Rz5b)Wv?vuFXI{~h8Z=-Fk*aP$7ryf z5`h;Da$F5WIq*t4fpK?BK(7d zjrs-QXGG}e4X#{E_|XaQ$%H-rvcSuRcoBGm{_6<;kq&QgksX{RaQ16#a7;Ro21dba zCL8>DLI?K|w&``k59!<3q=}&;H?YZH#x_xGKfne%-e9AXvZx?-*{DiIu*JEtEtm=X z2Vwu?SA_RoufrQw1V8!(g3Fv4Ro^cX+$4G#4E_p1SHDH@{ei&8V9*>k z;BrBrfo2pM2!8!A#ZvA*kmtpPgpLu!W#0McEQ|?huvZ9f& zi2oITYV=}eFPG@O=%Ol{2^X`6>Az<$*ESW48I;%XYafKPo>HA0=jGqtP;a@~rv1!P zv~g;h!O{6d4juWJ(0DFU`}iLljE(n(t-Xy+rUs8%c7^_spjZEa;4_0tk74Xql;KNk z@u~4GZYOlc2wQA3>Kz76Uncmp@!+)tZ5QzLPwD=5mX3WP!7mL4*Jj~a0_TUUo}%uV zy&xtIUZr9JFCusCcb`1D|Kjd8xv)l=?xo{TfeKC}Yn|)oZB@jY;4~>8uEt&qaoMw=?QHI zM??BMN2TueJ6^tjSV8pjvc5-syN{P$ed^oidD+vi-u{A@V?S4K-{s}hiBZv==3k74 z{OF}onZVb0dH*#6gxs_7_0bUTuN7=JFB^ZW-gfcw#2?h#BfN}!px$o!a5Ut#k48g2 zeF=A>@!I||-&6ly=XJ2*BO&vBUb-InDCD!?rZQe%fAk|6)Hz<>dQ3rl z2oJsD^`U(q$;#L+UbY=jz(;v`s#gII@$&0F0`NII&k*3}{FcuO=jFR66~GU9=^s=8 z|IN!EPw{QYJ*{ti6w>mRg8dOMubw4d+;fhXr$6{eEXJF>JocB5WWjPTFTZa1vk?C= zF9#m|voPxyyqx~lpVd$w-|**{I7e|J4r}sanxV!&yNG|u}xO(#G4o{aWOGmsmuQrF9xT(PKy6d{+~MK z$z7KO?EWRh_R@f9%42MJSag)H*aG=`nY@!7V%NX=+|OhiS$j_+`$~H z6#FXvBo;A**DI#;4`1-kLE9Yo=AdH^dgq{T4*KU{U=9Z7U}z48=U`+GM(4m}MyMHK zW`vs&Va9wj7Miihj5sq=%*Zw)&kTndE;H)QSZBt1Gd7sfY{oV-cAC*{Mwb~!%{XSp zaWhVuamtL-W}GqOtQqIbm>P-ck(d#QnURs;98B7H7$=fXZ0&bg?b3(s6M%thl|G|h!~F52e8Hy0gq(K{D?bJ0H+19LGr z7ejL~JQpK#F*+Bfc?g|{uz3idhlqJF&x2(iqUXUn54L$opNHIeu+M{Y9;)ZTGY<{( z&^QlG^WdF_wt4W)L&rSy&O_fk^v}b>5CD*7!iwMUJT1(L@$PQG13L1O42EJb9D|V!QA@t7Hp+3}bkkA?AA6py%gq{JgT9(nO_ z#KRSj`gkXG4b#y*BjO&~L+l4TCle*)VLw zhz+AQn352hgs>!pCm|vU<|J5>5S;{T5^PCGPeN`I>`8DYp*jhkBs3(UF$qmc@Ft-x z3BDwBB%wD6eM#s~!ax!RlQ5Ko;UtVCVKfP*B?w)Duq6mzf`}zBFM(wVqL;wB1hyqe zUxM5vurGmg396UCvjh!G(6|ImOW<9Cwk7Z_LB|sGE$1NkeoRtZA^NAw3PbX|SiknTF~# zc+$|2hQ>5BrNNtqwlw(C(2<7TH1wsRKMezE7)--Z8ivy_l7`VVn3f`RDZ-W_d?_N9 z!n_ogrHEb%>r&X3B6}(9OW|CK>ZR~3MZ;1wE=AK)c$cDWDSS)Ou@t>a(YF-+OEIt% zgG({A6vIm~vJ|6BVM<46I>OQso{orgnA2fNM|3)@>9D0EJsr8}u&2YBj_P!H($SEP z#&k5L!<&w_bokQIk&fPU^rfRe9RukYOvg|Uj?r|OG7y@9undG}AR+_i3|KM{ zodIhGY#B(;KyC)?8E|HxIs={zG-RML15Fw5W}qztz6^9^pf>}38R*ZzKn4agFqDDe z42)!8Gy|qggk~Zv6XBVN$b>l)mP|xv!kP(NCekyJn+ba+oSCT3geMaXnP|*JQzpEb zXv>5z6CIi8%|u@&`ZF<*iNQ<^WnwrJBbgY@geeQ5SqRHQcorhEV9tUi3(;AyX2F() z^ep6N!JY+Y7OJ!0$wEUG8ne)p1#cGGvf#@?M;3as(3ge&EDU5}FbhLj7|y~-7Dlsp zTq#1c5tfbcY(!+koDEAhqO)PmhAkWE*~ra?JsZw!RAY7|6z8Hioh>oQ;uejAp}>gU}p=d!?|Ucx*XG&W5#mKT#nhxF@HItm&3Xow&h4)j@;$2 zFNbqEs+YsF91Y9SxExK(;a!fl$8z*8N8fVvFUP=g3@*peattrW$a0J>hbb4K zxd_WecrGGxVa|mm7ty(}=E9bX^jzfT!k!CfE~<0k$wfmh8gtQ<3vVvka^cHGM=pAE z(U*(h6^K{?^9op2AbJI?D_~oJ^cBcm0s9I# zSD<rF1tia$346VTM3XH74=n9xtB6KCf zRw8^QB38n@5|)*SUJ2_;*j6HaC3087z7o!rs9p)rN;IrQ<4QEGgm)#{R>HRu9V^kh z5`8PtzY+s0v0)XOS7F;K>|BNRRp?rUqpNUi6^^gM$yGSD3a3}$%qpB+g>$PgH4oGC zFe48$^DsLP^YgGU4~y~;mxq)*Wal9-4~{&z@=%|Lb$M9Nj|FfQpuPa>3b4Ka8w$`| zfNcfXS%CHebQR!e0ge^mcmYlp;8X!l7vM|*MhjrFBiatD9X31C?Z~ymZimy3YCAl3 zthZx>9nE%Zvty?n?RIq8anz1ub{x0kq#dX17`9`?j!`>Ig$OM~SRuj-5m5+pAuNT6 zE`+rZwnC&ABDWCsLO2UiT?kJh8Vb=^h^9h#3(-~xUm-dQ(OZbVLi876pb&$F7%IeY zAw~)@S_o4SLW>YqgzzFn6v12sOA(@rU@byQ5weSrR|H29Tt%oa!nz`?FT#c*G#6o8 z5q1`#y$D@JI9h~bML1rBlSMdHgwsVhQ-rfcI9G(J#h6}<8O4}cjM>GQUyOytSX7L- zVx$x!yBK-Ja1_H;3{No{iqTjMZ!y}6;VVW*G5U(pUyOlb3>IUk7{kRFDaN^COm$$o z12Y_$>A-9U<~y*^fkh6)IgsK&wgY(%I2>>}u+D)EYv5f2-x_qRLGK#$twH}946MQ6 z8Vs$$@EVM)fxQII5>%JKQ-X#PG?t*L1l|&~mB3emjuP~ipsxh|B^W5dU z5{#C>REp42gq0$^6cME`m%>tt=u%iqVJk&?DRN6;FNL!d)ur&1qM;OxrD!U}wo>dY zMMo)mOVL+~{!$E-Vz3lLr5G;7NGV23VR9nWi7+R^orrM4?1aUMXeX>r*qlgrBG(DK z6HX_po$xr(;6$SnO-^{7Xmi5nM28c-PV_m^@5F!;gH8-NG3>;M6QfR;$`D$Hurh>~ zA)*ZCGFZwGT?T6zY-LCA3 zHn~%x+lRh<3y3hRuz1H*(#uyWw=B+6|8z4Q@2L(d35LjW#!YZgjZO>qeg& z{ca4nG3dsS8^dmlxH0O6sS2T02&+PP6(Xu&u7afs(N(Zk!B&OzD&$tdUIk|rs;l6s zLPHfAtI$*hZx!0A;HyGM6?&`CSB3s63{+vT3PV*GuEIzaMyp_|Mrbv{su5m|h-#Rt zVW~!RHLTUJRU^F`r>ikijnQhDY7kn3uo{HdAfg858dz!&T?1cW|@`$C_}w z2`8IystKo?aHa`on{ciPQ#Zn~5!D;v*@%XXXxzv<#NgeCwvF&@MAt?f-H2lwaeN~N zH)3cbhBsnlBStsEvFp>?W8tBXl!nY{tyZn7tYEH)G*u zEZU5?%}Cjd?9IsA498};Hp8CKqYjG4`t-HiFoSlA3}Gi=RBZ$@r2?9FgCqq-TMW;8UTu^COx z@HV5Z8NOz8G^4i}ea+}^#y~R$n=#ak;bx39W3(Bj7KFAStOemMh-iVi1(p^>w;-+s zDJ{ruL0$_SEpWAMdBe1?#t9!xl7e!L}{fxdrW8(6t3e zx8T?o9N&V$Eg0H@;Vl^1g3&E7ZAIu-gl$FmRzz%tc`Gbi5xo`Gt*~uH`c~v_g?%fW zTT#Ci>$ak4E4*9LwiUju=-P^-TXAeFj&H@utvIz6r?=wFR-E06b6YXhi!d+3y@>F_ z?1jaPXfLc@*t|&hBG(JM7fvs#y;$$X1~0r`w0Yt4qQi@RFHU-K%8S!poblqU7w5c~ z+KTC|n9+)vt(e`4`K?&kibbu6Yejl1@>=0&g{u|ytytHJ##S`7!rO|rR`^=c(Td(y z^tGbD6$7nkegfN`z|JSo{sg+7K>Bv%ZHHq!PHxAk?Kr(1XSSnl2Yfrwu>-w3(6VgF7&^1H(HovIC<#VEQgXzYE7MxOTy_3+s1b!!9)M!nR%5xeM*P(7OwLyU@Q2 z1G{i)7f$bjeK(xDQN0_U-Dud2#@%Sz4exHW?S^kRI(DOXH~My?e>VnpV{kWyc4K%q zMs{O#H%xmFx(8u<5WWWydtlxJ%N|7Ufprg3_8@x?^7g>72d+Kv>_NjGH10vu9(ebl zZ4Z2V(6I--d(gKB{d+L52ZMVsvM z9BoH`I|kZusvW1>ai$$-+i|WPQx9PJ0n9jnnFnA#0LuYHAAt1$YzL5j0J#TXKLFmUrXg`SFgXlYm{(~@gz|w)}4p=*2>p*%3@;cz? zfU5)b9az_a#tt-fz}tbg4){9I(ShC$^mU-W0|Olx?7&b5hC49Qfzb|_IuY85uug<` zBBB%KPFOk--3eUIqn+sQ#6Txbb>eg< z&UE5zC(dLE-&gc*l0^AKhq!u&&6cnFIQA?^@T4k7yx@(#gq2%bam9YV(;^d3Ur zA@m=@z#$AC!q6cMAHv8Xj2?pNFhUO_>@dO)BjPa3hhaI4=)$=d`g{CfeyU^AJUl%&M(A$N+F7$U{ zpbLXtINgOaT{zo?b6uF)jp^N((T$ninB9%}-B{R-Mcs(&MoKrbyOGxoM>kyEsPD$Q zZmjRdhHf->V_P?NcB8!;UEMg^jbq(7-i?#pIMt2Q-8j>Yv)wq?jj27D-h&xEnAwBb zJ(%Bvg*{l*gSZ}~^dP$jc|CCSz}18L9<1xZ`W|fPL30n*xTgR?z2*Mq4?5OxINM-XuY<|D8iLG%$=kHB^W=|_-z1ok6v9zpdHtUH3nBWOAT z?-8^ef$s>qj^OAK96N&JM{x3pJmqiu&YVYOjcwrqZTp*iciF2Y!&9~I7JP}a*S@lnA6L(bdSInxestcd;z;wHyYE=F z;FG?XKWC+ecm9IBxn}d-(rE6u@630b@76YgnC4|TfBL=7#}iavci$1{?EyhEKhHpx zXLNsI7<1&!+-H*Ec=B+?x`>OwX>3Q4v+^^(cz?8P>R9dXbFLb3;GAtbV=Q#E*RD#bBuV_zo9O`N?;gU7CLTYW~XCXTQKF zab`!U@5-bzzC@P35j!m`B+ry6Jx>klx%u)jpn?Q;y}7jI3-aHkeEB5s`ON=KEDl8>B?`Gwa73Xx3Z5tu-l#D|5Sp$?Dd0t|wNGmg6?uPW z_qIX7alfO+Ek9vf5EG@Re+CnaZj|x!e8$-qWMY&s@e;*Wj*!!5Fi{`0AelO)@avao z>`^E+Hu~8U#Kx99G)18;3ce_GkTDtd=epfz1l6hsRy`n%<-1qU{>CHoxT;pPmEYZ& z;d-g~^5lg@`&9m9?KRoNWM z(2bv6S0V}-Q(>>I<(c&b^1IL_0=DL7dw(ff9q+D5Tf=Js|7$wOH-1N~Y=)z@E&wtP=;>SXO(Gdt_L34uahAhXRl&!!1dmFdZ1Alw+#owtVu`7y zzKG9v;X_TVwY7Z8fOI^z+l^U|PZx}fot5(J6F!@VBdH9=NZLZa`}FT*0M?os_r3A; zl|?erF#}+?omg}bO+J)xyI$6n*K7wQjmDd2DZ(Jr|8I=75At+0$IIcdbn z?*;Nlv4l?Q>Cn0z;-oShCG{2dnz;MOKMuoP8DC*9sm-o*7RmE~$2aP~eo(gDP2MQ8 z&RJCM;m<)mae%GX=(93FcHyohkDU5k76{}e{IB-+Jl}nl;zF&bvS>UPL=Oe%y$9_{ zA~h5#>%DeoosuhlF5rLVK?LaC&+Yz;Fx@Jcg5+xq&`S??9`kn>Bwu5I9-H!o8^r%= zeAEO?Bq#txK3-o^)qH-I#Jc4%KEyGW#``zT`=emi1%s1V8)L64Dpi=3+p|nn??t<0 zP%r%JqgTqHQtK-!oVBGfZado)IW(*WP~oyQ8ogB_LiUQ7Qa%BQau>ivNou`+LzwIz zO?H&8KiegQf3;3M|L^i#->GB`U;^(C`&b8Dkd6HADwX#^Xk%uxlii($q+e z5$iT6)=}g(tX~0Prl>~CDEYK%v6sbAUIEUn#SPD||Z zip6`vRhuyqDJ{+C@>oP*RY+|LgB>660RysZ!4>k?$&- zPbUbGZe9EmL~y@-ZPf3DZ$G z8=u)AgxX!jq*Q-k42|xEMu@3TnXcGez*f4C(B@im1=?K8dwEGW@)D|8_kHR@Xdh~B z;eM>A3IyID@2NU-xR_`mk6hpb$K+Qb?*cK^cg9e6g+`sicq8ZrO?-TE+0Q%QZFjk}0MOcR4mYx;PF~*Ea`s zN=`j<>AzehqRvj8>hXWI?)nN$Y? z{7>x|%~sPKZ6YT#h3K#rkc26-_#f@s`+MZQPkS#qVY(t{?4M03;LpmOiYCW@KmKdN z%v&pIE%G&Gsl9c9n%&zIX4Q**y>&qeA9AP7_{VLvb`i2$7u>|Z?5Ar43XJYAW2A0sOZPllVOPI)-eB#qZ(ZoEZkyW`ed`B$TN zL1*iaMetXxQeP==c=fV3KP~rnuDW&Atq-uQvHF2kx0+WiShYaPJ2%S*9X5BdZZ9@U zoWz$+XyZ#R;#b$^y>m`(+RCnEOzm>=QHze^e8%K-$@vvQ50;D(9z}kA`L8}hU>l#V zRp(sebkyWK?yFJ{bL6*l&T2kxi!2dWl^-?!M+OWF;=dv1UQV&*ui-Nb^NZ}2mF~Ly zQu|s*zCGXXiu3E-`OdmpN5z^L)0~XbM{ce?E)}<|Qb&H3y@n>Uxq(M^2(@$~o*}qH{8Fsc^D<6hQJ`>ylkx;YXpQ zx}N9e764{1iI*JvL}@q+fp(oe`(acjJ$Xu7hRY-76u644>-K(81&JuSbkHF2hYtO)y z(aA$_)$H++z!Y4!IF3rmM^#gOYCitL&&?uv>Vd&3fu!kk1Y28WVRWqW1YO5{k}axX zT>bC~>)mW@)x|)0cg^kSEa9lwyOs*!;4WwW(!!L+KZt@N!Zh~ zi)KsXNoHjn31dg1JWltk?XSyWO&4gaoRk{#OTWAQKkt|vI57Wqo&Pdxise=IB8MRl zY|K}T>B|}AkMz9o+2#DexxKRpymJc7kzulR$|zJfKK1adx%@c6`RI)_izJ3~yuV=M zr{izSRpyLZY4}=^U-4V2q2oS#Q5~g~b+Uk$&?PeRr56)qDy?H1kL%Z8lT6UpNIVI=cZjQ3N;Q%Fa=}XJYnUfxE;TK@v_hc#-(~I*XMQM5}iD)4osZauOj#SA&#Ief*f@jAt8oHfBQiDu9p!7b%0 z#$h^z3caFSs4BDll62xuwMcT4)te3U)2Ce7cjyfHtLNGEZb!Cp{xV5lO&IZV2T+b^58 zOv1qnH=YobvoVvo_*ctcxt)!zle9MEQn^P8h*rA^o*SWn;o&V$#CP6EB(_S;!umBz zS12dbU7Dmi`+WrBOZB|{I(LlpJW0pFV-B{gK~kaj+eMu` z$XbNQ#Hq4`CU~vBFcv&MaUFV=tYR+4lwufIFks!l$Y(hmN!owC=rRdpl+W?^ry@q> ze1f9=^A3s4HDvKszyG=)Ss+6(48}$AM?=Qhc|ZM2^1N0tL4Fwz_Mc+H)egV ze-h6h+|EA6^8~1rjx{7tzVG|3Zv4h&GM^m0Xh@hwbRJq7B{d07v5MSv5-6NpYOV9I zCyw#nAxZ%=Ld}Q_4z-@_z8GDj+9 zpf)vu0*1tebMQwAiA8YjviO-_;dWOA#~@k}NZBSL7#lhePHIhc%Kg?Cm-&e!T6RM1y)Npm6-LRe!m&o;Y7V@X%+(?hVIK$WZ|E;O zaZrk7f{8CR>#zRT@YQ0?lj>bndQfM~UQvyknXEtdEOggb7Tfh_CTWz!_r72j)t5{` z1*U;M&&$bRgvDIck(a9HJ#(PP{TY6U3l!$)D905r-(BAH-)gm1BH*B(m6d*>X=uAx zX?+2fxf2&^#oFpcGtDIXQ_{eUs+?WOpd-HR{_4lX;7Ve<#$I1R4_KiBY+a^xd**U8 zRjwoFDKE_LL`Ei^F>P#DX0+}JBhuLVstPViQ$rZ9GUbzGM_x*JZtIWveL6o}STf7h zL`i6`q2Nqkgmh}1aCk3${g#$k)ya7!^pkqe8r>6jE|SZOEWu5+$iHL`h|S zE;7?uMen6YEKX)C%GgNESIg>zib6D`EK8l}&T&gDXT3P2l5(jEaB$u^SqEyCKYHfd z@e<)*7`Z>RQkF%}i6*64=~7un=`*5BL`u<=WPD=$tZ_mwNJk|JJ;hiO850*w>WQRZ>6zv2Cm*}|>sRtatdk2d zK}w}uNaSO)etI2$Ol00p{c*ZTT=6(S4HMnpb2Uj{k#(A&Al54+s;MM$?>9^-W7D8br zQ2fD-f4G((itOSHYy4VnfT~fix$aNhvZG&k{H|u?v6* z5M6QV2Z>k8UZhE9CozxToIVD zQB8ml1@!)o7Z*xUl{seRE6~4U88;&TO86_MHZofg=PRKsGb5Lii!ZE7XlqSM;;)?F zJSxjz9D1NHepcPp-1TCDbjKf)GM5{by8xg9`uL95cgUOv%V#hu<*1+e?I!P?1hT1h zep+b7s$0ZBJaL3DnpHM?@$+eO-4px5Fu%O{Du{#<4 z)EASDYH3hGTC_-so{y@;xJaXvp=!<}XO@d32(Ezo>P1Y(Ac5RiE4nD({pM$0i;;FQ! z;~xCP&h>JOd#$6U!fh}9$1{SQ;Iw44x~e4PvA-zqPI< zSI`PMO1h;-l6Qa4l`Z0KCt&qkVQryP*^;{wi=GvGY4giOux9AiNCle{(6N>D$XMvf z-i*)4k+|HW%fxPCT&I8@3EzCPxH>H`of!Km+CP480tS?Mj29<<(LhaMwveJn244=qX`x;)o zIOM^^T?}%Wm~A^Ob4j@-Kaw7vPi2jL3tE1t8+HuD%Db0ePwy2 zEO0ql0i)@WEeTqfsZ&-~H~nfUyG)M!{92-f@5zMX|60PvoI=f#Gyc+d(&zJL1v`sU zYDLvgRH38pUhYoGkGd~Pl_#U_vqZ)7MFYctx|WL#GHUXSX7SaCFZ}1t{4gdU9f!S^ zo3N<&s+F4R_m=-LUTO|1@1ZSF+XNZ-j7@$D9V`lxMRNa*^d~(QXWV<^=fCiyE`G3S z2})4n7_eweG>R{<9#ja0+vpLY>fJ*8j^V|9jlDoIArH%u^$ zZzOu-C}f|5)`=)J_YjhGlqS1OYK$p4qxz-ag(WudgD$WCz(M$RM^mwjAc$m;DAs6C z+;r8Bd-x$kRVk^N!W6DIOPH+EF}2d~sFVfuVpdPp*1B4qd3wJBtaQI6Yr&ggNdO2CuCy~mur~pZp zwj82ZY6mR8PqMGI>w6ArS?Kn!WRSN`*3A7@My-c&9Bl7j`-tZ}s_Lz?QWGfI1j)!M zzqGE7iw!aE3Vzo1lB&P@W@06V_j5_z&OEzB1E6AOXKHVY-9@$9yJe0-?M)Do3Q>k} zH;eYOlrxM47!F>>FM0kou`iShiy3~a%qPhH`qyM-VhqDi-O#pOsmN6GZ~j86LQ0$d z8vt+24*NKSm!9_XIPzqP*1hEv_?jZiD+}Z^9OCgt+q9A9@P-;cpy4eu};+PIkCms7s~2w`@LfHd;E< z*NFYHbFo<1R$ei3$MJ#H^bGtk8Rd>z4Wmqfe*$PL8KG!KfNG#ed)ls>8hd4}NUfNY zpp7>a0@?DXcS`&7?t{l{k~d8>itI^z1GgVaz(D-L)uA=KF;Zx$_0Yt+)Cd?()c7h& z#AOEVH1yjarC_?QPKbC_)cod@{l`S~=ra9jbfAsY~X30Y)Di6+c ztdycHg|&_p`ZG4e!7KlHAneXd#D8Y`3$=lzAKmmaapT=|v?2u4Fn508{?ALbFGee? zsr~AK&{xXq^vq>X7H3Psk|pNbk3-WOm4m8nGHP=aEvto%sB<|PdwbMsls}O6@9v_M z`)z9#7Zue$Y>qq7F*kp!HK7N8e9z}I*t1o09f7_%N#hGf!F#LxSSc+26*rM9%9Gk~ zqsfX!=^Z>-NiBVk??j)S;Hsp`Dg1PjGQO0~ys-FdefRLg1WG3$F)|Xh3_VHaAWm#i z!ROQ>t)U0OD=Es*Nx(O5saIe9lv1WL@hVUbGM|c4B7dVSRe8ws#^D-g*@KFGChx`{ z@&@`+5#m!{``Z7A`&AL5S_2BAKoW(&899AJ%ZcmWko`PFZX}nQ=8uW*$BV{<0Vu~R zga)F$L57iRe_q6=>_{JqeYUV$1D6d3~k?&RO(a!E&nF`V&lctKBT> z7g>KGt#1ij>*g*n_P}62fIRid@X+M*BKND`UyWT+p7DI@uY~e!6{|_)L=|vX$Sxtj zo#80_&RQRUvZ~_Kbc+^TGYwGQ`E0@Zv`0tu|;!9J@8{ zP#`uzPH*x0kGoAkvvO;FRgX$!VD(d`S<`@K>vbGKA$rDt z2B;DdA%ia!>Rp-h)bv<>NYS=HMop+ANJ-njjuX7ompZA!0(wgwyM~m*yKOY;ZE@DL z5rKNKU&C}`JV_Zu`K)iR3H1hf0;9fr5zVE485X_eiLSgSwEM_3r5L+HqPiCE7nNV` z@JO(!cyaZoEsw+zgH-4Y5%*h5{VSS$W4oxosHqj;loJjF7>W{ZC~7@@DnFYajKda! z&7>B6s)qgwvRYMAYf5T?E+`V6kO5~dRGl#YT>Ymf~<-npF{h@y3NSWqISD zGV+TKrbQqaj7Bc{Wv#4(`jtkPC)ohvpW}B!f4unvS!{?&G0zmGJS#P_ zz&0hlYVdbNM;da5t}h+AYeYiV6r-Owe&Obt%m+FfS2N{O1ZVlKch)@1JcA;ng8K8xpjLtl24Nd{safv4?Zh_S z-S~?^=fO0l8#T)tmoIuKhM1NbA;?4*7^K}nce+gO|40!l1+CBTIFd`G$^nkO*)$MG zDTWxTqMHTU1i8mfU#FHJliQZY?j2AGl8l`%H9-&0E=rO}B_^;dL)Sx1Js7MA8p#`p zP`k>#dVz3xLN!oFPP_TSP{zND-K1!Z4~v6iXn$I%nIt0(Rh`NFl2Qu)BTKIMlnDPP zAR}j^@(a1|pKmx4wRR^#FBN}m>>XoTo>BKs&ggHq5viW^`K_j2yfgf^>`paq_%)dB zHyEE-!E{VjAyqs+OqWtL*7uafI0 z^S^VIB;bG3aQ$0`10lKMfTE1Nj9j>OSa_+DL9Sr(s0)@hSr&FyuI0G^sy3pF4mqU< zT8|PKwo^!_fn0AYlj!L$?YF;Up zyYJQMYNE5O{cx{{7_wp}3d}&|9t6Fx7}y@Jag5V);O^C5UncC+Y?&rB6aZ}_7~>jQ zicwU_l*qk7)%BF%HBwNM;74hwMHvo#T_c-2gKp!jYU?$si2UHa)k~zXNU78fWsvY{ zZp&{)I8JRfO=0F9oI-Zw(sVyl+y5$7O7BdJR+OeB3PY{1K(oCvD0Q#p8|(i2p^NXF zy9g`(dJ6RNKv4G@Q9GM~t@6q)g;Lg$wLX3b2AZ1&V!=NeDcX0YU*VIqbfysIgQVSWk5W}^BkD4$0 z>KcBKSVdoh6_;QX0*p$NUy#QJ5kcf!WXCb4RT=en^xcuXlbupqE<#qdnz4Qdg1QQvo;su49cUw%MQiA$N?apw53c$E48`i5q)^U(jxEO-OQn^RuKCM5f&6 z*Zq|&9%~ei?@33_8se21v5UUJkK;E>sJ8l+1o{YK)M5#vKor;)EeWM&Y(*mU8jBd< zaW`a9V40loOc~iFH+D0mzI9IXA9#cVkcgn6-^h;gcP2w}0LNJ>$!+i|Hyr01fM1bavW zlR`#{%61+4iPX9;4w=lI5ihV2Uy7o~q7tsXiytQ4FBxEtMhD|}W4olTo%&f}I*)F@ zeBP-N?pe7;t(A)^v2l9p*cNA?QK!F@z6iH+{Oz}vXcW;ByujJ8FaBi zqqcnH*Ym9$)@Zq1yL?4m*pzNTzL$m?ry_gz$2~L&alwSq#?-QSMPq2B?*SX76RMRR z#GmW_Sto8%A`A+I8pDq(^YUJ|FXu}n#(&2g?OX8}t{LW-(qGG_yqP1Yq|Omh0N;A~ zFHhY|Bq|ON(@P|JSWfa-oM5)=-@7PlyV~*vF&YVpvXKNt_<9`8ORI8Yn#_008Pcp6Z7lGNzhi0Q{Yl#Nkxr2lk!QWLw=VsEHdR^<_+8~k zwff#)fwz#w6jgztQp=zM{V0Yi*RtaAO`nrupq0s|b__msXY9RJ7oeO`2U^=oR*GP3o-|H*JtLsmVH#eRrCN*H>Fr zQmr5FuO;&SE++;MTvcB1w(9{|RX!hb8G(@vh$tzI-rEzBzLt%wA`cf_3kdk?iMOyR zcf*-dQZz1o<%+b#EG(aDH@+(vqvA!ju&%ao`N{&}J@=ckG!ewlpg48hilJoCWA0JX z#Egbi?GJavB@jt5mhAj{U{xVW^vD7Hz+E6;E83|#UF6Zxs zXMING{DR2TNGIS@-%g^+BsXA9^*Svn=frN`)M=B6jZwQel-$ZwC#bbS_nT0mjYvq_ z^gTGRQUc~IdOY1kv)HV*2dJaCG)q9MJx1nK78m3s1HNZc|ERmfNj(wCGn^60;7E9H z&t0X*!avUsR{dxOc0XP!1y-A|&ab}QDX}a?GmyE6?NS_6Yb^npQ)ZN@XPYzn03?j&T zD!EapDyhTzeV2hg1E5eZo_O%OQeyi4-X($4R z)R5i!9u;H!PCjX&m{0d`Fjw&>VdH$LhLo=S{;Nj+D57rV-BBe%oZ*4=i4{gm2BbYP zq(X#6RnisXB8PtxbJJvvjRiE_$Cdbx+M@-2?~eAD#R41tL=z@PTH5X@LmvXxhO?si zbMZoTJe_3hEws9fkw*-VQh$8i^U9d&J1_j(9<(q>X&QYd&7pCRfxm{Wis5Qwg|-=p zddY=WEfUd}T?^fAE~^;i@aM6KD=AUo(TzW>e`+pAI6hF2%A{V|Fb+!;+a~!wq4mZg zuYdCixhqL@j@=rEwW-|MVC$HZSV^YVm8ES*NmNRP~b-$;=jmN%MyH9pr=!?%v{`9U%xqkN-OA1DD)^HS*Etzqi zwX5B;sI4f)t;LCy`E4?5nN%Oy)+9aN>t^q~dE4?+Mbpc8#5 z?{{~Jwa^+%|3Di)|V9}1LU-Ww*^?`{^(Pk#-b~G-1N)?Rq zFlP0W%pG3~H3}aZPkGb9bDll7f0m!931fuZP&ajFLe4G4j|2hvg*E_0w!ARw8?PND zrodUh7r~(O2P^(WIXIcWP_SY|D#2;9$~(kcYA3ns%l~8Y@Y|v+{~}HN2fC_o_L}c6 zx-^4>p%K+ZQf*@}tcBm^E5(z&1={McGF?=S2oeXjIv^sR9TFCr&;uV55@8Z4Hz*Rhe!*iO0DX(!2Q(}gl} zns3&|w6XfB4Dk`?2iCUB@Ju0D_pue{i(3+gkn0cLGJJ*T)CC&TEb0_$mqbSeLm7#q z0yk4jvM%RrurObfrk;c4_hKvp-Es0u7Kykn(22?&f+!T<`ZxEg)a$vM-h4gg!kq^C z;PZ8q?c{ zt4M*)6mcaAIO%sY^hXo2kRW$~7w1nZEJ_&f-||ec*r`b|2aL4gr!@I`Vd3wmnAs>x zB1b+PtVWIn@dKZyJHo3H#P)l)Un&{xMK_*YRy9gaY>#dd?VXf-QJ;zAsUyDB*x$(t zTX{1ZCtu_Ul)Y&Ap!a&wtx0>FzzgF%F&R5u$u*DmghjA%iX2(MdEsLm^rycf=aG#!jNo(+MwI`RIm*UBX`zibJheeCy zD(k5GRc@(U7*#1l3!;KpetvJj5aq$}nXOCyE@b0vgr-1hcAr|DaD})xV|KaBa$brj zjFs(_;yzRF`iaDV!NZj@8*MMo+F>F##@5LFz?7?p58YQICjnh#qX}bBJy-t8Y?5ff zFjAkyROJFP9M`@rl`&(?#*B;~pGws(JN)lwuOe`)enyNA%rP+TSY4U8PYxm)_oG&0 zjPevn6W}q^F-atp9__sSJjx)>HRA{dFnRrRvM@te0pDjN%&9Q8KC)sx5ns zEmyLx9&``BKdPm&EO_Hk{4633O8Sh#u-FQJ@lH9{#T6Adh{$H^ClO5wnLj4S)J2vS z^XbsEOOn_)HbAzjOmfLuJ-kXXc2%#Gg00kfbnI$h0_9a(_?S>6B5yd1M~;;p6YG)6 zG)Fsk)Tnxva1}t3Of8*Jz^^{|hB7ra|3c~ofS-D~B72l#V!!4(-~a!4SudV>KA(HfJ@@SA z+;hSEuf40XoMu~jh`EyRBNmebTzG~bfxqC=D}Kym%Q#E5CAhX@Jy|sqJw6QR7jRj; zn`wSFzns5y??wy)GYPdoJWt+L!MGfjfY3Cu80v|%Mg1>4X=S>DJ7G-=&fCQCz^@_Veo8>(GR@D8au)LuIfPT^(tW@9 z0~&$(gG97r7zPkY=^547Fhr$n-nGA;aoib21_3W}O~Rs8!$ACGL%GA6C5BX@@#Krs zz7mVb3LXioQ?X2XIT%OIUxXZPOIQ0qScPK)kVE`0?z8{@lmYU@5evpjzC#wPx8gsn z>r?oOQHTxqzQG(8g zR27J0!BWiG(7u7|Up(q+TJhZv94R;1`=EI}8O&okR&C5sFJ*Z>uZ>6s zd-$T;rk;WC`CHpV(uyKLq&4vHZ#-Q^ET5QRe~jN^pQvU-6oQRE2@l zX;ku{7dw;bn41#NkO7EE#Z5QCDm>|s#*+U&f5_MIbzG?D0RM7aaIejcIDrX&SuODL zEe2dg=rT#_Dj`uYyfrRJT!X2|$fDXHWhO(mLmDSU@2ihxE`U=eL&?S^|8pR|KwD$2 zU`=J>kt#=8L^(~Z&zb6l(GTrOPR*jFsX$G|m2^{aLpmXRaq2VA_P5(RBw&$eNb7Bw zqA=MNg}W6D#L8YvLgfsKLg>{`y#EEB!$r8dy%jC2=P?KCXB$Db0uOsRU1#qCZXsV0 z&p``$+kdWHl`CsX_i9~)FKj~QmEDy{*PBNaPL4|ttHKXU2>De`Sqe3ug8}m&r&Mty zO^L?*vQwtfWr>DEkSO<`d+|Nbk_IYUSW02}W%?eJ6spw9;Wh^cjS`x1l!F)e={Z%1 zX=gx4s?V(@9r1K1?PXxXk^m--^@<2WYRL%4M&@>*ykltn>*vt`*u_Bc>?v)AOtUyG z`mz9gi0QyGee%`KRcCK65IvYe4i?6hWqQv=@BI~3()O)%N0G}(p*zv+~uvD?(Pm3s*1gS;>S;#Z<9&SZijb~r(Jdw3`~xX$$>lHxPwmC z$l%hN#PrjG5C9a@F%Q9)U<)_D`r^KHvaIX4_tp*FZ_SoZ)hu-ssmxDrF-`}6_rCp~ z-D3a-DYw?V;dR@BR(Gs-jjPUw(ID#wwcT+dAWSq-o*IH;@;bC4iV2VacW-`q z)HQSh$}^Q1JaW593>F!17jyWq`5@b(r9qwt?kpCCVK6A@X7QC}zhTK}=rro|i*a(E zwTHJYdT29)EFrK|$#9j&5|J{P=pF_QqKkIBsqwRCAa)G0_FH`zA)c~Tv;+g$rCQ@wOoQD=(l z!>XI>S~}WTkB^TazPazJWrUa^EvoA|xQC8kxtTQ?_AXuqqpf@Ji(?o!NJ5;)=LRW3%`S(L z7Lh5z+l|{$wmlQ?X*bq9e(Rm8k)$=#8U17%+ukw*x0#~S#ZPCVD{Vk{SQdzK>O*&b z<4k;?9nqg_faYzwg-27Cgxv(G%o?8ApfAJKn@?`LipOj&DniaMfnn3JR?{vfvnWx3 z2wU2E`kp_aWm{VuqUNg7NoAu)L&EYR3p^)QANc(X|8p?DOvXV3Yt&AHjdK`k!^4aJ z_j^~9)gf%;I6-(6TP8+^(X{Ed2@6@+(FqTj)kozB9x?0$I&SJ{M+*-*Xx@&VG7Ihm zyf>0H2E|8P`^u)P*HLw#?^=Y}!eKZI2)k56QiW0F(dWnNkOdDd-1A;?GVlGZJtq>8 z6&fO+$P3;9BnX|p^36Z}bv7Yso^RxxL?hAnwhfn@;{cXi(Cn7R?8cP{JxDYZN&1c> z{#`c%ax2{ztr4>kV&HnzmJ6?(f-e&gXs<@;h|2_DcvHj0DLLmYpz55~>Nzmr$-taS zHT{Oe=t_%J#&7!E-ptLVyRXv`l!SiP&>-UJ^<^J9ncnF|MTyb%Ty-Nzt{FZZEy0vG z>|$pd3_;uF)sr43Hy7@5++TPse(gGiAfm^(irmhn&0*y=dbC5vpF&mKzj8bS{~g(8Ehd|7N(ck=^r9^hpdR*}K>G{(U)>U{@n6kcMaY(F`?r%>5X% zBtgv<%;0ZG=00=CMfH@qUClt)sbF$vf*-i_<9q+%(|8$_993Xk@s3kugsKcQBll`X2I8r#uaKl;ub!3 z>--z5J2V*Jr}m7o2xv>)x(8lCcPAPz7z#0*7@n=WxB*8>4T^Y}j=_aIi>E6X42Z>d z%sla;T>}IzpxHhEFfD+9xpB{jrr*F43R#+ooQzRU(}&(jt`MQ>v-0LIe){-Q4APmC zhyYFwk_#L-lf=pNlCq%#m#V%)=PhQO5LC?bBoX_|@jEe~#d)|;tQ79i&`*Br?uqy^ z(r-`A8-l~`5SR*OtiJJGb#)j#PJ`+WNoC786cb4@uuPm}}{8+ZvH zNsKi&y?DhOd?~f0DS}nu>O_k?deX}kt9hMJH6i9554wl!`b0KmQ^O{02xqetcX+^e zMrd2U>XGjdm`WBB3OycTVV1F2@XdOlA#&xtraX8haU+jg7ihFJCRw|ft<;JU0h+bX zWna56O!^kiCrPt#@r`rn_K#L5*&>r;CQTS`u|wq^iZ0Z{rl3lTP+6c*{bL;DxGwX4 z91|vwX+2t~O);;Mt50}4S3Dk@5f08HdZ~*=5gG3u8VWj60BlqQaC7gfNM@8odF|X& zKhLJM1)+rET%UJ28>te;RO{0B?nPs&lc+=whjKpIa>$Rqe*y-B&=xXPqk+Tm^cLj| zg3@`OKU@+8T}N>On&i$oM18;0ydNRT2C?3szy{3go{uIRjhOtGu?&QiF(jS!(vF|Z|a zUwiixf1*K91i~F3SrEfSUbSxH%36F08&VL%3*wJwD<#|l=^Mj18z~GA8dmlQk20?B zKgIAHW+9p|>4?9#zk7Qph0(>xs-njJ?_!hsPx+HVM{oSgdru)pNk{9|?A$|B!;G2j z@L@oPREvIE9rlQb0v9;Jrc}Ob)(^@`Kc_)-@w-ZcTUh~!weu2-86Ak|gpRW=ebndD z1J|_DxC|V#hn2<(PuW?@XAG&DgllZz?SXTuIOxcRP)3L_iJ$I7%w zQP5HL0!;_7P~b*#n(BedQ4l&&KyJ3T)iuI$qt#MFE08$i7#IXPrsV6x z?qXCj#j$O1L7c+6%gBi%toSddW1Pmspol^ZJ#C-e8}J>=hOqN{nXz0mCyjqOK-w1s z{;Zx2N8%XK&Dw^zYN8wrYn@v74!XY`uVaxOA&$q)^~D zek63(>~+bNBrY>gqoV@8vo0KMw1OZGN@NR>c}=h(xt5|K8@3yB z1tNyHC7m^c_m?*PcDLu4m2gC%I9@%(1J{EWGXamsFcY#>&Aq?=VMe7VhB{--*3Cx{$>$CUI=TaW#K0K$@3p2yRQvJfM|An31~%ZxNyJ+IzL{^X&`p z1(v2DU^P*2OYs+6KEQkdQWusp3O*4t0}^l{&L2;hf=>z_)nrgAeH)kbVph%|j=1rn zVpz<{jBJ-l9pxssw@fn49ws9QuIxkmBr9>t#THfyBWKxBU;gD`m^@*dyyzqBDCclI zhL?~J>3#TZ-)1TK1cEaM?diOu8DM88dZ5M%<;1)+9;Ba50T7J_mfT>TW#tpLcn{l% zsOKL3w}TlhOdumJT$E5mTZl{79B3@hDB3(jEa3FUo#zv`l}VT1sPjNrW*!3InL70B zp4$-kPdQi0S{xRLKR&aYI_BV*kc_j?GFNq3P$M#`f!k?KM@>GGlT$!;(vl@p)>VKQ zw&D8KXAGuoSn8GTK`&tdET6=jE95hk=n5=WRza%lch%khWqFGnZSVOIw--}eP6vsI z=**j5)dnmZIP+}A4RAFz7J$0B&|?Rx5d07tb#XK2_>s9_K4=szr`Ms!e4ERe!ovlH zYvEA3yCgq%1WDJ}fqX137RKA8JE)>{6Sm$*Rixd7r3(90ICsQ^!SFB?Ldm6Dt5i&+ z7PEGdNwS1yN=UwemBnN!uQ7HSH1-%cU~Yp{5$UJMjDhpH1_Kl45mv9h=%hOcv&#Ge zRfvgjm8tiX!A*1Me+w93nIp*09?hZnyV)!L^+sDqJuX{lfL#q<(`BmFC~=4on=M@s zyk?@`+F^O2p>%w}7hve{b4u#5NXYS#Wk2}!-H(}oFB8m04V5wRh=i3H&#y8w)umN9 zNRbeEWEri3<^hcI1^?Xp!I2oer|ihiFVQi|6k9AWmo$3#nGFLgF=%NK2wzlv&V+!2(93B7i@gji z$k9TJE9(NLDXak%p~0X~Gt2tS{Pf2#Xe9(-7z#sBE;XaBQ_d7##5%$kr~;*0L&_`Z z*~l5PV@xJu2|hk@@i+he@NX#mjme6XfpJcdA)I|ckWhMBJQRdxie>mC^HSt}ejwUOmWU@U23XI`3_#yupqBx0OpR)cxNTs7Hv5W$!4ZdcEl~$K!lZ(+$oA-Fet2pe!CyGg)srY3oe5pAysgQ^$O&g z_$EHW(dtd_d1ey6q!1$s@ zdB^LkX>v^L@}(>WnnWwB>*4BQKpzYcZ1~G(e@6o#iJo@q-tjH9@V@zNNB{ea_)^KO z?Zj>)J`oU*$k7QnYpYY;80=lX%${bAG{D)Q8RmpV>`?kn*E^D~Y>G&Bl$HDfTZ|=? zK_x&%1GwoG^J>QR`qCNzSL!x5ZJ<~}!8fh#a>hgSk@4qrzZ4rpB#twi#GFjL&B0*mZ@%cr`Set}8!SeHFiZ#m*&vX| z>nGjv!vXjfHA_oCQV@q^iH2H{Oz%)|*)$MTbpK zwYl#$D=MeT3)>TEI5^1C*p_9Nc=ar6in92Q500S5+~51^`y73hY}uJ?%K}jl_10>QiDKf= z54}^|W+D_MjN-7UwKJdl$>tS*Wc9$v1Wu(sAP4Iat3h^2c;S&$)uno{JOM>{F!9!! z*LxjFHyZB-Nt>8!O7JKJ6X6Jg-R?WZvSBeVTKvZ|&+Ba2(i^!ej$!B&)p`yXs8#hF zG@R9&dqN1K6UUfr!)F(LmchI7EI{CiEFI41Vr;e9%|U%v(@71?`t3iOZoy_1E3Q92w40*x9T0EUS=gr>kZe$22cgr!J#t3LF~6gfiX={@)Nq?^Tq^ zWUL;~QU|b;SAKf87t1kuB}Odbz;t@n33MEMScT7(WW_i#zBf!?Gk4VX3>rj20Nd-a zp>+@#@I83#;_2s1+>t@Mak5khwr%NUzorpOG!%2Y3-OQ-G9wsy-Z8;9lxMuo08Q?8 zxa5FBUHOR>&$DP7=X;buai^xhq7u_?`Nz-KGZa!$hqF8$aR&NQsS{;(CUOz}!xh6U zrvhn`ib4#-;3WR>N6QXiaC{_%E55w;?-lq$^hgqBya~lZ631-yuOxdGnKxJkXb5DJ^X3DQ2#M@w0kN z%}hqn?IBL5vWQy7LPNDoB8d<|hw-!FlK=hX7`zl5DS>J5cLF1cMDoYo59L^5O@X&l z1p?t*9`)$KOU{T8nSp2FtZt!89|ok5RyV)8=gDVbFp-F;y$P)fVPQl#a4=4RbC4w( zQY$Ip6q*<3k~b-@zGCPP*ADhUm4Jynlv?}w^@yYogap<;y5R|?A&VhAns#J_Q2gM< zFN!dhJXd+^47^HoXO7qeoeoHsrcfmoKxi2Zxr+oTytm%1E6+JJ{n<=Et| zfq5kK8BJG4OSq~9>0l0!S?1`00%b|J!(u5KI=gvHBs6Bl7hmOWc2)|Xg#cwL;>5TN zU2mf3<X(tyHDz`MBg&PFZ_~yw?ADz~tNtNK2Bx=C$(5^`VPfsReCUf%_ z|Mn41=2h30$cgoR2s8L;I4sZt_~8l6$UReq0;5(K?8y%uIglrEatF)b3#o1#o@~H{ z7p-+T+smpNPattbr_)|_$9#_t(zsCB2bnsvVLC#BI=$$vG?Lvnv_>^yLvb)DvWjbe zbou)**l4RXEFd-+WFt-uqlY^yQ*I&KuxbYaIs0YjRxH}ARxp+67)BAs41M4n!e0GpEc*x-R+B(+>m(*BSwHUtOG zz!3Pa=+8O+)`DyP_A^;9u%J*v7t)i#M)BA`rD3@0huBK4>ac?wBo7^-63&;df4A;+ z93dlHw31e)S@pbk5+P0!$^agSa|D4NDrVh%qrsI3|QlD+ZUSuR#YNPviY^GN# z|FT&|s+q4@Rb5SS_+@A<{^t+QQ|`qTj~DDXhl}!Yj-Uh58)@bsZ^GI(KDy)u&X{=0 zz_?;Dj+UA_hVRTqWNSv~)7H(4HgHDrR;Y)LMWaw{(T&H}tF#u+@}tU&m8}bJItX8q zmkR`)Jq$Thv+C47^bbmLCVt$=Af(czMGdP#vZSIJ03w2Ieg69#NiE7~+hx$s5R=aU znVig%)#kH>;2Yr}*np(|pC>LNBt07>3_t_Ws9bA*X5+bw;@V+@*ld*4x)hTHoy|C9 z#O76$7oJmdr5`<35Thb)4E0zz%{o`aYnUZ3`j-4?3FQUP+F4>Vbfm~U@60}kQEk&7 z(b!fDBeVH2o}7;QOR8=eslgd1&2=}l9r>XEk6w7L+I(RJI2p@uM6X%Y_X(ErD<+qL z7HP^W5R1&aBB4~v{4T*mW7LUCt z5}xfctl~GnGj;;4;xc1-ogeTZs`+b*f)F=gyrDq0FtjD4gq3Ob^sOTgQNq_U!I z%;&%U-)nOQlig_UB;J;+1HD75L+9}RS610GPGR0pIx2so=~gTd7AgQ!9m40`I?E)J z2BGA9RGPEm zP{f&_Pj^z)k;K%nLS{Mhb2{--m)C0mgRVHe?}268KPZn*x#~7@9$!t9k9&hbr(O8c zUnxM)=_Cq6e;NRV%~4W7@&wz&M}bu%jg+u#P|*1cXMM4n@jvINY|X5)MWVUT3ZRh{ z;x=YaV#*wqn@p74!GRU5yXll+-hBfGcrm~6nLYdK796JZXAhf%U4rBQn`wpAPqcKwEsb3yTpX>s3 z?3mT@0@27sSm4&ILTm}^&L~#D*<{ID`-9FJYNWW_az$!TA%H0KYb*#J!#bQ3M=~K6oj6{PZ$OeDrfRbAn2kZ)mAfkd@1_gJj zo;kXNdhh*SK7~b0POq8KmnPBZ^wg>+4J>OAk6wmZGOe-_9upb6LKA_U7o&7>i5C8oE0>i#X+>x!*2sQje)LguL|als88^)# zhnybx#jC$cIo)-PH(`Pun#!r~=9~9kOv@?Hn0Dkqs(MeNJS!O+4=LB{%(~u`az?gc zXRunk5rn}!*$lcL1m8er+_IK=?$PJu0Wpq(J+FNC)^-Ma$`ZqmO3n_`uviaLZF$R? zFVlRlq(mz!Uv z@vt0th*KtlzoZl|7%X(Q2BE4mp3+;yZ9<YzcMJi2RW}=P07iAcPRetzs7Y z>{u=Wvywz0+=m|j!d8~YM~$hfIdB&b z^q2#eODw|8igW+CoVY3VYp1m#*&wpuF@{7DO$PL3V+2w__uiT_jr^Qzo>8 zlN$j@2k!IVzfutRE}Az%Q5*qD$z8~ZCVi)mguyFDJbN;2C*YFdI&zP*Jz5?>E4r?x z7Yr;iNA76NVhH)~U^GGw`s*|G4c!R{osuhtaxAZWo6eU#W!2KVx$N#j#8P^x4UAL1 zswY+NhulNhv?J8zc==1aFZ(#YY`0O7bEccJ-?y;m=0$u5(*qb#%bR6DRxi@A)Yp}VOoHBre6AC*xGk$aqzHkp3nvslv`D^w&y_n3P^9ZuZ z29cnj;|5fc-%&q_T5;J?TN~RZiv*YMedDHMwSvrQV}3`%GX@Zmw$-I%W*ixnASk}{ zcW3Xxpm=vAxP4g)YA2c;H17(@6Vn;wHLH(*on}jJM4q(^xvXdbi&)lzmyu4Id-uLi z(8BQZSJ<=I-mpoEYp;#4;pYbp}v#CekfBoUbALfW;g}j$Q+5H9_ zGL{x z7&tVAFWv3bo9ba#lXR=0Gy!c=WSg0uR>%iYv-r>(KSEJMVW`dNCWcW=pDN@4M52tF zH*Lg1I|rpmgoEgXJGSH_&pp9}zB7sId|DVWH$nc6XMTOTe!?yI7C{x}dm!O=PKK2( zJm=QdmhLCZ2?p$n*+tc>XRo~aQ)9g%&w}fRy(fwt2%GqJ-=4Ug5CuU;D^E-(W1JN5H@vBQ#BQ*p?W; zfm~j7&45uXfpVsD89 zYu3DQ5^eTUICBzv;YqV6{g|gyXL}YF1J=PCb0C6kD=9QneiTH@-+lRshounVly#1K zs6?T!0nbUqF8;;-M^nT)=eT-tVaD^!loRUw=*1r*Exruv>V+BKe|Z94o|1Q}y@#XZ z?yqDk^e~Q{sWNEI*#G?i%jnz!==lk3?o{Fd<($9H(= zQa#R$fQYLb=eIS)!PnpVSQ~Ml@SdYRv8wS_^ng^^2T_I?kEwEy5*4Wd2ez=bWFn=T z4}0q0%&aGa13H=TooCj!RjmIfizbVQshp^tviwv8o_11Nd0pcyMyU&PZZK9%&8vjV zs4$B)0f*j43qg@IeMLm!BO7Y)(C>Vk;v-p3XxmUAGmD5R3<5bd^p30N(|#<|)sHm6 zyB1XvDXkxGe)>SXl$o(`4MZ;9c>urhW4yR?iNd|y@9ZHt=CU}@vRduNkvi%pM~^!U1SIe94=}s;E+%=QLF{ zAi9Ov&Gfptdf3D0e1sbZbK^c8elZWH@(_5O#c0aeGea*UifBRKT4g!>?BgLcRTfjIqu}Xgs^-`Ua|v0u0r);ntXwq9w?UlK2|*j%OCKQrWkubAJ&*6`rC))dSzf%TT;?zrW~O zI@*<7l|^^MJ$%qSb)h#Ll-KmSx;fHoF(8203w54CjtbhoGUxqsSwv8td0Wssyc!P{ zBD{Py#T5D^^<3}L+->9u4>o*c1PeV42p&G#i+fHSwv>C(Jr#Dzr#%!7S|Ma4tXH%| z)k^@3LX&{AqMD&L=x;OUk@*J_Xf6R*9Lruc=l*}(yz{_rhfv7{*aQu6*%OcnH10oq z=87>_JiYTkzXif5Z*eZcB&T}5Ml2F%CYIhYXw7siQpKfi{(%B5{tJ^#zAkhW`~K2; zs}1H48~`!6JHsL3XW3b6IAAGZV*GgBBZX-&DIB$DHkbFO0ffJ&d zBj6(oW6J&;2QlZ_#UObp;kveQDlH$)d~C~{?B1(xCWGxf_3}?%$G~kDlZ8S22s>70 zQM@h}qiD=gZ#(=>^7f7mzVnNe4YlRX*|o8o{7gDx4+=e_jY6Nq3EbBf>+hdYcrfsgJHLQx@9 zC|oXiGV$8IesEUOTe^BE1tsUmQ5%t_wcT5(m(& zLy3Dbl5#F>DB?w{Z>2IA(H@4z^wP6ujf%CJ`F--H(opa*R#$pI$6Zc z_RSR_DC)-`Y3X{~HFUrk`ifyj=_=%KkOp8yW|EQvu#id3H@s^LeN+=_*aUaDgUEEL zo!`R`QakkD_Dn02$N5*K^q%ph!x=2dAKV7THhWSEVZA(us_XGZx>Fzh*8^u@@Jcc_ z*e&7``oQ}oL*&G;MkswJs?p}1T@OuUh=ccA^oJ|S!QDzvS4~4Ou-YqI_>e_qmaMYh zfm_@TRPnY$&boNV2Q4f|+D_dgX*un+%J;F%Yf?)`E4saNdykK@8CEt=%$WFz%`>T0 zu>y$NO1({Vj|Sw*E@iRKN12Y>J%PcdEMNr^ao{`74I8HJ4`Rejw&zhw_ve$5|l z_`!#>LDFfu8;6(0VHuFmOaE}?SIe_yfKt!}1O_MS@a$eW#^oKp*tDFbc`(Oyu_A>N zRE+S1aWrVJ(@rYsc2FfjrX&3tP78E3Cw4%2hFB<&;A}MIv{%1P9_)b^ z8KZ#l<)Op=K9nKI?q7zEP+22i<;}#)F%|SLfpq z68o3N#Gr^8Zu|Vw#~C#gS1`evSw%`#r0HSMVYVy0KV@SDyt^$Sg_xMh_^_Ky7lN?yGA$lFq)Phr%#=?5rt6rlkmE1K7vz|&9d~Mq0jVAI|i6>WCeKuI2;0I>bvgk|0w`7uC7SKs=R&6%o4+VYW`(K*WPmwx!rIcH$H7P z4P2w7-ISYo680N^ct;7{=dQOU>L@axox13}a1UNS?-O%#A7k?)LhHBZxqqZ{S!6Q# z4m-58_-1tPnQcwx<>h zr81(7&qntWRvBRO?~rxo2`<|&!m5`CzkiP>__B+s;+7QoEDClzo+I2YdgtRuaotu! z3TBlcl}QJw3~EnU(gkS9kfwm@=v&C&o9T+Nbnmw!xbf(lsHX49uQZ<wYc2WPoT{Yk=d?{B@l3@eYcq%}t&QsK3rtLXQreR5?KA9;x z(=Vd$OLyPa#&Gp$KU`9@aHH7UnJk9O?&T^?AqZ=?JM*tR?}1C7(b82AU{8R?jgyaE zGaHjS&EY4D03-;&m1;D5n5WEXKw--+3J@x!KnI{KB-VMWL*u;a`Dj?wz{|Kzbjic5 zKdE};xa+iJTXYX=GhO^42D%%Y`;oLRs_;7u7F-IqZu@fB+Yck*FJ4)B-d(>R7-4U@@OKzhcI$f6%~o{wlD3w#(Gf zpyabRE?JN;t-CeVFs5Qe5;93fCpQ&DpZ|A^q`}})j#&Slrz-e``6)ks0h7=atQ2Qx zwm~K^kz8=`2Dj7RNK66E0W@HeQRn~R(aLTn2~9e7WEzQ|7w+sgbT}soQWh~XgvR1h z$`m3@Q9*;Sx^*^)AC|E%lN$#$SaIBocQLe4>OFy70oK6clGF(234rKk*ax9z-*$cI zWq~HQLYbwL#xh@?%GAu!F%*@LZ2$mrj-B<8I}iCiS7ZTCtZ_{DB=MFF@524LjrSOF@oAo6oPNk3IU5djp@ty0bbgCbD% z!j+$2&W))IgYri`#p>zA9-=(W_mJ_P48bj**nHag_ySH()8doSYQu%8;&qs!wgZ*< zFwk^wCZGx3b~05G%=!=&z@#xgD~RtIz4mZ=f2BwzSN?qA zJ&gG~Ps+X!TI9r7@Juk$D~2thnO}SVwG&RvUZU(LBQwSg4X69#P@R&7grn!6sYUs( zlnwhJ{Z8OYLMug>Am~9IK{^E_DScAKrfBqt{sx_Y>U`P>m_$g<{BVwrXNmcFt&Ar} zr+=a#@#G_0`yt`&d3}`RK)i4L?Ke)Mc;9iz@iIOZ&ARiKXE7KvK0B%yV))U#3}JSA z2@tH{?Xqah|XnAFUY$m$p);LqPxkR9&NeX?0Z0o!W5wm6UbI)es6y8fcrm#$x0!TJ3ICx z!Q@Bps;ee??e#d`!_(FQ?qkWWOTL8eWwwNeL98M8;f@7N0hJ}NirAbFK;iT}aLnuu zmj8Mu4}wj_!=7@m|6zmLAwReC-L=tE8Uq^%Nj|vmlsy^xon)qbjMho%r#%K@Z|C^P zsL)?qN6k|)8v{^`$wTX{J?*?lN!%4o_iqQBiW@IkbJ-;<&y!_$Uu_o-gVr~RPNi$6j_*(c|dLm23y+e=+BV83271r zV>|@?D*SI~a*$;vv*>7a3-P`NTe$3{o7oGb+reC?X~jhjfT)ON#mjg8^;Qhl75#$L zcq%?7Ovny_Fw@QL5k+_Qat|q77#OqQ)Jx9*&=r>R^2}M(*GB8qmlj=f{wVALDdE56 zn_sOSiorU&t~A`99)fWa0-!cD%z{hiQm^L2EB84RgSgtE;LZpE#p)ExDj35v{MgZ| zJ$t`CfM3jhEcy~>)0&sO{R`!1;9<}sHFkDTxqV&(Tq6=gb?&)|^d0y;)NcBRm zDQMSgz-2cvmRVXgeLkK=J_rhp&ZdeAOjHr%u**H#+3;`=cM{VB4Jg1{yY#Vn%ug2w z=Knv$C(Lbqc<8K42)XTJ6TiYi5xNm9p%%gc@?}2q+j}yR`wQ-00|;hM^^g ze@Ag@CV&~vn2Cr3=8zfKeAPQnp;^q$hjN9y3pc<)2)YAqDlb?5!LZ31G+?{(+B4A0 z#D4`2CMHFwV(WL`{5W$QrNJYKaAM_IT|^>$!g+1{%5^0o!&br+&iym5J%THt1sfR| z6ihu2k1AX^>}hR40!Mq*(U;XQVw1FgkFX?))IBL_Sk9lde+R{?Xaw^F=OJ3r%0#P_ zgCJ-(sZ#mT;mLkgRaNiBm+a_UoLeC(ZP2WUM?Q33`FB+QXw!pl}ba8Ab9V<$8#1y!Mukz-x8;?I>yI#&Y zMT$Y7W9zR!bt}&_Zyy8+s5N?(A@r_PC%qMN!ww$OBa3p8d|amyvA7})7cxO+0+198+(Ji3KZS&nHTG?6n9bvGw1Q}1Dsbpw$}A4Xt!R!t z88ACH5C+bmQ8Qnlfm8gzm6j7NaVbU`y1Z?yN2jiG-?vGzN2C6H!@tMlTV<{-;--fs z%d!X$(jhd}>gG1IbhKfikuf!T6kc)^AieIp{C5wraG;VQJElC_(a(#uNF*Q19vvpr zyCHQH{jS)FLRG8?x7m{E(}TMqz);^{1+Ekm7?4$EL?1qqUYpiF(Lm|d_2hYDb^i1;nkBUs8Z%UNTB zNLQ|h_42ZN+I}rP@Afe&RAA8X8ejzfS+#4Zfznfmn3-RepMV;mDOyiIukQe#*)I;GX6;1(_eI^AO;LBvsL;Wwg*SH#CoXOrhhB6M3$A7 zTle|5{q=A>3?7%ZX=v|YAUTkowuEk}s;uyw7Jlj6JN9My+js`W5id^O%p&3DDW~t+ z!uR{}Ay*xYmn%s&vGhx>Z$vrx6%T`9r+()>E6K3sjGDXcN7z7<$ss2jC3{<(i9C(S z5)u+iPky8WpI^wRsJw8C$?(xKm)!S*NlbKfiJOqc2nY%euvu3C3+^}F6~qrDYdYGh(FR22HsWY$ zl@~;x&RN06OT8UJ1c|%!z_w>;sEN(P=|ijD3iAcY7bp-<^JU`Tv;(hp9MqaZ1(=F4 zDF#XZAi5Uz5@uYp{OoHOnHmj!9wdkvdO!(5Rwf3Sb`_lF)swEi^@csEAoy}DKa|m$ zMQ^FS@?Y0lhb`RvKAQ4;*_{~^2wlI6wF29lPyNTg7)HrwXkfT_iZF04-r-07@}<{o z*^SLL%3+Pj$_AyaGK zfJx!#mE8U8ki_iBjfv$;WD*gjo&~@vSZ$A1rT``s(6(TYApGb8Vjjp_ohbC94EV~I zaRO*2%zS~zl_PAiWP15+pTx?EK1tMhY9XUQVp|79S{+>Bs_Get?Ld!;`eQ8IkR(U0 z69-H_MNqzAMQ{@-2|%g<5)ODMMU;Bkc;k1gS%H9U=%UG0)K;0E3^{(5J5l{u;{j4{ z@R^8_iB>_tl|zoMJn_AkP4bmvI}z_}Lq0DltcqEGaSq-OJWJkbXoNEtLIs#Iyib~^ z55!5(!{n%2mz{n!zKoW$>oFY_cCmPY_y}PpHlASJ2Ih(N``9!3e#me@%FZF}P-D78 zhriFVPLd^96X6ivdtKx?KVy>7vgH_ppAH&W_}{}Hv#e&?drweurL1t!V21jQ=Ae&t;}z%<3TdhB8zyvpXJqXP3a)A2KmO zrI|V3V#=ZArN>Rdpjmb>vFMIRRsqCNt1xcVGBNz+6pqlel(r`<12hb~I*ASY^Dlo$ z5({tz<*ch)f=QeOXrae}Bd(NwTJOq(=Y9!aOfk=gD|Io*M`ws-YgnQ}blC{4p$z17 zMA(GYk}OAyEM|VV=Z;qPGTpl~m zXGTX%(juN^Z2)No$Xr#&Uwq?qP%*Ynu5^0EyuHS~RJR6$jZ6*=fjly^46?|?v~Xb_ z6$bVjUQ!DVHWgAh^{Uu{tb|CRtZG3933Q4#a((2)fTfgdYM3z-BY&l3vabD04!D@t=GqR-?cTPgn zi>M_`04rocOQXC!Oq4KRWhfG!}nGvC2u zA!311dtEt!^<&UhpdSP#la3bo9pQS~8#Cxf^lk!LU^Q)8viIDfn8Z@8sho8#ib_87 zoppGVU;|o7fRcBda22y)v20B=j(&m67tD%4H_Q z6ltY^1}Pz$pI5*aN5byWoueLWL2L#auNgHc42v;zUr7wGumdk0=lIvyWuRP&Pbk&Z!PbKm*WNixiHors)DQrQ#vwE$crKGJYoUfO&J zHQZ<`vm*I;+=1jH`ts%}WItBuQj&R=*iB<#S=KXZ5!z%Q(@-9Avh3v&@P1-8Q_&M$ zMjZ<-y{MTQu5fZ^lHgT|?zI*{WGX(NFPcrxM!?2H_xxGPcjWp(gzdm%sYjCe&yiH{yzgc$9oe~NVLCM@x*s& z-4qfOsaZa@-_i!hF6vooXQ~=wX|5UE-PF=NW>PRbpVA@MRk(i1QI?oSW%>|$w;&lZ^V4|g#uEWdAk{~z%*MEADu@l3}e&@u*P3zKSF z8<6XiVFb4H;$&nDlV&i&nf!2r7$M62$}by5AF`AYJ=ilkh2Z>Z-V7u+j6y$#FRrCu z1~m&O4G)@B@kcFbM;3wSpWQ?wTqET@c;Kfl_Y5bf7w>wdJ**H^heE(YPh{fFEFW$l z6%E3xipB^6=^tq*E(Ey8;7fn^z`w@f%Q^JS9jst5=_To6;v=wzMNRl;as}fUAgdT$ z3YADwvkxEs;5O|Er8A(6=Cr5kqLUYkP@j(*@U zeCYuJ;3+O2;gO+F|N4&?;S0)J69ZHNCKJEL`IP-$PvN3K_{rfyVBLQMF7Yo5V3|>% zLE&d*(QW|&UA}cw)e;(Gl~`FPms8@UYS1ij2q_3cl|?QlBSS+)a6Ua0!b;h5SShNB zR@a~X9tOI-!A?vOhWLUxt2}U#8Aav6_C}(Ct(vjX48*8|w$L}Qef}SrQzYbxE;q}? z{J<2iK`MBgZ_=BQyL4N;br9E1q7V@p*020i@9$2os6hB*@uOE=wX_o;U}DXy1{3Ts zEJ_`Xbbx>a@0j0n5J_l|7!R*K_?J}xG6g3QzR}Qtjjxg?Vua>d&aoA8KjLH-q>Hzx zEBfO`C1ahswU1148t~6gc^`_K* z%_hkNsA8KvwqIdhy!oMV=cMLE77V%@r4quNRBE<-J+tIVma3n!5~)56J#toja!{B_R%GKep3xv z-@~aJ?ZQ7MybUH)#NRj7=ueT;+=<_PHV0*x@0N<@Q7M8GBA%W+yS|B@_2O(27$PIa=+a#n97wo z^*b$fV_S)v(y)L(AI$|7I9f`DNF>CVZ0+9V*HbpY*OhC3|83I!>X ziUMp551E7;x56nWjxAvo9;NcoU0t-I8ro(O4fx0~TzdPfx0K}K4cii_7$BQyWi zs!~{i7BPivgZGgW4%MJA#s}Xr_bOJ$YtYOTB|&TnV*kuG1VM{3A#Z13~(N&B7=W(tMAF~E-AgOY=weQQUe>?6Iz z4)mvWgIoP+#8kt7KmNYUI~|OB;h`dCMxkIZcy?GwWr#K~B%%hbDsrIDDFuR(=Y%w& z?T9{{%=XED(Yzk#Mk<-QbPh(R;&8uQmL+FY8H8irV8tL+(>st*MXMXQ+0a|DO&m#9 zr37)^Trl8~J=v%KMK?T@2V9R3k}kFy6R^@2`!EaXiNN7N2;?s@tr5fNRPLES3Cp;n zovY;1B%lc@AJEA+&yo}$Mn#Z9kzU3c_>rkM?A7;fq&+!VEJ=#4Z>?5uJ=R%x;wb#7 z3gHTzXJ*hJTOdW;J)y%-9k{XdM#h|7dn9pEW%6t4uq8-xHdfb~4eL7}PrkNQO@>z| zJJ7vgIT{ML+DZ`q-BACX6$9|Ch~&r-Vanrf`$@yQRZe1bo%))}XqwxXX6J%w2g`Lz z{{z0o%mV3+QVj7IgmQoslR97&EW_+-hSq5z@eq|{o+lf{5i%Arj-=_7%2RJ*u)oVe zvzSr01Is{J^TuyZzp#@*qiqBDGN)dtbn~yUh@Q@;$(GzO?)r~)HW|2nr)R;~{AjYl z6Tb4{^YEn@y5@A$Al@4?BpwXNU09EIM@%w)=2?kZ_O`ecvSN*Dy;;Z*R#M+JO9;}b zEx-FJnanKlog7iihkH2pjgm)LG|}y#U9OzBv_lN6`qG}ekz>0UEZd?UtXovDdD^M3 zv3{~#Ar7!$66(wc%7lnjzYvI|Ya2>?sr@ofn$OBsWw_BQ5Qeeky&Z(337X?_1ZaTiP)mKE?N z=34fpd;ZPn(2lGq44C3c5SKKazLq(QiN)zE#I$stm!ih=3T6ZK{j*rQ?6WEGeWD?wOe;raWvP`B>V z$-{)nrigS`j{L~-nl2|(;mefWDPKS|sAtpFx0KjjjkdDyU5T9~8TRg;E^ z0(VHZPP<{w!K^JVKFM2;U&+>CTMzv=Yc6*w5Mj9qUS%S#`@<)$pRg+?3llg#&9GYC zdjpeGc`GT3$8L<#E`_L8vheV;t{J=2ljSV~<4LlNIq-y^6_J_UhY)s<(ahoa$(VUG z-}BJ7eUpX-ypmF;r4->|9IzMe5)%59HFUVH96E;YfOOy)f zdr`mRa=oCPnY3_e($r7>cm0p4sq9@B~Q}ABnAP)An3IP5o8?6V{*7lF;0H z_Cnn#v#TZz69pPFFR1Rsi>RrKPf}FF>>`;%_G5^}zFF2B#I&bizcdIiV3< zV2b`N&rD=FekTE9R0cH9Z$i5{PaO+$N#lwzC~{|r@3-r0BSz!kusB!sn{^!3w zNV4Ospgxz5c0%t7(kc7A`-7)%|KPb`;(bxaYo{Kth1Aid)b#r>lcO@7R%U3>zCZo> zg=HKRQrra@Dmhqjssc(D{AOXHcsLTJF%tV7))rGQT69x`uD}1|Ynd&d=%pYjKZ9YL zYE(*+jH{#25Tz)r_43U*LGxi4EFWrY?N`74Q=XX36JYV|c#o9Co1ZvnUqKdzhX#ZW z>vWLrWUDNx5n~_Oja4B%g)AMk=X~X8So!bYH&dI73PPzg3MGMKl&luHubVs7)KX47P|jfco(u`F`Q?UJt}WdA?Fr zH1}kk5eYhT5-w8}Q|8Ym6S@DFZ=6f7P%DePMJ;R92Rl1);iNb~HvF!-2j_)JcV>l< z3Zb`&Ym(4Jq)Swbpa!0G=dH9F-oiaCIJJxEKv1w6#>_tXeT-m7=_-3!WCHrl1R%~l zTU!D)ArG~h>VzALLyA4?EHnJOBYwuhl5~a1etI7Tu%>#eiQB-Ok&D>JH=8v6y(ff) zJs!BIbkfCP@~#Q%EdfLUm~|goe>DRzDF$+c#`aCU6G8-L>a5cbdN5WO7n5My2h4T| z6?78nRlWXO3w9kQdNO$@)Q=-LqkjDQy3Ycbvd{~``vFMh+Bpg}K1Qjv*%2&(ZDt)m z%BQK{ywVxgMjI-Ec`mt~mF#L5sq+jSbEd-yK$)|sx_M4hDD|!yX$J`G2s4`w5Dv0r zuMaH$_}TcPR1_vV?M$)@5E=~7<7_(vP}^9)o{f*v`{}jS zt<$SYaXhlMp>1~d4{M)q`kthR^6gO{UALxR2DxswA~>Ksd)iEBdwAd{_rKymJUrR= zAIr0zXMu_E8$V^c1EB-IeEGG<@Js$fS%JG>#Q`FPKm5I^R9k8TGHuEc^s~_ z%UTSBLo=5$Nedl^W=h$dws`(6feFK7#qfacDZ6s= z$$68JS+K&aV_$A2gu=$-*8wLN27E2Ku+=U6@x#13P&^s&Y_#c5V1apvDaq8zJiAk zA>y?3?|1&3?t7001ERusJR*am#PL>uJmG-B;o)q!^{T;ij(u>QzJq^SxO6`xp6x;= zBGUD114dna=2t(2K`H#Q7bK~o__H%m>8o5WgkS${hg?MA7w-Bt<>H4#Yum@81Uj^m z!FF(8L(|Z#jT>G!dg^z_iLX)&OvEV^R94e}1k;%N0Y8lbnH|ugY^z0MH=K^KD@HU3 zaMb+F$NoYb@h+=&w7^$SOtx~M@YS%mRCzmw6^;f!H2qvEh;&f%al@c!R01-`ku%eh zYBSDd0isu~7&((g>*>o_8<7;=9-<@p=ycM82riB&DS#0byK85*a$p)*Cgv-@J?i#P z;mfE_6@@#VKsd}`UIdWQfV>r6ZCZ`~Sk?8w$`0!j34%dmo?7wYycHHl4~|nhhODiC z;Ypk`X6nbT7=u9yNoiKw#iQaWs7}d9ys82eMW8-XKVh>UoBn@gzRg494d8It;G;rh0_Vzj9o}WqR zBQUc{|6e^D?GtbgoPP;y>_CHX*8gy~G6siIVO~@;b|POBL9X%v8C3VQ9P*pWY*0Ta zm)xxc<|v$m-5E?fRb-uJu@&A&OOcC)T7s@gnjbv%9!94f^6j34>c)rGp-XS~fF15= zV%R{}Yt21kO+#ON33dXnPesVi^#~t=C34?kKe&O?ZA8wG-IyVo0p81~hNt>pf-k-d z@Tc_vN0)Dk5Je<&l%1V83p=U9j&cX&G3Z(uX(p^g+TUSqZxeK*o9kP|&RjffdK$=F zELQ^0cH`q;``$(P0?~m73Y!&JnJD`#Wj2*gr0^1XmrPmPnPA?aXS5#7l3ZRjQ~z+B%I8AB4wKRu^x;v@PjzG@ov9LcY9uSJMIxlcN?dYUwGu0tJ0BkA1XF7NM!K4|N1CN%JjPtcBjEK$GjlYq(Tx!5BwhmrEYfVFYD^G!IoI2NN=CWse;C0r1If@M7F3n$g74H#N(_W&raCIFPoCm7P{lHY7T zVPNZTKlGW)Fmt8cyfiN>zdLr$0w4R%!Ai!zYvH%=jou1C)(2k z)L07Om4z4DB5^1NVkfsYavc6fp&q($!2nWITDG8-R+bvnRGbXf<+hi8v}`#3;1Gvq zjAguJryK!@ROX<|6FY`@(?`k3&`tw`fuz^}?D2*4Nx3JjEr2BkLU__$ayc~cplnyO zD*~gJpFHUcHOKNxfkVr5aZm*2*KsSQi^9uW@BhhO#l7Jq*If86W`-=7w);o>Fmsvi zTHb`2a!&MSKo*Svuz%LFId+^!E_Ts3wk@vB1_xoa%(D~Yu#zg>Toj}o2&6H)fDqHf z4fn|qUh93+9rq^YPG=5r7q*R>OIeSp&`cj~L8hbKyR8TUZ{Y5KyY+MU7WW9SBn`KzlVO@bU&5aQ?lo8Wd%ubRl-&upU0uWTu#M44LUL;u5>?y!r7giTN*6zrPSFI66n z?}bKsA&iC|u<@do9>0UNIw^Wkr~)Kb!&o79u=j;FC;6zwWR-~^nU=%;b--^bvcZsh ztAWgG8mBHgldS!AHe5!kS!~4(%gN(LR+Zv^pxR05CPsKkqWf0VZ28@xPrnOaCIdb6 z9T82dHoFrYGlo62^3cWjF)l|z;~m)VlI`B_6N>LQ;U?s;M`o2?xOybMB)~Bdy~F!1 zp+EG0uU^4Cqz_uaEJ1EMW)UF8#TLAY29fk&$mUqQ1)C8z7%Ld|)5CwwvVxq+VwN^e zks8ni0GY@!vq!pb>&OF^Gg+^@>CuXXir0c$C{U0qu507uf1{xxE>Qw^!ft^V3qB-n zp+WXL>GnS`R0aKmm@I@1Ta|-))zidVh2&cLc$m+VbL#t6f9Ftq$sp)-GJgq1%i#Oo z^<^3@K`zj&33m??q0qR~>uNf1(*qhxSo}dh;~M)+tZ)U)ABc$Q8+b@F45Z$ru&`G? z_|Ct+2a}l{oF9O49P}Q3$X>)jMV(o&p+yQ}6mGHr0|YY~xLOTx0PkVHr)S^GT^Px` zNpR~&s}j-F=DIu5twNbFN_hBfWDr%g=*jF5Tv}K65KYrCh%}G-wBQ|fJVQ$)-r$9P z`os^KoTxhpAVA3bl^;Dff??wHWyW(6A5mi*+eOX~dSK+~w~(XnVtB}F7M0qFp|)z=T#g737i@$zs{`nvfmb`T zt`<#`W?(XWL{zI^e)jSs@MY5M2K2zE5f~ogKBw6v5rMt5{wM2hV0_(i;Lf==4bA{A zu!1!$GMV3mxbW;YA#UTp23$7)FG-_|;KdyUQwXfyf4u%S(t04 zsA+ocb#6^~$z_BP;e-Iq=UcO2+95B|i9tmV+woh*sg1WVaXcS!)A`r@?F0A{9#T%m zk`_p$*;NVlFj(!Cf1l8BA>T9$Pv{7V0p-)|FT@QfoT<1R9cc!n*;;79)Y7Q?u+6HE z@c_oArC<8}w`O@COy>G3x`1W7?^99z%G_7Rz5_qDNcXW#DE@fVkFJ`s2mWZ{hzq2e zn*8pk_QfB|FRo@EkPu+P)erdb(wWbGggVURsQ-rV`myBIPuA>-FH`)&h%7GzHXXC? zx0X^Mk8Wmjb*wYXcep}^_=Mau!D7`lE+_F8XMnt0_~gUA2I9*k?6_he7zVg^|D+jt zO8W2EB&mH4ah zb}N4iHw<85p4n?s@Z!4luHe zi12T~hnqo|LLG%!>WtnwTWd%hLSAp%e9O59XgTT~2{uJ^syPK44_*43)=S?J{(@4638%zhzyWOKbP)6GCFBBB5)@~M3IbP zuy$C$@NENKSCNYavU<`g#7q;q|gR1C|1|>kMkh31m32LUq zHc^{0>@^7U_5efursi#I5{3h zAx8KY{mf8u;S(e1ic}-IZ6t%xSv=_ik(v`AM(4;RZ|))lTX;$12w$@;md40 z>RcDNE!yW;Lyn>{hQJ=W$!$%&n{(6wC&nZYCEISewm+@1a?-VasGcB2 zlu!CF`G)tTz(&K%Af=-|`|-zEx!LW@5ht>BPqy(V`RHx?Tpy7eSz2<;&1+ zE8e%}p%e77vtymQ9A{OJGTtn4<^|J!8AD(@IMY){-rslz2Wg+rdxL_l%S9m9N((47 zzN>9%1YWc{FbBkk=r(MMWcZpJe{wTrcr*poa?;Ib2t2>NKoL{Ips3u`$4>vc9ox;G zHt&vXLp~7K(~OiA9|9gwB#3E(Q!MgkIBETP-<3=#RLD#ZtKo*C1!!ZqtE?7|g2rkC z1H)|KSqoh=ia{9m|LNaHCVBi!k13LtB0`Wn?re(DL@VAwxaP0^j%64b^6#^D+ zQZoy_I47gph~APvkJN(>?l0)@Y{WYC8X!iYgs*tMMS15)!h%s77iatyYh0$c&v&+@IaC9fL?g%FSj>h zkPx533K=&^vbMqZ3=5Nxw%Y{!?m@Az@lm-ov@?n_cY%tCJJ^m=#dGBqVLGe;a;M+N zYs+ZDnCPfNGNe{&%nS+(*Jl|^usqpTF9YFQ?)=Ip4#bxVwMON8?sOm?%$jkUE&K>q zB}bm}9^$IpueMxKls-pYKsjj#IeGu$(;aswW%Q5{m48 zhnJ_F@a#d1Bm2u*-0>>-)5V-Y9QoF%W=|^+aV)m&vu^9j+Au`OO#iJ(>d9 zc-R~f#c|_;*>RQ#Bd4;$z)CAXIJ$#7;L$+jpuqN%UwLD~AqC*uo!!&p5HwvNEBCtY z7r!S%;X*S`gVbbN&4B>l^ahCjNRlCz5ewIZu!`M&coAv6V1FHTF@Zq_B@1*QQ-M3W z74&-erbpgG#XYvgvJ<|=FwDr*mb1t5<>++|58AfE>PFT>FHvm;Rh|(StkQ~m-k43( zb(Bo(tnN^SH~71j^YAsF7|-SSL;8)vUa?ymjFuuxh!FqmXlyr=cKx&OD=S=%Q#ym( zzG>7yT2$A7%gI^)+<}MFm>xwt7F%fTja7n$d?(2k(s}vaOaFX09?JPS#yhOMM;REj z*2ZJD)?rURa0_WY=Ox;Z09ZY^hd_p6jzm6}*vpvlzJHWoBiBT7eHQKxYO}&yZh{0k z#IcEA@6br?+<5o-CvtiZvoM&XG!ms1MJF?YqE-?l?CJW3i$(q*7@HY`)Lgk}1|wV$ z66UnZP=T!%z=rJ4S(2%SA-bmp!^mJ8yvL&p&&D7jcb4!UFa1UPW^Zk)xQC^&@K@(y z7_Uic&Z$-epHUoGy#E9D-bC}r&DxNL4H+a8Jje+o14njA2PHxBWA?;hzdoH#|JW|j zC7e1aV)f-M56&b81EV=f679kC2UBJ&7I|H0=gbf;3+E0vZvX~yd13X-P~@$=dJsqt z%M>H+HT2PM9*u`x7A6d1%MK5#DyzO4L?gn)OxWU0zuB;jGpBwJd5{h>G?Zykl;vt# z$5aR_;xz-e{`Xg(O3mYH%iooWZAKuU#7E%vzU9Y{lNUn0gwq48N7@r@F`=qU+WfEf zGQb^k{qQL)(#&&ZMLMZJYZhmV7?1|CkMmXYopG3h+Evv;JLaF z&x?!~d}QE%D6z8T3OixCHk5Y32vwX^K$xs!{EoZhA=$4Z};4qNIC=2YP zHpFY^m>97YBtx2IMM!|7z0SVtak{1%Bs+t60s?PN%LHkxFaPc+N<85xaowe>f|yLL z*3@DRgKYZY%(Wb3tQ!O!bI5MR^od_Ol_fA7cQ7HxufKg|3rEZ&odSD^Ms5%a5U>mg zb&X)!DV>63I38C0_??9c{wGI+L)POk&c{>WB}-E0zA z%O1$5fp$D~0}O#+(2BIC{$t^ZTy~iesH?+h1ehdrQ3Hj8%b^iKqEL33rrV*hLG(Q^ zhq)eFHcZt;$KExKX(E{_Zfz+71Zj{3vMBb94;o06_(nuZDfyW4uPK(An%{xIXo+*b423>;tD3fNQA&3Pg;qW-(glr!F&gJD87GkrT_dM)u+Pry#bn~hc=eF;R`T1}z%jE&q zC2=mw=XH>c)ru;_fGH3|Fz^r<9}Ix3QHOK{a5Vg=|2>DcuiN{fCIJTpQO?zbJ5(w9 zVpW&l!Xq6wT@)JpOixR=6#2)jU(o#VLEXp_5TQCC4Iwg~sB#o3pcl$+`EF(QGB2v4 zBE>4(bhIFvb@jw-^=>B5yIQ@;~Z5kQddX7QSFlKRz219dg3Hu5ZFAX!J;)UQ{(pNN}a z7Zqi02ZeDU)tp47ai8d@*MHhCA{~`tz4DW>oj}|TPy25?C(6BvK9B^E;CRlJx2h5F;%^)njGv)mcH{n2}T0r zKOMN#xCzo!xV!oJmrtTwEwOHyh@4ZZZpPtZ?BcS|)n7^fGmpaItB;~23=$m1RV{>e zkE^{fI0AY@o4;}6dog%fT0AkqD?0dhX69ZPJn;w~ZiYR^)1D9_Od?vwR~AkKJVs_M zBuKcWKtLOC1JjWi|`X6idz z)L;4NPstXOYLP52Iv(cfcKa#6s&_3gV13 z_-4S!X8CEC-agDZWr#PN^!p=eh`cqESmE(R4`hxWS#^SN*k|UL2bo~X4cBC-gTw-t z>d*p_ijpaoy-u6(=2i_Z zrJboP7B-7zfxs0Y*zl91x1EBQ#=-ZKegK2D;n=utJqri;i;M)In0)rAmlNyS(-$90 zX>ra>bf$vF1HaF%tHWWrj&?@k#l5hCWBMJWk=6J_Zo;nDqU>0A!BGn#FpI0mFf1U3 z-?;I)6DCpZmx4-z)WJaZPq&H#m(1a=10v>QVZaDE3sWg3E84dGbt8z? z@TmU-=FxFV%DGK9$6ATqz=PJ@doTtq;`W-SOB)8i3sF<#x4yup&AXr1)7I92GYYEwn_^ekEBnN%^VQ45?M$f_NJ% zi2-DypeA48O1V~;Z;K{eaRJRY$XFfnPmH5*`hxxe?CDx>R#gRgG-qa28P4KZ&`#B7 z0SH0xD6!0z=O`GXdN*oH!uEFH{E($@b+ z+?&8xU0iSDXd_k>R8&+{yrP0qjqD1pVMz_L83HO+V-gYwOM(euQ>ja(E)^A(wp6L2 zbr%)43WzOlMMb48R$8^vij`JWto$lgTJirp&zYI;d~a?D==*#B_tQ`1=FXg%IdkUh zbH*oW!fE6M4i9lN@kSc{LR-soE`NgpKDDxDCqc&&xhW5EHC(lolYmNGF+%_du=vp# zYBz1Ko9H^iEf$|vCk|s2K?vaPs^OqM=mduR;1Ay4NUJ&S6hca(UMND!J@*k+l2kJpi3Ehi7xaNB57;;7b-cGw(>x<$@XM z{EHEHn048+XQMH*TbTC3{RL_xhgQl*(a1J4S6*P2l4abKM1zL3-uA5lkC2$;5Rt}W zGjPd}11JO%MH<78uzO(Akmnrt`x_ZMPSRV;O8XBV$=w$V7rDN5ZAm%9IDQ#QM1lE- zW62nd7waNeM>c3pgpDblHuK2eZg>J;eA)?wGZdAv=6;lJqW)9~jJp@I?8|V-Dg3>N zIk8{?;Ol0_!V^)89Ml=vzYmxJ>0Vut^>e9U^&i(zGhqbmQP+uyF@^O5QJo3iljz^W zYT%p1AA2nqmDGUBVseYWd%Yjait3>Sg9O_FDb@pdGaHZ=CsRSoxMB0m=aZwKSJ%A zI(yAcd*hEn@Q|_^q#`r_yG8%JN03nm3vy=bVVAZ=gUA^>!kk_BBR+!C^2>LIP_vUt zB{wq=;D7AL7T-hdff$jj#VHBlC3x4>1E%ldvPz$G_<{r7@B=FAa@@iyl z&%kQ9uPI=9AR@#-mea%fjHdsUU&o0bK8n9KHL|7be`Qp z#jtoE0Dt30$Xa+z!FhY}3r3!ZgI{CGt@sfXP<7s0N032-_>$UGloRl5(%Z-b3Gtu{ zan3X8u76Ndv|rOD)i^Fk#`IQf`SAtZ%aj5h+<|O_b35nsva%kbQShzEQitmYyviIh zNawbjT@Z{WyDd5>wYMts<{H}CX>FcE;h7IG1ltlf;=G!@AEo^#8f-G}z#EfD4Czj6 zxZB<6CzrvN78`GFI!X&>W6#q;9Mn!L@D*4FCQ{PL3yZS}D0btmPgcJ)9ABI_(3m36 zw$@_Fep9{rgmKeYa zbBqD9lu~3==}7I$U+y-IT-ujHaAGb7?iMh)7I(&IglTFDmV;;|i`&ln_6xLcatvw( z8?b%@%tV-k59@esdpmO@)wrCUVf*W%7maZSq?36ebCy2Hi4$cHkMDZaM&9Pg34{wk zhQ1sjl5M0OmlsTH(xrp*5}pvfV9#ToA)=A~%G{9T_pgm;)LW=}<-n;w0#FgRr5a%D zRFgX%g!sVQGlQ|c>mtN<59dws;Rrm_-j z9rTyf!YEaVUr|^8UCu3@GWYf4THlVzmC&;%&z%#hNPl)_0M07LV)~U;W+IW|L})(D z8IgH|Z_F4bLrCoewh@@DF9m#h!Q8bHc`h+I?xamfnNg{p+$l@3!St{_sAtx9&)0u> z$=AkY!^FzsB%7TX3d2-xw5hjp+igiVwSO-FKEk; zRQyh>-tKlZzL?}nwVwg%5QTR0K2CLlm#5XHzfRySi9)3v6TLX~aKFT(p*Kc=%GL4E z+q>+^#*_(z%YX@QVL(K$p7j2KRCOYCgAN#CLo(AsL_^9CKLd?MU=QWS-ibR zuIa2j>K~o<;dDTIMD2KIF$x=-w|;fi5%`fBP>#yj28_MXh95y3`rL|Ls0|K-I#O3^ zx)3KX&7jjEOW$L_WP~!W@p}m*R|v{EmBvGAxB7u&2#NRRDof)ZfvWENe!O-+{8)^O zi7W=eTUtpIH{9so?s_r94o-r5ayJ!9K`H!*k9hN-oBw%r4}O7yRa=iZE`_cfXm&op z103^733q1GB|w(k_SY2z;&ebselHftLs(x6bq!tNp2)Z7^24;lbC(U@a58`t`hk+@!sXb6O%p?a*>*(LE{3-J@V}D)Oa!OwL2@&ul8}*EW)d|=P1R?ZcuaLG z9UgGNq89p{&T6(+I1DBu6nBUmWwul3!kA@rP=~S2u6G{K!n118xS=HJvK zjy#BR&P@Sp2Vcf{p{}c&MBb{$-zMYFD(d4d!mzHK<$*AD9YLiIM^+XO%mxN+5~no< z3m$da<<>VFq8o_ns%E&h=a+XXP0_i(o139{Ie^Vj z%`~U~%g+xBV(nwwmQlqikh~v*sFSs_V$5R@uf)j`!G-9eO~Hs4l`s$}nfLUx!^wwp z12KS}MlmpU11zEZsJhc=;PhkRP>E%~E%+lof;_+1Sz|im%Sd1M-|Z<=Qef0fg5Cx4 zN?;4}_;ugen}%bG5)*71F|0oG?JG#GOs&#Q0h?wF4Gf^7j3*|A=^3D03Mh+X8?dU$ z`=859gk(gj=F&n4mj7rT68ei6%CFn>+!^>H#6X*uyHRy+-8$i!0gK0`;M#nkEM3Dv z#z#z9*qX&Jeefqn(FPgZX*XMRkj?6A0rLi zwN12CS5=Pb2V^M(1`3-u{_cvN_~NV7|d$!UA&uN)_KaU-vsaArqZ6oGgUC{xK3*ht2Mnj#~?FkG~};|J?WC%I|c z>4p&;D)0_^OiKU;C-kBpzW>lg0M_5G1TfJFkOxWOU3n~0;+DWJef9}D_eRJfL4=125x6l@G?f~Vvp@JU z&=I8ub7PQ_yK(PR{>a-X$#L1fs31;V`ckrV@kAelcb66YTORt&SAz9zoM>tF^HqK5 zeE3=i-$#pe3;+Zl;ri*K_CHPwza3=&X^65S#{cuFWitW!%rZDO5l%7}l2X*-jp7MV zh{?Sn0*-)_Y7}k(LNI=WF1b@O-c{1XmA^73Zc!~*u*OEnh2cF&?utMI?taq`e@~Uu z9=~9CQQ8iaQy7!PM>3`RQ&;|nyrcwb zRPx$2O5IeMPLixqeXP@hzn^gWk@%h>SqqY2p%w5x@FXT=AuVQ8@o#@*SxjF)Uyr!) zmaGBg z|NH6X)Bm3h+6dV_mEo}Hrthyi_y6)FVkB#&n{L14wWe3vJT-q)g!kWt1xF8hwcT}1 zuJnI@5{)+ilfg&Aa@6ue`i{q!uS@MahM{<4G`3Oy+?NWTr*)cQdy$SA6x%Ef1y&By ztSBg4jzm2kiu4>ih$tUHr73C2usU*WUV7q09=X(@iT(~9bc+06N>ymAfq{=Z1c?O3N@(Rlohg-(8)#27?t?VIUV`fIjIx6ZAB>*WiTMC`Y0g1-%f&z!PY? zAsD$Ly~~uEk$d=Q`<1gXAVVjWR>5P{FD`n4&UNJH$mC7=B)y;5Wq`6IxQS(%u1-+7 zNCIH+Bf6S-Ld9Gv&N+el$U{z`*}!lCVBIO@Yeo^W-#;Az%%7OXH6@W0)pFWZHa&Rl zT$G>!TAnhkpd6;BA4ZbaQheH9e?YB;jwx%|2+`Q#rail*stqxXh5eK*BR;=v;=(5x zK>KFY1^rgkU=Q=zO&6-SIP#_?n`S5mL7fsc_-Hs2V>}P5&u=r9=V2gI&$w^l93W`# zHW)u!QEF1mR9A?P%4 zI`*yU*;IE{%ubTF39b5E!!1@LdY^^;vx)uKkAaw|QYcw(>-%YXCC zrtJD3YKia0lvSL|7bPQ{hp;D)Phdg&zi+sH~-+%uaA;k<>5S$2FB!PI%g;e%%; zO2Qz+LX3|IKCk%>@j@_ak?e_tX9nBKZA~p~-C*1?2?R|bgfn9h&bYomKYGmLf zTI?E)C776t$Yd7y0N{LC`N4fvT=xqiFIgaYj zX?z?>!3`D`fq~TKEg$~dp#YZi-f0lkP2648b^yJ+VUDyBV1w1haCRIHd%rkeZsS5S z1-W<2;(gF2X6KJpTCO#q+6A9MFjbYvqvgXFz4C?wt{>3Uj7cBt3M~M&hxyMLGHIJw z0KfnZh~>BX=Q{)C3iVMla&PbepNgQvY%h3DH)6IQWR=p zJ+~PqFYxA;UEZhboPJPOgUV<68mkpZ0>!cIUes{LF#)LM`Vk#`LtyO0+HyqBYy<-g zGysQ-_bJ=VJUgG^=k+lLblI66i0sOQ98{e*w|-?BV;$S?4DT;ol6=o5!2zZ_zQ>{E zWcLG6J{AfOTKQak>{HpqX=O8@9R{VABO)IAU~$iVZbqjZZXIlng|2h}2rjpy2dl@% z>cv=)+W%c|4<-pUc;!a}zoH|{`}s)Qs@&#f3a+IzUkR%oz@V;dZSmIwsICmHMZO@e zcR&>h)pm=J5{Z<;89qDu} zyhqtY0ETN!lvT&Dnc1hsX4>zG^T&@&fI!Imd^E!|=7l;2WpUMiw`W8LPMZits=fbv zaKeysbaEbS@$gT{;eB3c^8SG%?vh05C11&&DustX%On?V*v8&xDfAF;n3FKcmhmJc zp404PCy}-^l7*Z4|0l!g0?~;3Sin=+E%N4t0F-1)`5rHfVt&pr^QAjCCA5vm85lW$ z-NRANP!z|8I2bgn;lIrDbjOfRPFD|9$5&fdRss2m5HGJKz~Swa%bYT3SO0(-Fg!a( z0B6L!>$#5{e+ItbErkZXC5?n#T@9qd7|t9MgFzT&sGveuHM5Wr1&be~mQ`BS@~o}Q z(JZ#tTzD|Lcb!xYPY3fCFU-COUp(t`>LFQX6yk2? z`ie6lY)p3L{-*=+lK1n!8uc+Zcp%uJlyNnjSk0GdsHbDKstrPdhonK;ohKg@BZX7g z8-c*1E^Ctt0M5{}=;fP_S2`4dmz9nzDJ(sywBHPn?yx$R>lwla&iV}-!jmITRynrX zirxTyjrA}9g;-htmvbLK0VAmJt3OJC^reE(TM|!BGSalTlU5C*vY_Q6Ef%l#82`yt zOBqxbokcnL;|;K7g2m1H>AmwALh~EOr8GJjS(N6<+kn8ywQ$X=i|FLi%)G4#yC+%) zSeYIy@>f|1(v(|naDPPTYHJ3K2xf-v4w(8xsRs_p=N52#xw>M2S(-)8uqPl*m_&jz z<}!FWH{cusb<0Obzs@)>6gQY=KA+36MK0I-1)ORo2Gn++rPudBCwC%~jC#-bWdBev zAp{GR04=USEk_1!@BQSjH&T2w0%E$9*MwYm31Gb*?G;Zm^56Z>LTbn^bA{@=F9YpG zp4i#M%CTjrV3qnE6p*f~z2@YrUK`BF{%lODi z$}mk>lQNodWctfLST zMf~}p=IV27uml;tPYp zed|1WEDd5&KE05H$zfz{Ov?wR4y3(b5c&2v^G1by>};`^83ZUO4%FvYj0K1CPVw3+ zC#e;YZa6D`ey(ZiqGG=1S^UZUB^#;v?=qmbR&< z=VX`DJ1Jz!nQG>d+XAC(|*M6748P&}W>FYp!5QF6EAW>aa)NZY z>^))BSh|#A`b%nGF*;7qYgrYqMTRn0mO;W33FnGEWeG%P^e8=pC4E4@5^ zqjdH|HFwZ=PA5(j#o`2s(_~JWJD8%;%{XJ@@%hSt{6mct7#WptQqPJVbO)jN1 z*I&PqYFzeUg>_}R!I$tt8;%mP%Khg2vpX<&fQH`w-xw4h@#oD4opvSt1b!0EDz8@# z2QPEUY9TX{oEFo}g%LL)3u5z%nfVTJzED?!-wSY9>#FM~XlM=&mbv1CGhQOg98xyH z*Y8jQf^!n#0D>u`luY|mj_MtygH3J*zYNFGp0?_`t{ZucUmEnO0EA2)-3iOOZb#6g6Fy>8{puF&!ke-qoTVJh2M5cFSdr<9$3})mT2Q%U z5YFBK2mjvi`IavkUiUcVQhh?7= zU-Q#$zhE%d`xL42lmzQ0Qe16Ntu^Hcx3?QmFacH_Q%hu^i_H!SlK?xJckq{QGhF9a zEOz1!i*dm;M-kWD{PUysqekUBvCv_JXj-YL9X}an31?>;fvc>ztmt2Q_kkI2@6o08 z(7BW7(()UN{8lBzA)bbZ7jVcV9z<>0T^Sx|FT-Wk`kqguTw2&xbA+*(pFtK;sxxF? z%Lg``z{_mj#&FxlQNC^=K0xElpxLy%`X4V*v+=;w3-GTBnuXx2!#h_b6+iRy*?GmUKDnBAZJNdGe_~^aIUtdE$>I5m%0_&=!3fi0DS}%-U5Eeoe z^brFE#2R1s#-EJlAx5oZ;#ek5@X-neJv;N;&8&b2fuUxoD&m~(iOJ*^lw<7B7(nu_ zCW%x8*8Z~ctl}nAYkzl;ay(oTTT;;GmZ4=QkuvLgX3QS zmpFgiIS0MLqoOlGITgHKwx)arl zBB^?a-HrrJLWeCR+Ygst#kyvlSN*$ubn-F-W1}H&LpWJS(x6V@saM;@wZnlk^`=&* zUmtfi!J^Y>>JHXQeL@hq57@DNdb2{jSKC?IiKm1bmXc8-&=vns~UE+v|-y$UwA03oPahQKctL< z{DR^`ziBmX$cYVb@yVQW=9p&41fR2JO=Kd5Kl6MXm*+#iekgk?GR_KP@~Vi86V-*x z4}JL&Q*QZmukMT&cwiRoCpZ+GgT#jD2lBzj5C{h0O;nDKbT3)nd?MbraM!daqUZ@& zr@NmeE2q_7kNk@!ydPv*wJs`%e8gbRkp*_w+C&z3n!W(5EEtsq# zQ1ps9fTuyscoLQM;11HcKqL!@g64srb%p>O9}Twr&R&;(4`2MoaBA*hwWi;l5*jQQ znVK08VG=;k_z;B{1?=40S~eIAik2sT?{P+x{9rgdWp#D9l>|EQuPTbl>KzT5wBnHp zP5>NDndj~g7|J=#L2ZhF41K8Qa|dG(kMp3Z04b~|;26F&esIEV?<;}Bl!?Sus;jn1{=NI%_ioo{`ovXKt5tFu8>0YE>;;y&0p+&&NH94CwrIgL_9cfcAasP6+)@G-*jorX%qqo|u-Ckp`5|ROD&7Pl^kxM2&a=WSE~bSDH{)L)yqf+}PZt5PI&=i# z%@rX^w>{Oy@4b(DxI?Z3zUic&SCw#PR7XwYVIO}n#f13w3bUDeh<%WU1C|5^=cOXR zV)+cjJU8s}i}2+f`Wm3PAy`2A_wATQr_te-w?K0cN#M>Q@jy8jNH1wHS;v$>+f3(c z=L5Eozq3}~SwPXL!P2`ug{NNPf>l~j@K?9jHAIlqm@(LVn|ZhVnrz;E;63RI)B7a8vkz7h&BV_aSY zVht#BZbl5Ktswu>QPoBk-@WhOiX)(jS`N06{XV2)c(J3MXNrNxz+@nMFf4>6fZ`*x z%>M63m&EFmo$LTKxKpKi!o%y53NYPG^wGFgSF9 zqlr^Y3U)GDB8PDy@HDS|$D2+}fP~j4PO#Bpw8;c++E_%l+&`j(MnzJA{BTRR1J^%{_?< zdIbd!^xypti=K-;3XR!34KK>ZiPbo;BUp<*`X?NekI_e9vO8yqv;+E6pls+L3Ec_ zf;pm+qRdPe+)0?S^e&wmo=S=VO4)x z=lqn~S|n)c${YL;)nH9Cf(yPexr;9ky8ai>kS-bQl31|lzOT1Uy9p@*)lz88g;82k zoX#%{vI(>sqDAr;0IrVbX#=OdD+AV?6_^*Cbl$%kWKblt7})ozdkGx_7`V^+KmMO* zcvafPwy|8kl#8w9mPO_!xKho88bqM7re)8N>C9J54j)ll z{&TOs!x)Yi$SR+oRvk14xX_h1L(~BmWb7hLCr)+w5F93Q;~^RQ!uB7vp{8xBgbHR= zR#hRb$*-Q%z(=aVtht>k=(aQ40jh=xWgkWYnNMW0sMZfCDxt(A79;;^f)JXrkjFDDj4_chTkA8#f@nI50 z{SYNs7EuGLovg-PF3Io2e08f(>7EmF9vkAsD#LP4K>m_v7fxkmk5psYeU`x!+Uuxp z?te%vFM_3HO?fQ}Ad0U8Gpq?Zz8-~roM1tQ#KZ<3D;^9erqZ7DC_H_=L?uUXVJL+2 zA(m&a+Wf#77zDqY-4VBlt`zuCQ;0bk`wL%XjLiARgmM}q@@oKMvNBt6xS@bH{AdPS zpZMpWDa#$U3y^vWR5Jq={}~Z*KX62EF07`sKYgumeGS96I$d`dDldU%P&lnsDMJeV zfm6Kfj5m3F9*TEeq~AReL9QKhS0%}5xT`PgQB;Ay@gp{rzdY|*+DzxwHi+Z0*gvd? za-4H>|M=2!>4!O36P4yCfS^#(s-x~a=2Cnq3V8+QMqqtv=#}8Ts4k^HIo5(8KphL{ zdQ)1`h5IXM>Th%nV;G-mKCDRwyV^`E|mi{gkN`0eBdy6ypj67y@<)wg+-|Tt)T<#k!2cxhsNca9V;X8m z6i5iec?9T;U|zjnfMHj|IutuJ3X|XuqgQ2~*qdL54H+_QXes|wFm%8Opc$%7-Sp3H z*&ja^3?9s1h8GPUSX?@6cuCQ)p_F}S1PGh29`Y};fO85;`kxv7svlRrdE>YFW51C_ zg9oI31o%~#|FHN#f(c`%N6;MBul&=)ZCxYGtaNbE(1E3egA2Z^8E_7;-ZxZqIYI{p zs1^%SJ**O_2QL4PbWD^25I!17Uq5b*AezWtQO(*GUx=tohWg=k(>%;+tzFPC) zwZCJyqX^H7S-MlI9IxA28!K=S$1(4_mlPi`G+U^qKR5{?hbXH5(ogsGhP&nPuvPtlW$IEyn<2Ms3Fp`HB@` z`p3A9_QR2@#Z&psjL@Xfaxv5PUA}Yh5PT_st6OmabnwO*?2#!tB}u6UCQRua z37VL|i`CZ4flu+HZG8&=P3LTCrrA_r8eHP*^=QxxVL)uXz(9M4y`kVSP_1| z7QVOz(U8Vn>Ti9N5!rw)&Gu`7Pa#Ts(X#cVy%kfodK%lIiUd1yPDOhLu}YhS{DXM3 z5F;kck5;hcj8Rw0S6JHsCd;;Rz4{wYrol^n;DL8q7nSsAyWDw8(Wppt;SJLtQ?PZ% zSKzjrAX)S??Q9b(+c@awW2ssVE9dnJ@92ZoL1Ygc-;e((j-B|48QnoKWe4 z;f;G{EsHUZ>YIN-x!j+uH^fxG-YLwYDnRAN3@*NS)*qfp`=t$XTnDUdiBE>bfAD93f9H*s3Fbo>^4|$7`gI| z>;z3FfAZ)bQS@c_xd60dRw|X!wOcF2XtUq|l z^-J)jgp$EUJDJ}gn}i0qVS;tMkddcx>s2Jy{gCV^JarRcn_x$R?n>JRtC3rxLatWi zrQq5M@7-bm@5~MC$|VkA)9qCuHwHzKO~dC+8C99%m`mYeU>f$j;t#v`#+MH zxQ9$#)1iY82`O7xuC|wDwG{`0BEx8nJa!HBcx6KzY)P-4w@`fUh>f>X&^dj4q%(K8 zrd@yf6q>aOcX9+tm*R$9t||rarqLrjK|nq3?~qY1io+51JC$%?%3Dp0)76zc3WjSZ5bW z$MSb4_X<3(W_sO|Obtm$TXvWEP%L}Hj!3F@Mpx%4WX&t66HA8Qy^hp1Ft^r6$E@=- z;FK9B7rFU*+cXv83M7pldZKX9I|vwT_R}|yp`x@CgC%NwkiYhVhD5UW5Zff|900y_ z+J3JNd9(muP{ts8j^@{5+zXtUoWzTEV`Nr)jkm)o8oL1oKLWbqw%Sz#@P#Z-9_O9( zesn8M)O7<2xBi0%^qF)CzL7QSB0c$-#`bPL#c-JR!iN{=G4z)$P@QHhJoEfh@bpX7%<%{M!MRu>&I99js9YrXN3Mpj)Jcvn_Aw`Tm-`78HpH| zkBM`;V|pj9sLmR{=-A1a#Lh`#zP{=b%)g#mi0V6_(zY+p`h-_h<&CPx-RQFEj7DJ5 zyydTi3X63GS20TtQTxGvLa8RcV~DfEw7wXLk>(=hchw)~oHzcP561Lt82NaNiH|gr z7ABQ(N*LO9o>~fj#=}$kNNXCsvT$h+d@W$|9(4^7ogS)yuI_ba=SE?c7$CYQOdSvy zXY+n?OuG*F>LOdz@)2^v!7?q$SyE0?ZtFU#n3A$_W7m%44FwU27t{r5CE!SizC%_@ z2~MSpZ@8=@0*4Xz;HK+0(1+txMkuHVTEd|L^+1dQfTF(IL$?fc-uA^|ud*{RMgw3c zIzzcyJ9ze;XP`5|M{FfBODxZ#x#?(ETJE~y2J&?tIK{NlSLq5n1_QW&a2UVz`bX## z1EbpMS)iK%Y1iQo6=Z)}7Ptwne(&HM2*LsBFuk?~iHzJ$#T2bR(oP^4%h@uf?cNMm z*i2|j%15-rvk^{u-`HY~X2M-cazb}*CaVVi@locJBvKc4fPb}n2PjT6S^4v|n+aqn zgp8h~e(-(~`3v>z=lXKomH5pS|(ZD28 zmq+3&4a@@h*|49%c~DNqF+X`l z?dBGtp^_DXPJ@1b^c3D_w!z>kGT%DV_2KR!H|5P79Nl_)An_5C>VCl4w;hi!zEkv4 zMUs3hDFRX7-JN`OhPR?{;Ory&htASpv^aUk$#VVUO0J+Md~}JD~ke&wmns|82Jbmnd zS-t~K-VRA=Dj6f4DOg4#Rj=+lhhfzSn6n3yQ@kVKG_&QK`hA?x8-<;?G6i56J>L>G z*t9qd06h#jl>TU7CiqBODtxrxqm-z&o8M?dN{jr14Mqpa(#-EQ4z)z6Pw4UlacPsl z2DZI>-#ZKOjT}};)^vkSAMt}!8Sks_4_nMu-7vTzQfsTu+YCdI=1mWpp(#dFZ z<_(YWWHiRXcC{5KpGYZk{vmqlCgP$ez+eWj30IwPQ~jW?0Tc%j6Ke`IfrAvD{=rXP z=Z%vRp0#?g$$CB~%S?FaHPB$~D(enQJOG74+^S^P4Wj{cK&W{WaPCfxU7dHTlc}9l zqOeQ4b_oK;+@YLLMDD5UQeFv5UNtk(62S2>oS%F3e@VA&#&^^LNxZ|E^EAFkl^=Z{HBE(6ai&Pu?g3(BZ-V@C~5? z7C$;az0dl0PtJI_4@P}M*y86cFJAtclje?z7;_~fks~I!Et;kXn<0$*Ru`Z3PA#X4 zBq=3XIvtA!rX~v&%wK&*87Pak`0G1S$~KPfm|9k#Pw}>}w zQcYrD2Sd8S_U0;wN!8M$|Hf$HKwkZI8ad-7E-@h?A^O7_ri20_R5L=h1NI%a^Pp<# zA{s9Lmh&dW_Qp0ewfXq7>xia-PD?er+!2YVBIBF>oHPRzT=MCD&z^o3fEFb+G?{fQ z!y!f5&b~;hv2GVOW_(fc)Z$ClpZhR?7l@|m8;VXFkl-~yTRo9v>LOc0W>4})%?=D& zDkoIL*$tA}Y9?sm97(Pm+A%Xb$z-t0F~f8*c}UEbr|z@l2lx_6mMpQIST{j-M}A7@ z3`}mEV8L`wN({)ow{s{}686duI*q)1yOT=m5>&f*&0Pq^c+NXTgg7J0xiWTgLNYU&!aL~@~q!pbWC%d63p6bf#pBz|7f!#x!tvgSm9J# zGvT~($Ayt%CBjY3lV3#VXpY@S=Pr+*{!?1YwzlncjtEVi;V@cx!QCV1x@xj>Dt?p( zfNq!pfYcS9u$Z2>2)meff*HBzEpSs_;@EJ{%ZCRbIhJ7_Rg4H#yKvjG?iabn zroK}uQdsyDuHfep20>4WV(%1w_9&s>xrAKKa`C-o=oLE2!n(&k?@3uWgqgY|ln4~; zV6VL!lQwV0RG(j~DVfu`G0}y(R4@fsY-{mPim3t47BFa$4A?P&V(97q$g|fGJ=v-Q z5=Y?D&?Q-8n=}Jb>3|xLzKk6bj1pg4=KoK%%xW&sF?VC2DYGDmZuywE!;&SnSMG84 zenbZ|t>7p^6*H(nPH1`WaA1ryB*9N7O`f9aqfVrtH|Z0&E&t_$vGf2&XmnZQxlQ=sh;7c~L)^kCuRUag2(INVg{+gDFP-<yYf=?lW;T7M!b6xeO&{()BZj%Ye~3_mo*bqn=P;YD}Fjm{X{7 zzk*a{NmkguiYMBgY=)WWT?5^k(}=EAPJ%Cx*L_&_Q)VW@YHO$|Lu4o-?tlV3AE#Ss zSc{VcN}YjB@_wL7TuGC%CjTQHD-v+9HFV7}ZCN9<>T*FUaYM)9s?oUu|3GJYz37-w z!chC4|MuHA3j=1MghL)p`v0#9Vp^Ca_IO?Eec$IEN7M#?qf5+96H8b;J%W}Zaql%x zGRea>(X06`bIADujE5$I(p+&M0N4OV6LM^&s%A+FuK(7lmoepUq`SdsNQ|#<>oSRd z`vo(iSEK&%`x*J(u^Fkv5{o6MaEbMCG_w5(VlbT4%`XkRjtMhBnBPa{)l|bMMmb-v z72$zSP$G&<;eH9@ zB8Lc0rE=BQY4_K@cIXY}w>}p+Hm)3I^P7(uLy#8!Yus)G^O9^_^~=s{lAPL$$}BO2AKwySbPIQuh_(|-84Us59`X(>R3 zTLf?k;{aJt{n5^@q_G&tivBk(XEuuxOkiq)m>70RUhcD7l(Gi8mX7fEW&t%keEds9uhoIE}8*k7Rk^LJoH-bq7$m7F$7Y`g<(s}lC)9h4Mb`zU4GDSFlV?x=@`L& zLS7-K)Y=Fb1n#z7C)~)?7+D-K`wF}uCnP6rV+|KT@X>CL|Mx?K89*yT{V?gs9Ya$Qa*proW}`7a>il5O zeMYdeaM47kGMtr)U@EIGI)ksRyJgchZc*8^GN9jaLw6sZ`9mNlok1LzDNGqn#JLB8 zVyg3BxMmHV(?b#3L`g({fyMErvT_hHbVN(T1c-|fQ{l#TFYVQXjCw3ngrOKQWdao) zkYrOgmj5Cp;9@+jPh=j$fT*Zwzy$(UZia2ZY-HT2H4OwSdGN6d4yC=6Yyh^(Z$XNn zvbMn%V(8e~YVdlp`4o__k=uh}>`*sf+r9t${dm8hM%ScjxGkV?CaNEI4bxq`@Ynyg zd|#AQNq!Z^T{s6pVr5TwRW71)0zsI1=H5%D9Ey?p>v{2@MFMs7Az11KmnRlce9z(g z9E~sT#eoL9rx2Y5Nv?#oXf5+9+qB+4>Eum+x_88W)ac4CKb(C48sXshBs`{y>s|$0 zY52ugAGnOM=C~0!BJ1ko7CBRUaP>*rFdE^b1#X*j&+S|wQHl5~b zTo*s)488QHyors4F=XW5vogjr#yCdq$L4=k3x4-)w zNzS1RQO$`;3B&F?lEdZojUCpv*$0h0>dXaE3QrI2r_UVo*}=S@?tgS+`8KxdnJ?ad z?ICD196r_R>DV@DI1n~;w$|g2w{~>GCei&!s6EfnIZjQj~ z;@=DyM7(BABYVSaI=!nZ*RpYcW}A-jamq&g7L8%ZwEFfLOO8cjxpl67* zHR8n~+VZ2#ShTV2Roo2ZwjiC+C2acjyAm?i7~4JT)eDM@X;@><&T$U8Jq&9}FX4aa zSrgilt~tGMT#}p(o5rkO)6Q&++@h!R%vstN`dG=fi z_RvRav0$LG@Zhyabjr~wG_&h4fdOl?=B@4g41I5YIy9Hed1G(4()$6{Xk@YLq@(vG zSy*)qIAx4o!>a`8O@$+gA1F1*u^H>!ZpiBPR3lHoHOoz!T!74WX`@c_+Un49dIjE=a%r%4qok%(%JO9f+A4G}Z z8+(-$x*1oW>VRgLE2!_@e_gdNN!E8_4M{2{i9)B4QW#3qSGekiZ5edZ0)a2D&4|$@ zZh9n>L8BS{!mb&LHa5=k{IVpK>d9Nj;2aXmO%MSf-S^w=x70`dG^54H!f=I>jP1Z) zG^R`PLMMDQ?dy)+vzn}3&v03~k&C{vgVZS37>SRg+ zhpn3y>!^_z?t?GBk!Sz52$q}l+Atb zxO2&8d{4|*;1f3>BqB1lG?)Bq<~>OHn%%ftsc!_5o_)~pEr+2|z^nNzsY2+nO`q=tL&N+20%wbSaA= zrkrl6;h{wgy6@XPPjHZd6W!Uv0bw4U($erxOX8ybm?@5JO|7! zO3D}EY@YkAf01yq`HA6$IXBp`dB5NPnS>TRTnI<<{A^mdw{+7jQgjjIhj?J?#)b{p zx;WIVb)%P)xj2=tiN?lT?Dns(LW55DXnI?hy>MOzU%ZwPTsA>bwQON2YAwtDwHt|u z3r@xOfgBq?`D>g`p{PQa8~1;^pXxOMy~fugwHcdWW+6EUgr60JF z7T@$xt*JcbaAYUBCF~^y3k8_krNxVQTK)3}eMzpVQ)Px7w$V9Fm{I_kYOXMnobXtC7_CcbXhAE zM0qE&UUbUTnRIqVTUeVoXP?!>fze=YbYg<19prt}A0&M0Qf$8Q&LAK4flP%uAcbvneP%wXGnAz;gl zy0c_}d8MHe{>mA!oPn+&=N!xOg3i{2>0SBvgYP5}XhhEkTp$TrcJwMEj@qcB&Ymw+ zI~hTYMcW%R44pS=jNY$^k?MZEM=3{gr8jUPniU)1T#v7Tirmn+)xEQxiY29YM%pey zv*ZHs5sL5aS^psgO-#tSI*9GhJ9)qy+JhFR$BI?*z&sEJ5sH4BS6?lpYzQ`0y5@!r zyZP9S$B=ehBO)W$OWSA+h#Pu8t_pO-b9|g@t2ihZf<|kOdF06b@FfhZAb6J3rqy6~i=uHdu1cyX(i`_E=j)1lV2ml$u{!*`lJYf>jNaE}c zp~b$tk@9c4=f1d8+=NFt4TPPrmUjO*=wQ-&OdfiU!*RQm^0)Zo(muH~7r#4#xO(4H z4vbF_QIqmBU^ITz__P-D7UU2G=??yEwz+lWr5^8|l*uvZUWr2S26@fggF9w03>U^W zO?410t!i2Gh}Te@8;AS_>@3t!X9|Z>(hn}agjpRFX(Z-`34B1jS zVYunu+2uEWx_+uGGZzvY0Fi4IO|6<;(uw}4c+;{QYk2%0`L))#zV@+aJjv^5c=jG_ z2Mbj&c~HNjBR|r@mUsGTyZ-p%%7jzyg7)CJrjA@T5sn$huwB7No}n2iR!%HNA-lls zG^08B2fY}d;AkQuSMNxIJT=y#`EW+?eQx;Ev_tpejGVJTTt(9bKf7?=Jw0fpgddSX zM_WOcAAO_Z-ZNT}A!jwhF~J}45rK~G{-d+V&zwz0udpB`z8q7=)ws)DH!sVeG4}-v zx2hpIob}qoLC5ah$8Tb2%w5aT!8Q@A-um|WN9^H7P_zpF=10R9x2yk|7pWG?0vZ$5 z+KQ5c%!T}gOI|sg+N;;#v7JzV>|%*i_-BMe6AdMKF}L{R<0DbfP*YRMLjTOqQBy5& z{?MymGIwz#FOr6k96A&Mwx+dt+n(n!J0&8tB;{y3G$jNqUtHhXGqYR>AR7768oFG% z_t~^NqwNnmu{DBHyyDckBadW{#5hGI4n8TuXF5`WKK21*bb9);7k?NtVs0vSB6l}2 zH<0Y+Rpb5~o56u3VA2_6G@5nM2QTc0Z_aj;l4UMQd0#L*yhnYP9MFpVC^GO8CvY&8 zBTuFaQF=f|OTByEPx~`kc!&<+T!I5~Qd}_P`21T3T|`J<+ldXMEe1+z<2g1#71W5YHnI%I8H-;kG zTzmfS2Jn2vKq@>#0YM1s-G5!wkDd$P$osh>ZzkMVM0#2?QRmUmmedM+j<7@Lh%R#V z7IBnUy|jgb*z*qBH>}YbMa^{7gpjIJuh>Mao>PaHg&JW3f1$FKa33;G25zEi$Drw{ zpZsB028sow36yRRl5FnLT^{-kZSGOk^$hb*nKHTFRZ+3GJM{l$6F{S`@KPu#3?%AG zNmPnD6+gt$+aCG@^a-#}~CFlEdw)UyX?Soy8Trjj!>s|=Ph+lv$hSHb3U>-0FN z)-H=z7n`YsC?`x@AX0%`NfUQd8tDVTZ#s(>Gr&h%x$3S@kIUeTrVUc{x<~c`qy=o9 z(s>&bPI65jebaQt$ET7`%BgU(BIl(Z<+K2UA92e$J$D~Z<2GzsdcruF{ErEv}Upb zAVqg+Ia^E@E`-s0o%q*N@MZhfq_PM|r-bcZ3{5=g1`I^g;gZ7R$n@RWNF}vdBM{B_ zFD^ciL5qN(h8jZ$mdso<(0!behG}Ar9Z!AYP3HM|=8&~FuzA9XPtG3O(=&my+;q{}?&o{1lb820dzHgR?9{>&c5#&MlpZsQ4x zxr?Ek0Vt&J=+_o89yAm_Ilp0&+4vhjTHCzM#cOze`jZd3TXr~=dQHJnfyJUczd$D( zf&8ERqBr%Q5%>+&v03?)yix9skiX(bxNgp&k5SY0hYd3}%>yZ3P#$nVQ)kTfYs*jf z?_so;+AokTC#ToAoclCK=UvA?KRE~0C*DfoDH_(XTv5u@75%SQ=WK-F)hE2wJ+YK8 z6}YiA{kU_@8S7hd+7uX5Ye^ZkrNdnYLhzpwp;iz8yJ00-wdq$rarP?e0&!<@Ne3V* zK9~c7u#HWhJ^z)PsT_?&bjdS&H(L@7vR}aerheJ!W}b#78gWp@k(hP>GEG>sHowcl zUtdZtH2~n#@f7drz>oN-@x6U+7?nh7Jk#?U0Lk7r#LUD8d@Jay+i6f7QbNyBx{61|H%lEQe+oMp}2~tC=P{<>2lb zY1D*WZqYf*IZ|W!A#pmWP2t{sIlXLJG$VuBUNuK3-2MFZ31rvziG~hmu|Prk{huJ556cE2Q}N z;p)!o|5RkGH)^Mato>~948M9F4v&FGtz%x$q1VXGRHU4MwnLRz_)Yb-bCch?ysMOq z?439IGBN7cuxY&Nm7xd1!hs%xkUWf8!XYGd z>b-im)7YuMce8+NV&PnW3I6G>I>fvbolg7xUp|OYNAhEn+#MNLhXZZYJ}(`%7e?|? z81Io3{}}Xm|CJKSc4tj)D@;M^TD>$Qsx&Tj-PnC~n&bR&6$ddt9}JTmL32zv88Jo^F*RGX zCY~|JHQ&Ac&zxhCB_c(6PQd4!FE84Ik%~l8=*bA*h@dTjiQw>&L@)m7j8CY>jS4Tm zYxd#w%!qM@JB`o~4UldQf^RQ;_kyh?Af6n5x^WK(w{j@ZojvFR61*P-smvRDkn}k9 z3*JBI1%F0;XVI-nf@0F{G^p{cHMjQjVb?~D<(0A4YW!g9NfSNfm?sA4frK%QZFly( z-$M?{Uua3>MUXc=zvx3AGx2R*YJ=7l8YlD6G>Vs3DM#0{wZ_nG9+C)GKJmo)`vJ1I zC;>DMjo7BOxv6svy=B3o^llmKmih5x&5pSPMzn9|+>=CJ*SOl*B zD4wU3EK!qAM|SNFzAZ8kIxCo!(+M#k%c)Z{RCzN9smPPAF)=7;xBTA^Y@!j=pI2Dn z?%>@M9!yu7E>2Jd>$8vs-(40us-C@V@(^^U{hny~36KWx5>D6-Lx=_CzU+l5!q6?7 z!O3uDbKl#gHEC|R%az4dUAQbUAv{A|VcY{J5knwA#^erHMHku-?iQW)eD~e^p>qL% z%9f~h?=E$CnZ;NsI9}+{T!q9Tu2OFDFDDi$Uu~ zWym!ntkMMJI~lIRE3aQVM7N*Z_wnIqJlvPl(KSo%lV2VDJ+N4GJy;0@tbOh(CA&L8 zxu(*8m*K^Ag}YbJxz;T!h;kWeCoyKAj!Y@4%CbUX8<%U1J5 zkxC2=^x;H;gt^7qKS+9&mbE*DqT8yiyZxrnb#Bx}D?(V3r@9=a@;2bqBsYv91G zoCqlXM6LQx8#__Cx7w~SPemnTtKGvPEVRQhlIML(l?+U~Zd~u-=rpg4!#y|%y?;3t?trlF{J`)0m4-ZtQenb+ z=O+X9z&&BMH&hrI)i4Vx@Q%O_y6IjahN?oSM0n=eG2z4~SH#Aur{K!$6ppHTcMIBF zdg;aAY|umm29+!x34UD&QI>LWyJ!!wJk8Giir@gTpFcT9|ZXT+SC}?(A z-X>0cvJL-?{JY zQvs~Nd&PpGv~!aaRjjRf@sM)?%tVi`kT6w~ooK~QV05F0zB+_Tw9^+t1dnuOdJMKXNI^2=ugmE*CI*GGt5fEU zj`U;MOtIg>P1c3FHHTgQtNlWwkfY+N#c=v`4wnqkZ6t#f8U=rhRLAZuKUkj04T)zq z-MIMLbqg8hA?G(v5UX~gMBz}fX6rex98CvF5Va<8Fq?!*##T>-EW|0dia>ET1=aw? zz2>9auBLy!2)2t5(TK;$UU0XJ*#eF6k+5%d&#adjqqUQ0MJxi@2a@t?L$ikxKvA=! z&86pmdjf!B9lRZnBFg52)$I62Nm8AX_dURM9hvd<-LW;*GbJcoC$*SvN{25xIz%t3 zgP3v4sD_Q9xZ|V27j;^E5C@M-1zP1;@;fW8)N$DT}iD54qryMOx!kO zZ8MI-XqdFf+jl8>&DN?P9!1A`Q826}c`G;qK=xpp`9Hq*p#4*AG9Vb0H!)BLETv;^ z<0-e^zYm%L1^#D(=*8c`rEn@#H=%FsU=Fq1F+ZIpx z%QCv~N?hBF+%6fk%|K?-8aLQ^M+@}Y-@aENCmtVdtFE8m9ypBluN&PjEx3pC-}+!! z8~nR-mlNVGY>{`Y5ZPwMlHc~h&oEK4BAS2(YfV|mE}>^i8ER#Ko%zD@z+pG}1iJrp2 z*gcF~y0pJ&ocvQD)A}i-7_d1&$kf(l98COCFhMIbw26+cJxtqPH`lygONqSpPrh^r z4@aY^`XH@d6o4iMw&aJjv>{9lESoT<{-0AnAchLEr$!i=^2!WhWU7JnXxdCY@>A)f zsr0__&hhf4gmL1dtw#yz1fNt^H^Hp9P>Z6DB+-jg>5x}9h=OFehmkts9tIFpHvwcY z29pZ6B{iA6Q$?N~mS2Ce*P}0Pz>~spMFTf0U&}o*vJ(tpK4LlG8M9a3|C~r2*fCfU z790aISR_nuuqSN-Z50Sh+g4xk09S~UqdF8x(w5h_0w+w79%jTG*%su2>jt-fmM0Y_ zMldoV@hgQca>SU_^796YyyqWx)&AdA_eN7lFc9w@4{vUtDrR+(xM5Y-!H4wEJ*5CM zJvoq53^U_Zbi-F(kv4MkE4_ZCjckmq05%J6o=A+)FhOn}nSb9tmpouRs}a@$tn?H! z9+n6$Y~&D4ZC$@rd(h>RV`DpJanWUS8w!{uRPl6H;dWWcyEtuaTZ=|r&eQEt3 z&2CsTlM%PZhTZ$(&)YYP;zJzS z*)CbIo-Yk!*pt|2$?t}(Qv@#VlOV_?7-rZ<3(l+5P%ST;{0fJHCKk3#Bn0gMK1@a@ z!rw7eAFgiwQD4nw)Y+6R|8qvv;<`EA%>U;2PPJ5}`IqPKby(JrKL61zsRxlqEv)NB-fP(-WiGX!UdzF1RRdFn2wo&j)nr&lP3-EG1RA{5V3Q4-XR z)mvDmMc_a<)77{+XV;Tc>(F!lwN$k`3mA@p(qn-PJEb~hXJm7nI!hpulBir0J;}APg;^QVJx>^SiN2XLO>01tULFJv3g(JG5v)Y7OW1l+bpDw9DfRLr)EjrwU>OdUvB4mple2-Pb%te$@se}G zrQN0tK5gQEw@Msln~aXNgW$Yrz*(NC4gU8hLnIEf^mLD3P?zsxwDeQ4!tuNO>UR>{ zO$=rBL$Daqk%E~jMG!etE2qPi z3J%R=ltc^0f_bz-);Lk^?!U4%4|hSPGhB+D9j7Pfr-aZ19296sa;=!5EEJ$$-d&o? z&=+UF!^2&Wo{B@a$AQORYBIefJ~5Tm4;6ez%gLTf7B)PTY@;sk7n;hP$EFUI_MVMJs{dB{{G_SMAUY$lI7CC1b_n4#-L7b zwKv+x+L#y{Sr#RRYLWMdh8}dqE>}>>7Nnm@kJld7Lh0Dcv}Uoo71al>)al7|My-p8 zCgP!!-uZctFQu3N!f&0i%s9j_YpNk03fdij_4I)-pH33QwwJ}oosR< z(g9fJll&DGhF7p_FVY)7yWsKc(Ro#iF2}z)cE+RwG%C5?GK#BaG=9d%fpNxp!!E<| z$$c6I7SqL&vw7|WqqVyc4p)x=3DQO71 z4*bzkXJR_0j@Ke%YNsMyz2uRxga)p(H{D=uhJLx(o;HO^Akd>yr5B8~IMxpy>TmbJY$9qX z10*N7a}>K}@^mp`Xpw%dMH&xx4KxG81Y{l?zv+__lM=E)UO^C-+ zCQfQn!a7%_cMiLC?M>N(DFOD18;-0jGT4x#fSoW%voUZo;(76pGI_b=U=bM32t8ow z1!3vwr$+sQSc>Cl37*niv^RJ58oKj-FAmTzzt6@U<^Vk6 z(L0WqBJu8Ce;w|QPl-9Ta^#tU1N3&1MjT+&KBYgLPOR?;2ar9=6BUS+__wh=dp@P) zG%@x33`Wl&Sr23fUIGrT3S*hhZjPrwhd!3MI+8I^y}ke5cS9cYjFn8JCn0lAq-`O@O1v{C# z^p^l!2Rt=L*-rqT5%7!w_ssTvX^L-stCL*JIgH`hLe(-H-E`}EV%HW_QrF!*d>*0Y zw#Q?OR1*!-H}3;uf=|9tPHwRMp&JJqFg}e2F~KEOogXC=Y<3EbqBDkuypLt4=a<~K z>12Q@$T1hshh_LeDRRjJ(TTzdInmivaJIGD|E7(C_k3|QmgD+l-~m^5b_Lu@CeF|+ zW2rP8EblX6p;P9uTR8I^NQh}c=h!{Ql*YnI2^R7{AG?Klea&u6eEu3Ep+(*oBGZ-2 zR_@L`g=VG}&H`}JM{XmK>2*v0J&U$f^HT}kwF7xyN}F%{_>fPi&6~H=mv_jOHB4nC z_ChM>1iA@)8SWVh%&Hr;`T6he^>+^wT9}oLfJ$3eO;tHY1C!hmW%-Lz6hJxL@6;mY1`_ae@(_RWTNZqry<{f7+=yiKd<#NdKmg%d-C!e zUW(X0=T#Jyfzj~EDoNQEo-gixnX(PG>o7@KRtXgBP2Rqm%DMra=Q~wYajqx^p6K^Y zxsv+y78hRqudc0}Dk>OJ3_Q{Ix8q34J75`uHxG8%Eswz)9C8&?NfcpAcErjp?2fi| zkKfsF2-@1AR=~pfC!q7Hxg)=WmvNs)t$ynr?h~mwIT=k3qX^l?lOI|@BaeNO6JWuS zTw~=OrlDpnd0)@5YN&L8CdY!1JVb`Q`qtBq^h5bRO^?(f?+6XG;kHYsbB10vFzy0- z2N|DaI(SD&?pVa+kfnrXHdNOymqmRTQ=q+Kc!W`7{TPz5WT?EOMJBC2zdWB?EGZZm zPtIsU*2t38B9n!+#kcZXX99`Xm-#ZpRV-7AoGyDF=$Ch_SV{Z$Y7b+e&E5M-kV=~m zAty$RA*PeOy2jWrzdl&WoDttTM`Fr=b@qoBL}bv!;UJ^~wA^#cJAN6>$l~1}dzDW0 z>}>YL|Ksj25d)Z3+Q9;BBBbaZaGT=&@`^Z`OCPQ}M2!HSV?BM+r$0C!|E565C}FpA zM!EBmXUL(`M$2-k0PD<(n<6t7qh;ioS3uH@=1x$zWi((`K2-274H#+Ta4$7L?Fe!W zdtgEPSXtuwVBdyUF!4?i-kxIGQPL6X@vcYcy6b=Q$&BF~Bhwj-TZ2So!o9(EAa3Z* zTE8w>z>I2D+iRmmD{7I~M`vNph}M&R66M6k@03ad0Y^xt>2H`~qm1~l1CSyVtKGk| z*Ij2|%CyD_>v+C-3RXTw45G|y9<^2RT6}sOwxqKFcFxZ`Rbq8{r$uit%sQxH(qvzk z&z;qZx>~&TY(Q1LPQao`& zH!2&suWsa~)Ob25tc0l`nF?6hbi?(-nIZt1G=UtizDbIVCpcyXCzm&H8M7Wo{b9_15iQ^R~Jz9M-}5Bhk+lkH|}ZS?VpZJwLv0hdF4dI4>#mIaoM&{O!(0# zt=cod=H8MIf?~8Y^vs$-#(`W*cN#g zEThk*chN^*&>>v_r$!Upwz+jAYzv44m(qtDxw+$EKT+0XrgPKWwYm8R=y1!HY066$ zKG2{kgPKw=hkK%vJrM3fI=@irmQ#38n|m*96_e(a7k=>l>Vf!o0c4n9`%3|y^kgr1 z*xu&^*cJq*uamIlBvYzV|e(ggTPlx*^4svTf5Z zZ~G%<8?b48bNKCz7JOgF7cZ6~e%jTyoKBT#SOrRF!+@c>GqJHAJ`$z?XvVvS$cikn zl;-Eun;tKwvLYCs4jXCV`56E)1n^qc^uVi-2PQ1c>hZ}foSQG-5@(P!VOn+T4)v8- zn19vY%Ne2eePhSLb_^fnfr;f__&?8_uphb)Ks9AKt92=cizIF^1Y}>^zwEi^-o8yF zLGvz?wb|;+2R}R#KI79LvU-Jn@_Jag=IT-=CYDR>=(NPz8&Sud$t(w18p+GN4XyIDA`Sy%tXVQ5xS zW}#-U6+{3-7U=S?2KTCo4emi>wCD5BM=*S#iR`W7rPH_ zx3an^*AobV|DnsV?R2rz_YpRHpJ)YnKh^BJzg~CP!5AVApZb{q6+L8-;&&os!EZ0P z>j2l*hfRH3&H4__dg?I;zsfD~Cn$CLe0##S6QNmOc<9gaJ4Ag_r_T4m7iY;$4`_&%_hiaA#K15SlDn>F#iZUNNNWtSc<|xVI7HHi z8*Ep)QbRnVA+Ee-{~n?se^sW*&7e#*L=EcN?Md>V;l583o00dphG@0mkm?*BEYqmX zto9S*mh|DNee0pOZ_44piu$Csq($CSBGwh}jh;mImG)po(3O#C#M-Lty~}vQWncu) zm2V67^{hZ{JNA#W4@I*P>ARD@9cJ9qVmhGzx)-0Uqlsi~y?9Dyn)jR#QvS}9C8Yns zsN&*OOlF=S63AL4@B>*AXE#gYG@NhQ`r>V*fA&E*3~H$|MnWHd2Qcr~%P2wSy*HzH zA2bKK#53*5Q+j2eA@}W%-#TAn4Cdt2)jZJm3tIT{cbA>Xg#$!Yvj)5D`P{)saV zW?Kgm?S0y2vUvR5BJX8^9)Ir*CFChFACKXNBZJw_`uhXoDN)-L%)HlRbaguN#^VYA zvdA9hiwH%f0;2aCy_ORqOE5;vZN$9{M~MdXQT-O28eqmmuFTJ}Brm~h!U1Nc%%z6w zH2+@O;@n;vAEvh0U(Rf+yW_4WWARYM;4f2+0p6D`YZ$9HMpQD;Zd1ESB2N+NFYAz_7 z*4C`O&SeOiuq_a59$hQUgw$HV4@u)5z7lSIYu=5OcMZ;08$(!sQ-^Kz!6!UU?gN`P zMHJpqki`3OC!$-gUgSP?g{MBnazkz+iMHWVeBlp>x&ce?NWoq3tqBF(lI)>^^_%uf zxEdoWE5f0BQ^6g7>kFTe26GG@yKHPJ%Bh!|t1Kfhn993a^{N+!UO*NTU+FFlFIo7q z0WeLy#|4LY!O4g2LlzVF$@o5=nNh^PFCDtD%YX|Qe2SYR@-wypY1L#pUff258KVS( z+ur!X$=sB$7cv*(6^I>iez> zzxa*rjR$MiC4LwTdT0Z3Jq(f4$MQQ3t?O|weMJSOGw3CX7m9;PDoh7bT?ua-Y&}|l z$WyYm;%;mOTtytqkRvw^{Dffc;IIB@2`eD6GSqP4 zG>DQQLy@mKb=3jg>zhLf{fYJU39h2C4LLIH8S>}YJB5S1FNK0m1D@^FJPKTz6ssst zjEvQqWNkqhZ4bThCxp>xNJ0UR6O>Hn1dN}PXW9omM_=E@&Szaik7s|f zMypI_GT!C}+`Bh(#5+k<&WQ&dXZFC!WmF zb0oSYQ)>#hCiK{+%YdV%(@&6NJwbC0*P+oa?=T^He8(T$OMSJ-pYT{i_GXeKPUFd7 zsnSQ{c& zvR!D~*qfPmv_P)@L)%IkndWY84AG1y91eioa$Cb52l%#)AtHZ6AiLbYYn^Gx&{55A zYejv+SD*zfmIq|^IsI>^lO*w^`s`O4$-V=&{diGYyN>sKOl{hGhW~ab=~Kj|9O^S)86mZ-kE0p~0?vOQwEhl((?s~`zN z@DWG8YsKUTcqA%vmLI{2?HDSy0E?$S8K5bB_~hun(dmbe5xCxhR0W(2ir?&8zqF>0 zp&;V&<{W#>JS|VPvevjxu>8S@oeh}MIW}pF6Gk19<0Tqam%UTOV?N5KSNs`u8D$qX zLMg3%$c9TlbJ{(J#A7^amoN4gf0GKKIP~S+Co;l5st#ANpjfPJtD2y&%T*)ZI-;rBs>o9TroSKi`FRA%APwQP~XLybXt_ zA=|CF^RwU4EkQVqkmtVPlXKzBP|e$5;7UeHzs~r=^WBuuj0)<4%^lU580%oM)=!X6 zVB+ddbR0nh3MT68(j|L7&j3_KRB-2EhE`1RehVhFKu+7Hu;-&R)g{Zn+3iGt9V5@u zs~cZ$ne=*d$cnktY-3|U%}%*OQX7APFf2iQzb zQ&`N|(*5u0n2d$F&|DK1C>T$bEmV#eCrqes&?>i#c<)-bN(XhjR$0bXF7463yIZA= z&EoJ_X5_50P70y>Ggll>AwJAj6X8vB*C7nAz(#!Mt;8y>F`j1W=21U=mpaN|#q&r* zD*=`Q6={r_^hoNZxnDoz-CL-I9Vse&C%)+VF#}U1z8Yn!2npLj)ckNG7Ib@ie;N(0 z8$qTgE?A_uhkM&_NC&qm#mP*al+jnbaWuJkh#DKhbYrwBFx+W?7N{qC$ zQhu!r_68@w_q#w2h)D;7wxM$FUO##N7mozo zajMwy8JM5Aw{m&j;V#Y$!Hs{E!!~08@7L281NKZ$K+QC|97{KRyIWDO@Q)W%&9~%B z?myIK|Cs?$Yy~Vym-EVCBhaW6`~$&M<+_JrH?I&$UmZR1FC?kSz&LB}j^Iv{)QLP_ zUjYswNnKPxQNXq19{k}I0rw|MQYSGTc1fgdk|nuPMqc%eSHDVov{E%2*cjTFuGP94 zDXSB<>ky6TpBhNEIIGmHTA~-rrG!(}-l6YyWBpiVos;F<_bQX3>b7927Y3e#3sc(Q zdLD5LB4B0$ua?fgYm&X23-(Ejeg6db{a@VEmwacl%2kG*a16t`er(;y;&zO_- zxNuHLa^1tM@;qnJr(d0r^>uH?P?O9;r7F%Fd+VaKww__|c1ifQ`2C}fe zW_oJL2sic#Z8+n99`*esvbQqrG--_w~8xDdw+Hhas|S=B4}hv zs*aVHsmZZkfI%M1V2?u}o|9R=&SU=2%m2D;|91M|_XW;JhsdiI@{%y(CBNvsDcZd;3t)q#$`}PX;Pbm2etZ@z@~B`HI|qPn zCSCcu<~h`7c)*IAYyiWBuH<$x>hcM<+%1*7lT#bwXph#~i2pz@DluA>^-P3c4H-4) z4kz;SU%8pNbY)=eMUC@`tq8(tZD4n?u>FK2`_hL zp)IhD3?>ohj3=)eFf9w`PV8dFVcw1fC-+)}@aE1vnYQqf)Jc#&lXqkd(1~QkEf@d% zTa*I+Dpm~$>e+ZL{q=6KGt2nHp6{9!H(!kJu*3%*l=_3L-R z1(aY0Gq%Yf6=)*_n0bXtd{jc!N-Y8mD+QnAL3w-q)=ynQTWCaGBd^u@URIo>lma$@ zx-Bn&i`l8%obLMipYBypZ!d)r3>ODOIw~)48>sfMczDCHKe>s*6yBcCEJszksnu(; z8xzx8BcaxVGcsX{DOJ{-^<5GVHTWJ@bZ0rWVLU7v_*Jl891)6>N0iA}dfcA39|AD2 zFj}i%Yx+LBsaIagdUrG5lJ^%MjGxV5#MjfDlK;99{&9J0ofm%ot3B}balWP)IJ_I{ zY+4v7wRYVIOAFCzgld+Jto>TnSf?M9wS&9k?AI_p5c&zNYIE6NzRj5qv2q|i#1O8q z%b0SFi(!;jwqEvX`d!qMp>#cb*U;Lko-!YFg;-&x|wxYrQHYKGaq@w!RP~xG2pYFh`&+qZZIN~thCE(z}&#jP( zo3VKu^hU#_MhZd*K*M3&lRPJRG4O}~bq(p1w-k3iRdb=BhD@Kh0UD-?3jsKs&C|c} z2cDX|sk(C+V3@TNH9B)sc|lV4s zvX4sSUUIj^s5(yFZxMBHS`VwB*cK0e_vBuu+7evkCBnf#yK%A)=;U5)x5We;Ea*2J z#x_{WYg$UbzppHIOEG1in+y0DOOZCfaop5($ZWS1!x2;UlUT}|ijZ#l+Yc78Dj2)s zGpvrR5#{p4Oy1^D#ak^hc0;BtBBV>ccGNB-Z8vJlXULV{J@S)UdJQg$m6KwJ?+B8>vmhj zIQ4jVrLp|h41IQ!Oy-`?_M$u}eAjbxP7_{;#K~YD0E<vhbsUn8%gLvUjy<4ed;s6> z)PsEy@zS=Yufzfq9&fb_>z|UR-6aGZ+q;Zp7cHUNi~SpEe{6&Lo@2Lv1^zA)mT>5& z*Ivf!8{60gk7IjR&>`7FOSo#@E59b6Zet0q@-{sZ+t>oT_yoAzg--L2dW-^l8%uB% zwgHFvbX(!1WY6UUKjXQhwaHc0hzjx1TS#mRjs*?vs$lf&ik$_wAP62$xh#c11fA+mEcV`r( zB2dp+*}23cBBbC8o3 zeEC9a55wO}4oH#8QINXUGL_B>bV3^`DYmw_{?fZ)dn3qdlGfXw_`qLINpT_b+ zZ;}AE8SWl73_>U1vSIhK0_!wp!@AGq?1lB}x}S}i1hB)~8u5B8WLG3W($rcSvC^e6 z-SyJF#yI9KX&ri~t2KQ|Y2Q8ojt`Y_NeilfTpywNWS2A~D}0Zmq<0v!#Icpl3oB}m_7WefnbrV0v~t<{y{h=l|61??7x{b4ms*=eg?i?|sLrZqmze zCvVuBCY)WT+P9*e`22)ER8bR$9sCM2PC-SbFn35xCC6*m4&8g=W!yDh$3RH~gncL+ zZYPH9T?pI{uxsNmLHrxdUN+-T{zqP+gH6v)x7@W55yfHZyx-KDXArkq9<|_AiwHYU zCDm*8i$TFb!VLpqr+4SD2dR!f`I{`n4y)hw+4b7FLc23~-=Cjop(vNGg7{2>{`e6u z6jN%sC}^l%BVCm7Wi}UONL&_GC3k$u&1X!rP?U!m2Xq3=OwnP=VlXZl;Bv*v<~q0% z{=;PBblGJQQ{~tt|H5XdX*ZRRU?a<8CF5st)@YmJ18A6DaO|K8hwnS-9BFpY#l6<) z!(s*tK}EtM^?h#NS0QUkBc+du>%Vs0XidqdbX`~`@W+lv#`q8gZJXw|kL@8{2N4&S zO{TlQ;}J1T75&vj&6^JU+%60{n@lmYEJTm|YAluo_tHw5sMbp?*#ml-?&NV?-C_P0 zXnh*2)C>zjF9)x;hvzC#Ox5#tHxoUW+Nm-y_8Pt42om|^y1C30hbF$DCnpqbq;^p2)D|pqNZ$4*Tj3<1>fI!xE?!lj-sZSd8>~Z9m?at`u#nKe?fq%hT zUr^NP7EXQmsQ2cuvq1_w&PwXlFBsFg8U8e&=APD4(MUB}V`ADa4B zE!Oxel7)c{&{L|(ya%uqzff3Yi7QNL$W_>7&QtWjV;t7MEXLv4g*eQE@Hh&JFkIkB zsK zCtIzkkT7E5ig8K3rc!$5S=}V2VYTN<^cgWFF zp&H?W<_4}Psu*xtMvA!De4)sYu@2Sn@h5k!UM)uyfiw7U3kjicAGWet60#%XEc~hm6IdD)Gv`~zxw@O zylbYtC>E?>D-Q9oTv#}1q}?A1Z>%^?WaZp~0+x#V4LO!OkGl~Zva>4Nwp_K>ML)pO zT(&sHEwkQ-8Bnhia0lDY<2DWDtGS8X>W-tVkbTv0Ts_hTEZ;OstOZZxAsb#)hqhB; z?+}Nps;z3R!oMLP5VK}-g@kV9>iK7J8Q4td>JWof^dx%tT9gQ9oNIR;8qKhWy6(R( z0~3YAnsKO=VAAq~F1KQa|9@w_x*dAIOC4m%5uG2;@{mDo3oObm9(>S_QuQx_D00xz_p=#PB&hc8M)Pg{r-l-6Hu(KP&x;iBif|(A`RAmVB;5cLw@^Bja#D zQlRwPaHz&705BN>=K%-aVd<5j7^VA_e#@cBKxq{xx5|*{^37j;ams&UDR4a+>Z)Kl zYE1)ZNDRKWY)jRDXi0EHgidP%4t|o`q#wHc_JJDehtceQ#QKgxF^Bkun!+xy?$AIrjBAV%w-ku%&X zM?lwNzNTq>48`bWcxI$~uw6bQ{()AcpMX=8C>3mIs;+9t7(7L=9zCe3c1YUR8_N7?1&;IjeOpw25j%GZ(;94NX>cn3LPZH3QfEYMnu35OI7&YItxu8Ubu+{<1HDrHVtxwnY<>qae(d>Dehj-_SO!N z0$l_fcAOrrWPzk`Mn;E@NCp&-{K@@H2J8SSh<~T);il1&62t8Ni1>C8?iXn`O+5KK zxumi80%T%_9s0N&qF0DPnh;jTMTW(#Zw`PF5|ai&0^Spb%|K5qVE@nXNAiG3a8>sk z?$}Ned@RmrVdV)(Ky!v4=0mzYT7UHd{2Xyd`y}{Z;EUwJ7vBek5@E!>3YSc0&TMKtX`BgAJWc{xTIfiV7_`yMOJDoYQ-7p#O>w$@ z09G#RaQbivtTl%dq7UrhahebnPB>fz1ew+615fg>cK_A$zjm{BAGi$ywS;q7<TEuIUQ^W@!5hAO9CgsP`$khaIO>&e^r~!Jq%=|2+~q(}yI?TtLH~Wr|L|XXf)h z^HU<-JEu1 z_m%%ml7*Pr>6NXEI~Q+R^naaX+hnc|$us8bJU0KgQ!w3Bv3l)fPuTjrd0O8S>+o2S zxu4-S^8FjgN8E$awL&sg87qk{`rzF~*|>Z=2E#?~g~}|xxt8kgEVX67$n2;8M{m!g zLzT~Smhd>|pDPAqY2)eA7G90`AD8c1c&*8l&=^T+#DAUZhRHj#lmV<|HynG|2@a-v zyNV zLpD`L_$^#hWGcfRFq1Yya<~qGTv+r)2w$9AHLb3AMvHUdXI7odS8~7?Qdn@|Q%X7B zv5X5B*vd!g@f=A(!wxAL$1`|BBeAW8I_HmvR34{b3FPpLii0*z zx&Pa|m_{EwebA*~v;{SK*{@Gdb}iJ)*z*N!bk)*z8J7QS*K3_vwVeOMskwNW>;51I3?TU!a8ZP!p0MX1M33)3YDPEaRJfIK&J0Eq&{3 zPTf6=8DUfKhuFdiHf%Z5Hd}e9H?Oi8(K* zLB8SroS=o^I$q!9r_X%VaPw54<`Y~5Dan~vWr_wCeJMk)5^)!xw{$_sA%~bd(VNB0 zK&vZ20s7qMzCbRdQCcmlP_VORaq_m#(E30B*RhQMb2rs(p7{m zSY)6NG^Ndhhv>=i?VeKXWrca+l`T+f`47MHG^?eydrA>39SYa#GxSAS@ho)~!P@m5S(2v1ef~48Tmb zo=^#ve?_*;Jt;_Pbvx*+)s*!GTVj2QnC~X^cBPw6xAe(894j<|Q|k)LQlLzR#T?YI*m?V5YLghbI_~iFJ5y!F248 z2i#XMzErv9#qqTOM;)xYs}!`z-NB^d?Q440?&2G!s@OEH?qSDg*0XmHF1PX`kQYSj z)feiaL%GA6DT3@lPQvh-(Aj4N0~@dZ>3|Vb5q_!mVuxR zlg^WB$GC!|2-*^)xc;AKzHwnh3Xj{DS?S?!s7+A_Z74m7wCA5BR~#f#^y|5FcNq?I zp@HjbT)ZtZ=0d-b)Hv|k2xX{r*_;bj*Mf$9eNbb%;oK_bWHPQHX@n;cf|#3oS@v z^I3TR2A}*vnjMauRxpr7P?69k1tEjE9YYd2<=>&>_{x?$&UxU!Zz@2-n0$~Rh8s4s zrL+r@)9};7ObP0Vw^hQVR52o@&M^+$VNy!n-kPu8`W1fhu*GV#IL@mkh}jkZz)L5g zPO_}DFO|}aV}Eclm+oOujyM=TQk!Z^gW=DV?beant&=|5{GiT0J$0JLn2HNB;}0ka zK)4t6P8N2iRJDs2n3qp2FzbkYn`_chx)dfU4EpJaL702v>`LyIVFa8n2s33E+GN*70Si2Vos8y(@W`z^&uj}@#t zTJp^s*YYanIJ=eM?_bF2tZiyTm;%Y>rdF&8I@Su*upoQceRKC?zr;?WHa65AaxD7I zaV!$Y9GliY+)GcV#2o62Wr-b(5!}P8aPA z+fXo)4tjlLWuAq+qdC23!2&@+%Ag&+$MEEXT$ARh5Kr4GwYh`Ytlay z5k19&W*|9%Nl;2;zX{EH06hlah)YgPN<@M+CGRK~j7UH2vsrk&qqV7N@>DcJ^ygHG zI*e?W|3UswFjDt_kb@#}xlVQM2t#5{1LKB7@QzywABK;sizwe*$*V&kg#nByl z*t|^{pidcvUYs|l)Hx}jw+9w8z@O#v5q}1+=?cHNQ1~-NCm`k=cUkN!>#Mry#XV~< z4VcrO51f%>AmT42tchp*fdPp%4=d~U}QRHGD;3@#~C;}^5OhCfy@Q#jMQD^}XG zKXGntn!gg1``}ff=dJ7@f>=c!t;_Ev16DAU(doH;4m~G-sjl`a_c^bvtg0F%AqojW zS#yIuI&eAL#1L2Iq*oOCgh%1XDu318BHf|Y5F3k-QH4?C>LWcu#6FK-A?JA7UUesE zX}&u(Ooi;{$+of{C`GU;CSNMo@i+H=AXqq*)-xZv3b3qxSh13=Q(xdPwTcxiTO7Tm z;!hJXwS4^MX47)_=SxM<9;ER?gvAQa((XdUVtQ-X3I>px*ByE5=ysL@?c*W_?!^Noh7E_y2YZxhG?6ri zJ|*nE9=q*Sw!J941XjMvp1|R5I?pG59dyh0dg!?|P9vpfO21tF0q-#v+67LXyWn8E zhPh>WikLd;q@tGlsAt-r8ma4N=33|C`^hK?*=H9Xy@GlM-tac6`<==LH$<=L$t!F| zOI>G>yMA=AKGVo8?J_~)jam`d^3s{fvNgTt8W8It$Kk1zNw<^5KtDk|p9RDUg?-ocEi zt%_REGPX=%5Mz1UXMW3Oq}yBqAs1Ky|6E9+D}MszXcp>GG8Wh=IXPR$`kXh;`yr3@ z5ZytS;N=rdjEcbSa-tOmU5Gwm#!UF~Sa0~~z)d~;ScWlRacOKUFrk$FSV=;BKdI8O zUfOxwac0Eh{Yx{|(P==f460IF0{buh?`2E-9#=u%)!akcPG~=3pN_~=KlIALZ!E^I z4S0}Dd-xf}NF7sLGXBPBcQ&)AXu2{a03X;{W&=YmF zcl{%SX;(}}bPA@0D@}#*(~G3G^d`Ng3aGu^Bc|z^2vt;UTfvtq7IygJjYR-Ey3hdm&HWqKn+0AoYkVU~Qq|wy+X>HcF2nx{(slm?GZS@U$@T#PY zjYJrfxOCl$!x-HY4Cyq%phQbyK-~!j+1SJIyl{z(yGzF$e9uULp_!64^2QNyU0r;1 zq<-d1^h?E^B~*8X6ke`pjM;^{8t}y6hnJsBp1_GpVKbWLV0*5JV2AtsxsI=R;tEl3 z&P_)gs{1f_25cG$=sh&Yy4*m9<{J?hsRvcTcxFvwdZZYsch%^DA91P`=&4B0r>177Rzc4#}0`nRyTN-Fy_mFoXm`c?v+u}NTUSk_|=6M06in6+v(s&V36Ja2I9}^a>FOADs#6|d;=mjpH}?>R@9G{%d%T4myoQvp%0FAB@N^+?=! zD=`T7dhLs-q!qc6yCkf7KEJS>jVORVhXiV=?i{7Gh1_}gvl+BtgRFO%Y<^{~LkERb z@zPz3C9HI9+sSKBh{!LBI>-UMb0wgUvtM2!d^h!bO(A~sgo!&kb!rK?qGy8SM@)J^?<`P+*oR-ZZ!RCoQVC0Ct~0y4R$-gvNmQr!I` z&N-z3PCcRptPRucMjkqa{ICS`Lb!y1bJt!ymNaq=tG6nYB8xOuulDmJz=@$;) z!@9-1L7qY)DNY`cudwjuIe*9i{E((b6u7CtQXHVQIJ#m!@)fRW*k$zR)9+GN^Sd@D z`!4?U+QL2T z1l|&iLKA7>L@l#-MyZU#2o&)IS(6xOsZV#$jC^2X0@d0jAE zr$avH(sPC!gkPZ@vIM_VkGiyuf7XlOl_P)o%IEO2<%_Zexs+b=j6fdHZROz+NSiby zngnQKpN<=-DM32!=~A(byi7Ow8tmew=>4mZo1uyjTK?C zzL)JGF6obdY0gZqpN5Tr!H~Ql#kKjwe;j@qmRq61FsM=l<=F(n*$%-Tm+DF{@ajB~ zgZ%zE)4_iA`WFwuGf=$ilu;Wd2!|JuIk&4^-#w|WfCI)3P7T@4<7iI_CogI#3!kql z{y0mqlh=+Fglp+s%7P!fQ~z<6;&J$;*zXeNvL}*POiTUw_3L_pViUk7p-arUkYia= z6>__|8l9kFMV7e>-%ynAoP0_HC0RNaS`lM&p2C}Q0bGLlQ^*rjx*M2dr%?PRuUSlH z^#enxtx_rw9otVGN)jJ%X`X6Ws zCl&3s&qRQaDD=s#0?L6Wf8&xRb(PsTKB-jzDt6-|nacD2{+nybtx(W8@oMM3Sqe5z z>ACk1%XF=i$8dGh=L33@rnq5p(9{pMilwg#J`;U6gYW8sr5b9;&{Ko3WY3i(#z`wruA|Sf70`Xh1&+HY)&_(cLG-byma%&^|dE_ zRq7U)msj4Wgs3(ypK&%(6pY_qt&QjocPiP>PYS`NQFG!2?S95ZfWls zqlvoU>hpLfU3gNV6LC_np(l06@KIH*HPc}rAW`FvEmHI7KhvycX9y@v1}nh!8MY}O@>R0Vx6q-tak**z68ELCOXH7HZ!B||xWcmD5NUj!~RwNZ+6tuR~DEU2^ zH>Mip&-Q~HIR&ccH2y2+E zUvT=KItYK$m+uv0>ns$N$qI}l66$?sz}cZ? z+*s4l#_n~#pK(cKEtQn>jdFe{7v4GPrKQZt8%%qxK84A$UBV3fU~o~lQNwCHAQBY7gP=VGSibD`lDEHTWX+Ez0;^=c}KJMhy)7mGfx`0cm zQX(rt{hXw5<2wT;vDk*2#*J^`fO9T{QAr@9SB zaN?VcaQ%}{9!f^o$*FIf5$ePU{dW1)OEDv~Y@PaH^8zDq=kXyURQ4)*Ic5Zd)h^Wo ztiTBMn$ngZ)C{17Fs!i^*2%0Uq<<&|vlMTi=pYsHl*(}fnAXNQzwP#wK^|5{utK$) z*F(1Z>1+ylL&?mQG~t&mub4E}gWI{OhEg?T4=qbywCs3hb@qcE`OdtKWHk?@gagzvC@s)T3#ZJ+CeK z4PCQb5UP2_-7d5&LKd{#USG%eILm>@{P2TJI>v*v%2tgrS|wNOkSrZv`7bZYs2+VS zfuC)JDy-9=5nf5;L0h`{{@JlL=xYT$fyg43NQxA-(FzmAo?NHZK6^*^!L$Y|n^3AS zmFhah$Z1&M#?}6nmnxUN_;bG-#(m@%%Z7$AT5&Xnm{7N+RbM~xlILS<&>01gz1|87 zM$}}dCq(EF82|?^{hzhp_Fh9|48SDyR#$+Lar!~>H@QWz!8;E5+4(Gu76c=PWm?tv)s6MmBXubVdfUp5=m{{o%i7bJUFAMm zUbUgAX;ySG#D$6{f9#|>*aFjaCU6HfbYAfE#4{cRZnjR&xV);ct1#^Ux1>5qdb>!v zY~fF4k)%i(yC$C7B<@b8tD9;`Ql4Obn6U12+EMpBkDsH2^|p!o|6RHoq`pfA`JnX& zH8FuV+O_CFwT)elrQ@Q9=T$drSN*a}TUl$7R?#B_T8`^{G|2brUeTEbxr3Rud}r!J z56v4)-BZafSViZJdPG1&HbY%(l6xg27rlSOAiBS4Iy97a)1FAn88Hz>XmF-i?SJC^ zR3qt_P_j)sB8&+MllvtkpM5l$%yTxO@!ZCy^BUX1la)QD)Y6SR4C_GEFnq@Di^y0G zMmu=3um4I)(k#X&c|g|a@I5a*k-5I1>BcByi~5{?xq>K@Zqge&zO48MT19A&V zht|1qeoGsLjy|w-2QB{oALP)7rVTHS|h#nW&vglT0!2% z6eIn`<91P){!+TBwtNIpgfZ=*ctj|Q|Fq^$jKYp*Ot4DT96n{9y9_EVSt1M1yx_u& z8{_h2zNk2B8Oh^fw*|jw{3vodLU8IDnrgf;pbKnZx3v?NH;~;t zjCfnJGnQ=Zhroe{;aER;LQ|P>XGIIAGQ{3F!mVZ2u!KYh3RzTWhd;?~PapLH`}1ZR zIluFipReLn3@Vx*h(dBD3o22XTIo{=tGbIM^K zQWwJ9Ma5iG!yGH^+@5B}aZRYz^TwmXzgn$_y2@f5!Op|&+t>Fw@3>arHs{%+r}1T5 zxAkVmaXG1#D#xS3zgjtny)_87^lwW5ZHMGJCA}`4{m(z2jbF!N2CPd*H3~@Q8;sxw zRbdB1@1&|ZRn`mAN4%`Os;R93H;iGZ{x$(q`Z%A53nqcWty$vAyi_5O<3aKA7H^6b>9kky;TSP?t4`!PPeq^ z4vFE!9vjZ!xxmDv9*Axiu*(fpL5h<>$bb_#_(@(ABhMMU-@#;LT=SZv(%_(c3)spG z_vlJ2ItB9GUM@ED^z)Yxus#?Hwrclv*D8#w4R$hGN_Rbcw+HV_aYu?jx>^mXq^tS# zpNSjg+{CR#vju_9Y*e04+**D1)8div=#`2cyM$|3YRso-sGHi_dpZ_|t9`ZT4xk1a zxcptj0zFq8o4m480(HdrX$9y0+?gC5R$b8pL1Lb*&S2mzxEVrj!z8o~X5IHUl&zFz#!Tn5&;?+KX;b3eBceLzZOYMNEnu zNtrE8p+6cd%1i$wF_-tymT$aGv6DYFKnr9iuThc{T3oGtQyGgbTwZM+%cUI3Iw?OAGewd2~;UkwPMUAk(_b z6*u-DiQ$SvfJ@R|#a`O_EL0yn|H`uVI-E9fb3}C~m@%iII$>I`>i?^Cr#RRQk)*J_ zR82EcOjxp9-I^?SBZ6)*b-NjgZUBe6Hch?x+6BkaEAUevq8@R0rNhmp{fVoqjN=!( zzMd|?>Wh0G(e>rZXzCvJ_D)>AW{fYea2CtO3&0XOK0<-CYWGEdr>nE=C4_rlIuOw= zl$7HYX2M$b@9%u&gzcVk=u9DIkEx#QqNyEv$#vh~o~gNtB+JsWe4DoV)FwPw0vUpf zUYy$UYfm|u<{+0zMrfCs4w!4E%|dOGPS7yCL$b#s6iAyMJ@$={A>>ly025?%pD-J* z+H*GwIka8!-L<6})K6`Dgj|5?gxsEwu%c}6kZ;Y+6LR6JUU_{ILav`hl_BH?e}9ku zc|tDW+=h_LUw@X63ti@*gz#0VNV~O$-Pz|6T90d+KioJ4lpGIz!CqLimV%9gV_yi z5Hs+_gbgMw4115wnX}l?WdBE~J}&B6dL+%aQNC{sbTd~0J#Q<89c@mA;zFoju*&Vp zq(y4AN(+n6yd#62wLxqd1!{15{EMx65oGfp_u6!C!0i$qhM#7O$M0?d$h!^ri!+!& zWWq{e`w7UEL4(_IPPX-OR4D+P5z&eTKx=jOQRHFhOh2)?|7;t!Nw?lS4 z6u-h}$NoY+tG@wsdbo{%_0oaGcdLiY`zpU%q%Z4^2?{LGQlVk43`0c9>uR|xhe#Z( zzxnVlQmdwwy^;CJTPML7lgCsz1RWGW|1ye$(xc0|4;4&rCPXmdyr(9aJR>%G28Bbj zXETWnCW(N$K25dwsOoRHsbbx{8WVxt@u`+`s?9IlRpF*;Ftgn4otWy8n(Cl;&goNu znWF%{wYjMQ2NQ(DL7t9qG3*495+}1(&lh@?3)>WxHIv;-S+sG}8*tDAAC8+i;`kIM z7!8Com{bcQCC;D0bnMvMU}jDlr-XzBLwo2rcKzm8)QPyNO(KH5>Bpnzfn|^UTlT6y z_LqwMy<{$=3M%V->xjZ1EER&`sKMdyV9~mknKY5PB0Mu(p5A0bv-(vRTzL3pSV}h{ z?=~;2hhas+XEUj)Ei79WFEOH_O8cNk7h5u~6Xacwy!IE%%IG`KsaGj)xnp zS)s#i3d-S582SXM0Ro|JWfA7^JaC7)jiSB zE7zF)cHWK{F0Fiw7OHExx@+S7fv0LEGnNa_#&stWycAMN}G=XTG2Azt8 zsJ^H6ZsJ`WbUf6FWH8UUTI?YT+<*`WA7KH>)2Ck1)OhsQfAYk2BzY72=nywZ1UW;P zUh54RRbg?mp#2Ovx{taq2IL%KQ9$sTO#tyBhL9VOtVv&iG5Dh7lnXNE$_xzle`bCf zMmXNAil%?u^dBG*F7}EeBVS<%dJC4@wLWqs8me@<{O`$^{{tkF6ypoFREy?aOv$Ch z6eFmbO(YeMurz2{-zTn|sOjtgw-j6`m4V|X@eI~Y>jF6D>`}7h^c`OE_N@h8Y;iJ7 zyE<{$v$yZ~t`=hJI`ge`63A8NeVe{=OT_A2oua}VYbIWC;ki4!#E;L?$w)ap!^{6! zOs9vntO#5BEQxyuA>UYzBl0GREF6y$yVYlVv1)UA;Aky9c>cfYfYS~r4tYqd)P~en zkaGEvc>5<8hA)IS>m@AUNk(axyN>I60e5*PG829_&STg%5qNe(**nheq?`7g&9l36 znF){HISg@HVh4$locaiLa?f9`dzCtQs875IA$J}Tk1-q2 zJ@Ob{4h_XZ$ppnFI?aCaaK;nH)U-nQ*R|ACAuZVgZ?vwl&|BgYkm-ON^6#lJOKq=~ zGB$D2sh1u|n^dqe1`GW+94yt4M6P48d>#rldz{S_0?nG_3{heAhDDz~62A`1o|GL0 z`ijm6y27z732sAi^Rg=|dmgZu;rn3IN z`;UOq;8Y?+mIx(L6}E9*cKm$}`aJIDsHU=d?}sntv}`Idn9!kA5jY3NU*T|8J*G1A z5mqVe`pCK0vPyxLE0ZR`jQ|@>Gv&J!$wv%@4YuoMqDs}13>(r8txR7K_VpKjr zI{l*G)ctMz$5;Yx$_SAO!(4X2F0@JRG|$-FK)JJZ-rVY>CBP|n7B4>bYm__NNLcJ4j?%rARQI$LFGFHuPqyErr!9$G4JsXV40` zTgiO!?6Y^i*kdHX3`<>4Uo-+a-GFZFh$J*JeF=uOH!n4BsZP zj-gz8=k_Hf2dCfl3ISNdcO}`9E2J0(Z=8J_Rh=)VYOb5s)=-5YLrNV!L__d$8;N3L z+py<39bZ1>#>9>{#}p@53dN${clMA5MtjKm8u~}=BI(QfoYXyYPTuceCRK*|F$hIy zVtGjLC47>rwYPJ+|JNlZGhpGhhXwznP$y~;>g_2jt58%KGQ=*95ty<86GgYEzI`OU zA&)lQIj73QZf`cKxYetT9=6$TR%c8{)y@?>HNn>&U#F=rnmF^wu~-S^(S*kO8YYq3 zCX4ozso=!$=W%uLKX^;7BR#q8)n6hxUz`;;7$MRg@WF!ip-OgoVpMzA5?9qf$3mNB5PaoEf}f*Wv!YXi-} zFtjO!@K|)D**0R9wD@6{R_H9%j~~_yn468twr0?Nd(C zx%B&cDxH+V5`hsBlz{ouA9nA*t38uTG( zKtL0vO^lquZ*4|~B$&Y1w91PA1MBS#7`s@=E z&tM{UblXfc9o4Ng>Tz_M1V%`k6b2WKLPel@_M>a>Jv<%5j;r#SIh1*4Gn&Uy!p<=a*xkkX<|H>Tkue%SLvUmm}Ny-HJ9 zcEWR75W%i=COqh_5KI8bWG2}0Ud2f}e{tUhEcqSJSX6uR%Z?A!8H8EiIq+SVlM*9OpF@9YMn4Ur6r@UHM#Et)bGWS;s2|qherS)EM^L3sz+)ZwR|p*nYd=xX@IdK8 zHq`W+hIgu>LluvS7H2%o)jc8Kw|>tkA=tiTx6CuaAn1PODsYH6qmmIgVC92 z3^*=~iT1WQd0b}ununU-J`G=QN7q;&icU3jksYHE}w0bYEGyfc;|2WJ)_SX4tyGqGm1g(BJg4q2&}L713_#YZ<{d z_5mQdy9Qh5%3$XXLi4Yu#J;?sLxUl1!m-oV=9XELwYTgBZY08*#uRBf0iVKE`fLnV0qMi6H<4^ zQR7}W4OU)y3V7h0aJPWvmhR;Z5pLK?Y}BU;QjZ({GH@eI4{c7|l$gRD5fLiNH#2nt z0=4pnJNlOPM;wwoqmqTr2ajEFCeV+;MS&_bwQ*L7V~Lsd=f|QyU8%9dY0Xu$_*RIyvRp8gz1x2h&RR;J)|-X1*x0Cz z{IFd{3zy=QhJ$t_~153%53|bdug2N3ud))rb!xZ<98aN%2SHu9jmhNYP$W%pm zZ^tKPy^hc&S4xg0Gg=15OoNGeXT^`nZmss3P;FXq&L&fMc2lTbJU(IrUh90_=H908 zG^SlV$(uqk@h9IqoiZ(A=R7P{(1%@sfzH*;`1{XSa9Vy$q19-NDGT&n6sn#xe(|fr zG0Z#a?c;G;0IPZDw_o4u3+b5r)i^xKdy?*V{P%u~iPX;w24xY%fPknUY z5d1nmb+HSYks@iQ-xp=z{7a+yZ{5Nw;KA zG2WVQY&emOH=c8#YaHrD;02>+va&c;&Xbfl2!+RW=d&@Btr3i2{v2O~$nrbf4J59R zjOSb&)f()2-J@Tb>u_hXaAVfQO%uM#xgrt=p-5S{RmBL73|xs#&7}$LzpWDjyS9%= zap>>PJmC`}MI(w^+9dot|O`3EDG%?~m=C5(De z?atcPPf1U;m*PdFHj9PwQ=PKuqD9I29f|fjWPKK@qt>@hz8-fcNXWtZQA zrL>n4MjSUAw^)(}olEZ88Ut=`ypP1|bEFRI+C6ez7DMlSrHe!Aw-@u^5~p#R8G}Mu zEvAS1S4q+t$=d@b;Mbv+2lfGmki*u+vFr)sPF(hLsRfAzve2!hB)wzC5tB|$apcYy zs$wUJ0+{?HhiTVWO&qRA)xGRDvgD-pKbAIBoF5E;k^n3(SSQfQTjo}6puO%DSk9LG zhqNJGCe1+$+B2#1!p|LL>{4W%! zrE$kJ1LmOwn?C8GUD$H^?0?Hf6nML}j+_>lO<_sfw-z9cT;}y zSkx7O-ast-gyxh1y@;av=8MKQ{@7b;m)7q_bPqJkrVOdHh+ z=tZffhUn&^zcES@1RofsOpR}^`0I3#fEVN9OMdQpWK?Z$NUZNzReEi~{aNOYiVLb( zG@5g*#bd84PD)nss>;Ty{YSBaD!z}$yR@J1YI*K>Q(Z64&X_xMN{z$m3+?R^=qJ8P zV8L1IYhtEX?Zw>Pktz;XG6Xg2V@6xGscxJUT(&FwWoBA8;H(Bu{V5%L1gcsd3p>H( z##Yeq^Upq?R#3nVlZUunxi0UY6fD^Fxbbzpr;CbGBJPIZxK@!~Kz3DZ#s!L6J-~r+J)i`Y5&q&h4ZHEx=ga$|(uQ-aNc$!O8Kmb_PA)sUuTJdBr4!ZHJlF^(v%6 zd7>V4lU45hVG?Xuky z3|0&cvL45O#GF^Vl`Y#j(PWtP(?i!kcRbZ48~`!DdaLx2A%T0QNSs19R5Q<1*Lq%^ zo<~AB&3&`9w2$7SD@|7X!@vMz)3b=1Lxd08@_lpvcqgvN7glJ~z!Yn`x^#1<_0PK4zq?IsFi%$iB$l9hay zoT{lUU;o3y2N7JjrN+eg4$cpfjo#{BzpQ#>a186?%#Tx?u!`x)(G$K=%X)`w>bXKk zI9;L3f|cUM{8O03ps0ipEZXKzEgwgVHk+C*=>(rDz{a)YIXS$!HIj#ueTIzlIXBd- zWeB1Gl|SA(Ocu#FU$s~DHz&q8QtE#q>q*X*s%V^l*Kx8-oCL_L&qVr6CaMZFX=8bX zV#?5|NNG-J2LDyw@^JqY$_poBqxwlz{A$8fhtt#^60-go+Z>Zt^_;@$HKM5nryhw` z%24(%U(rJKjr+HNw>YWNQWs8r>dYNoDuQ;r&?o)pelE0B_fG*Om)f68UGtd_#_jl0 z4V+W??w5i~b^jLd7AJKQ1C#bTpoCgcF#t^%MRV9YxDAtM;$^Q^B=4}1u!nRJrnUtF z7I_QgVURUx5CIBLZi-wYLDH_?vUUfD%5FhsJ_zcmiU9Sk5ItXgrC(Uh*dk>l9@=*L6xH_ zS@Abrq$rs@t_^Pl+xw{i%7rj|!B0or9eQZP<~MJ|&(JZ9kgAO~ajTHX0 zXw-M{yJ0%Jh;k0hUqGd2sk`cnvhXniy3Veg^%2j2j-wjN1v zE*W`L3GtaC(XM~Fx9ww*$eq0HBvINA9VA+F<%dsyJQBI%_n#us>{ZNaHvjV5RiDRB zR^UFAXW^9Lu$_18AmdJV{N_stE<1ep7buWMrrpBm{xS$vg?kqM$>}-1VOw||IE^Wz!pGOqmAD#u zDg|psYMao;S7bQPl8y+%6d(Eg?JNX{aMDKfIQrsn4rQd;Q zgXu{n?D;9VdKGiAOM5=|JLYdk`OXRKB?Al2gd}*vHLWTkC&0Af>=%^$>lV)5#<;C! zj~G~Iew=3rlcxAGKFM`D8H4seqk-J6J1V+~#(-65A_hF>E%9Vb{dQ9q|6PX&Qe8fr zyBF$C#*Jc=(J$?F%n1M;6UMo`Dru%kgW_^%f>}*^gV|(26hng)D1+LE&g^&j^6#Xu zjDQBqO$2#0nkrfFOd7=Sij!L`aCP9^&rl&`5xS}Y_X=y(YXK^W+T7FIth5rut?ZsJ zH$*MTp&8+)7?~L$iL@#_GE=`?gDHP(7!Gge4RbG^6>#hIGH#c{p6(e6adogl0*hBb z5x#O)jngSWm%7)OF@;#SVhVDpr?u6$&Mi)Em8o6b>*hbuqOYK-BE^r|6}U7x_hDpr zD&Fn3Or>mA>Sg0Vpt=TKqtP}6vj84R@%4ATPLHGlA$!yBDURG2E8Tv_Jyyyl94Q$* z4zgBqn-t3G=T2TC2N}X1#tPb-wl%YWPP^fR53J?ZmbwN+<`{>nW@J@@sI;s^PWI^Q zzqf~n;aDhwW2rV&%%G{8!BGYR2@U6E7bmyN$(}f(^Iurl7V|i{1=Gqc;XSVzL1xRV zkgoU-S&N#qUnXXTnohWhm)^Rp>xltop0LR70}G=^fGuZ2V&Xpn4ox(_l-wnr|M17J zCggc+Jku)8csmRQX_$usx2E$st)^<3kfIiJj=t+3m}K>J`p?caqp^&O6s%>m$`&ji ziwRCSc>bgX$Bbvz01OdulM$>bn&IZm4Y-|Ko@ma~KRn4QZsFEHJC`lRov{iTr;JPx zp^jPFp?Ko)i)lTKC&L6|8+%0gilR`jG-89N427qZuXoJ3ZlfQYS{9LBM~s-gVSti$0^CS~j3>u$gwp{CWgSpvIfdf-5dNORBeu z5n-4P1c7}7{?ZNz^%_gAI41irnmXk;>U)l}eRHdSMkk{C*Zz864-9wgh$j)0 zo%Hq!_{cj5SUQk~{0mUOx^U);=~$>kH#XJFeymN*5QANx(`sLx^OYM|c|X>76_?nV zS&!wyN}^{Imdc~ALd18Prk-$AG$viK8wYg|FEx2Vd)#=(#%bK2Y*e#+>)3z_jc!G9 zkGzyirVWjg(Y5p4`!dMuVWg-XoNUzSf`%9kqa0yEc=MHq^+geWafj+%Nj&bG5&#H| zSabCPQFqM3ENn~#gkN=3_24j;?uQ@i=2R$D4OFOS)uH_G*>QL9?yX-5b{4GGq!(i$ z3jE?r6b}l)Apd94gz0tJAsf4Q88kG4l~TpSiwHoWL9%$5(LWh&FvZujVWqFV@K>I% zlsZ28#h{rTL97yUjq)X+62%`sJe2Qz>!X9HwFE;{3NSpKwjSJnQ&Snf`Pa{r|3;#~ z<@_k|fTmJjmw|c(*;eK*r?PC+l|`JIV5A{sUHKxHr&Mc)jo%i}OuS)4PpYp#0=S6r zoGiJOLlaaiatfob8n>8YE;p?q5b2aGlNEz-r#5*{ywiBVr;jG@sPxAqFYkH;AKVo< zIe!2oqM>Ug3g~}47w5^U3VEWsBvTG!h`_zl;N&kwFhYIraaeA?E`Jp z#+Sp@}>Rgm3_;Gib}w?=eTUjj7=3hM=V~Ac95$B^@8-{x2xH;8?P8OB3H<37|KhR)06AW|JBa-AW=!ziSOJ&!RW5PQM;S`E=FBe7*9U3` zp0f_(6#N0FtIpw7qzJ2zZ}=s0;V#5vFz`DJOm-Mx@G&g}LkZFyKRu{xOSDTKyX#@c z3_!qcZl2D2guRy!aCr3Um*9dP9zTUUyAX(PV~$Jqd6XfuK?838uh?pU{LHDvMFfy? zScX;E0NGV#e}0K=UA&TXB09T{mYq+W{5C@lRNwzmwNq_+ui0i6;d;acBhbv>aWL*Ya zpdp)0sz!kE9z`gGON^J|(u2(WhCfygn+e+Fphqc~E;?}G;}lGuGG;z_V~UHkvcekK z(==P%U0+)rZC4d((WdWt<}deVt$>G>Dnf5m(M%>h$1f&T;V~QuizugYy)cOkCy_yyEw=RI zF|cm}J?cOoB_GebWA`8R=5)y35#P5U9@8?_l6H39st`%h0kG;K2-!}@e|@R9!gBfF zjYmkyrvNw}-|`26Q#eu#Gxh2H9z6lSR=C$AygzD;Ls6T$Ds*vz^Ble?p6N@5S^x5P zU!%S0VR6mb78KP$3XiT&fERt)&ob#DDjhKW&(o;)S;8>cnmnW;0_3rKZH;qN`;V%q zyq|1_^RIlLl7eIX>wIAg-B@h=+{nn%c}jYVI>jBTub`ngp^@vTO=Er140@(QB*Jl; z<({{U)nU2QEFGv7o6k7wVrpv#E2s#kW%%rb$Rk)rw*ZTCN=h`PLH}$z_yqhqv8tg> zU6DAmbhkJ$xudz!OPGy(ZW(Fr^GKazxMAn?_Pm!HO`g+)ryjf(k^u#ig?O-M;R-ibo=Rvwkpi6$nc43*_nbykm$0C! z2rJ$kT(-n160G#9wiX>KZwUz-biCMgdGR;<%K>Boe!3EBbMi{ipW3C;vh?v1D8r3q ztKMUaG*Ye*6};ghQV?Z@MCy|7&iM^R>JYX66s=uY35=1(CHSfe=z^>WuDDd4%ObWK zzK{)`K*I-!TCq|~+tjIetrdniv?JckX>O=O)0IF)>{?%Ely~S@TY28rrBgqILSPP!MGfS-(f zlx1Zzo@{z&B!1mqMauw-^9@5F5bR=)9AF&^^l&MF-DySdE*|#wC{jii47Sr%sY5CF zX2f9Xqhz6VyYaBOq*O(EsI5)0i(*+UQb)?MF54L(D~t0kS-=MkA=d5Aohw6xn2^lG zQsgypDcF^zSN?M@t-*@)&|6z#kIPt+iBiX+U?PXCs8^U|6qQ)YvYRM+5Y1S~ZljSpIxv&8}oK7aDJDXp0K7r2B zco@8X!t^efk3v^fB_J_9(9Qos%&dDz6AC`+sCA@qa5HmxTA(tyE2E#i@Yjez7X`!3~$=xa15;(0Qj_Y0r>iX$2Y&a)l6ANGD>w zsYD6uinj~oh*E5}P6<|T>Ts1I76vD9?N9OQq+4o%#)PmiQ{?fBQDLPy8=9Ynt1=e$ z6L6helC!n+#XtYjsZ<`^)n-+~Wj9^i*4o_x%p!q{PXvt&7O>1SmM)bKd>vaWFE}Zg za@^By>B6m7vS5tGuVi_`7hJnLlDQ->N3&Nr((+6#G0nZmF+yo47Y%101Sk+xPx^$2 z$YwpBvs0VYhG40padT%@H#HO^3pvv92adn|Ib*M+N|B6{I=*pMts`+k?n+Oq9W3>- z_lG8??6zbK990@_3?XlGM$cQ>Eb;em=yi?a}+U2Gc z@5fy$6)U4CgEm~YujAlMVJ=E>Iyv$2)(X7*B=r)Dmo{#=fG$1{P~qKGl1SpQfb5`J zGC9?rOtd#|R0F+h@w#cJ8LoDAHQ#Y(ffXYF1sGOHboJjL(e_yky9=F&^(*)q@;fAxQEo`@;=V~U1~XrTs0 zD1YCnc!&zu7?z<9_eTle5I69CP|)EJ$Niu|X#{S3`Pbt$5CVE%w#Sn3TWPRPp?x=XTyL=apiO<&cYQ;a*9PRj60K+6}7tps84e^u~W%h zxqeH=jekR~ADz;mZ>*dy%}6j?*-t8v5x{qLIW*(PMDYNo6X19G2REYN$$S}7cVF9O z5>3#G2#>Rw=PGB$=pNiQKJ*Cnof9}kq{nKX5NZMGF`uB9;21AB<+uMBM%dAOJZ!q8 zGS?k!+TbW~@U9tbjcnLtM4}$%LsfG#+zzpAX<2{x zsT|vF-9<06Ot1oci1v?RY%H>&`8C$8HhdYMK+@D>IW4Cktv z)-f)XJ=Uev$5X(#XNg*y5z-ohlLY$@ZceR4yU9>aJ$P-X%D-t=$J=klR5;ubk{()K ze31)xWR%f{X8Y|;Vqk{&;}T7q0fPqK-;=TmVv$xSe?gqCuC)mHk`NX^ovKK*Dp_zT zNmSu71dJmEuYs~$VTh=qIU@)7b953&Me!$pusj3I=~lIOn69VQU2=sak}C*&9j8~G zU)kfyk`=@qG6uvR`}XOh%){;!p;Q?yP_v9p@*aDGM~D8l#|}!aK>br`|`Qd z+K4uID%T&UtiJ-PPb^ri5*yRv3Tbky&f;Fb{?ST?8Oa~mDAv>C2Cw|hC?^VDk}!31 zfYdjViSQs0>cwE2YEcKGMpSN8vKp`geJLunBwd;wO{f`*xW5nyX z_b`&7f>uPR@7b5mXuO|OcDQo_eFe{7y4Ar&lT7H34}7m!3ARw_Cj@9@MS-s#6u%K?@v z_tx`%oEhp|HMN_r{&ZiKYZgs%=crz7I?Bce?6D5*xlv?tVBJ z*%jHFm)6!mWXWd&5ksPfC**TS|ClGl!^*M}a$vy?$!cAp6S7VxWcY)xAIQx}?bWF+ z@Rmzo;75dq8@T!9OS^T8$Aq|E4!ztFNuDte@uMvxNL(dAn)0N5nvn}#9i$m~ttopw zsbB$54=87-eL-7M8N>L>)OpycJPUct&@sZYSG?ks4Lcp9$tTZ=_co8+V=bc{C`zoK zQ(ucl(=iLNsTb;eV0EI4Yt*qQ?J-Cd=5>z3O88w*_;CT)MY?OnJ<4v$#u2j+tvAFn zi6j2Av$g#}rF!#t$f5jQczBFnP_(1xpzjVlBi_9D>SoAr!|b=qz`QU|UYyp(fSDTg zrDkM~-^@IJ`lJi828+*b_zWW}fw{7-NCu#?@!j2HqC?`w<13<_n*lTHSMk~TBEnovR zzU-M>M=-Ki6aiDgq0<|LX4kC(G;ipm#sNCQAJf;wAQ#=buwn|S=-+0E8AN_gHgAM* zY0Uci9vq|^^$2v=w1QhtA`f6xy!)}$=helyvKT~ORUUJAeA&mqzDsU#D7{M*kaCsD zn~I88UjE2q)%bN-C~P1nH#NA2u|0;Y!)z>JM-G!gq@p#dH8rXXad*t*Q z_DpsR58g_yTdQ839(yDsK8|n8nS*riN$4+GTC#x#M>||%j;y0WI~);JDh56zl`Jg; zp@044W8WE)1-HrpQITRmV~0BVKwQxE(Ko(LHx+N!ScAsaG|LekTjy{o zgiSL^uPi85HPPopQk~}WV0QOGO=x-j4-vNVK1>Di!^d6+O?&yOx%fH7U69$GgQmM- z40`DG1>!EwWIXm9ZI|W3T2~BC1L1I-4+}K;yAr^kd?YL6s-Pfp2~53l4UnbkJ7cv;%ywxTc-L_ma8eV+6nc z#%t!EydzWh=iF}$X zQ0_L6_}eT?Xx5b?>`|~r(93-3lVc}e;&!P}YPB$jgFG# zLvZO2ztf!#0lIJVg0Z(`a${}f{@y+>Y+kc&=AnnCV|;2UFVdFm^B4`R& z+9LaRW^}l?%qN(NP$U+cZJ7v9nnNCA&~3|cr|rg|n@_laJYGDPco#UGC<#>xuI1e| z>7Fu(ZM6=P*ZZ?~nny;koPG@ASfSm5abUZPZ0RNGoT3ZLqGNs7V+vW0Sp9o?8Qj5i z2HQPLrPlHQ*NuNt zY~%aSKF3=C8tZl=8V}!`H?3RZTP4}I0D4Q&zxA`nI#C%%JOYzM``-@rDaK^2Aux*q zk5;7I@z_n;rGt~igMQGG7oES~SV|iYJIvCXOzmz9#h0uykAooznVyb=WNalRkI@%z zY-n3C%u~XLJ()-pGi{Zk7UzUG$CpK2YCy94ma2K*BD))P?Q4+4C{FX-72p=4boKx& zGib5@!`!>TX<1eK<6w`Jo}{BGUQ&F;%Lu^;Bg4fK9S{^wpbnQ5lW`bkU=U_tE+A-n z#H2K@R9=sinwpfRq?Dedynw<(~ z-e;}7*Is+w*V^Tx`hGqBd--t|ep2kZIwpjIS9QX%d0=BLj10kscaUXyr$1@HZOp-`fT z`6YncJC|kdbS^%IWf9T1>h??aW1U}xX?mJ9R-O{q&Z61O;1!1Hvbag{%wKmlmn9jk zE!X{R%VcgjP~8&bBr~`^Gd3*-cg<@vc?1?WLX{!n!K!V1$3~+zUx%{b!Rc%RbS^%0 z^5lnG0&c&G4kxvNybUqnw^K9wGAzO=W~wOyV24s0Ft(6JtDH^7iUd>6`P(jCAwj&& zJ46E808ia~CtPokVEL@W_U$SO0{(xH1c8zgV##=spkYq^UR@zUARojly9~ksbdd{E z62y!hNHF#EhPS&)f`I=cCBexu47Tq7+bsuz8OH|sP^8#xa2d)D;DT(NQd^z(+jfkD zWU?%`m92dzj^NdXnVlCjfv)hdh^%i5{h}tpGl6 zYB_B(vLigx&?!6|eR8UV?&{v7f5Z%1y6fV0iv&%Sx<_hq*NDG9{)55UZ~Y1vvs>c# zc5=phr;G60{_XXv_XC*GY=tOdki{30K=`IHFFqK4+YZeE3A?KnTW`DK@B_)#W06|- zUJF|6NNsu=E&nuFXxP}0pX8GYK#Mv7r0Ikv^VN|Tu(}_#Sm2DpkOO;9@3 zgCTDr;LbZe)TXm(4al?q*>nJqTf0Q4!D&*33o}qw_M`p|pU#H3Nv*ctr|F1Wxb+H= zwC$r99-M;F3wsao+4?%oYiqGL1$>~s33o(&g3K_l(K>kfh9u&C$SiT|F>nV}cJZ5g z?ahJt6*?l}F5H%97+?n?Iva3fKWOss!TQ2Y9T#w$vt`}8d{Po|KV`MB<@!gM6L@L; zwAPv~ZJC)Zv(M_WIe1E$0a*cE@vTDRVYB3VTt~Ra3(iU7?B*UFY z90~{g&saSiWRWwExWjK8HxeSoSmD2?^i9r}Vjfd6;};C6I0-Va86V7Me%rEyba&Zc z8xR2=J?!;wU7y0r+en0EBCIVNz;ch3GRcLC?KRv#>zIjHTG3=GM`Q_rslM8;QE(2NrT zGQ`Z02Fx`R8J&jC| z52(EDf=zuREDI$DSXq+G9Ac}W)tzN5XOBTAmr4>We)Pn}3^tq;f*%Sw;^$uQMkALl zGYRX1;@s8^wYt)j7}qse^C=i?n0nqhdomgjV5YJl!qtE-!Zm9s2D4lvgyL0cLU}c` zmy#<*%_V0((TAml0X)MZm@gBTOa`zObMaoy7yhFb>e>rJtH*BBdQnyfSjCpgGfr2^ z{$HPdlKw@^l_lR2@9w&5%gc;e1h9om?85jG{)F{A&8^d=CBfhzv3pL+*&O=nKo-3O z9L1K-W?Ad-h+|T81F>>by~f4-8Lg=l7wzL~B_!XRe9LT_Q2|X?wh~02EJpXD85L4U zVGy3A#ChR=ZT^}&%sNI}dtW|oVFq_sw;049glF*L6^)U#VL^`^7eTgb=QkFRyTOu& z<-7h!(Je#@5poWlWZ^z|kr@lPr#Cj@=``a%wgPzlzdc|?(PZz7W19-hK>{uBzxXXh zw-6z;P+Wi??}WjZ$?NdNkIk70%tC%_*D%$}^i_aQ&#{tZBxRDC)yB{Td&C3^z8~M2yI3{5EzC;X>4ht$cVYH^OgG=vR+0nXzyB zJx(Ev?+%?*xJ(boB%QMFNegrSxuY&(mX;5tN&3*>|NB+=t)t>^gS8$jgp&t#Pn-6? zaSiVn&az_salw?#9%b;J&D=4$2pk2sRGdIrOZ9N^$zDX$!s z1vJgArEeU7*^<_2NfYirOtpJmTT+0)a4gmSkb%suyqD|WyN#RD`H})KhE$_Lf}M3s z9v4rHSn|MkbNE-WqYM&K1gaq00y%idAo9f2lX~_Gzjc~`>$zztqfFal@|5hIx@WFF zoA!?PT6&x#R;NRZsv?smW>yf{s!|oa?}j)(E6c2vz8huuicepA5f9aSFCA9Q#1j@& zCJfj-SZH$+EclO4%f5N#q+^y+Yw_(m7 z>o$i<2pBFj|B#3iS$%GSrTEi&An$C4e8JLRdGm=OEZ0URqcB-zSFTN2iC6#R%`h<( zm}Ua!&`N@mXT=a@mk&OIRjaD2blCthM~M}igbR~!F8pI}I~Zd2V^>{Hfz#>y&*D2N zOw4F8KTmDI27O$@F@!zB9 z;~}Nxhf#3QzS;;p)T*VdEEk7n5&T$IY^oQ6JOPd!N?Ux_g)4?mB98VB+!;0Ezzri) zWms9R4vp{>hkj!6?@tmk(=Y4wA*1f2%*fFDE?$Uj*;RUqXh)}z=`nf0$n>`eEW*b3 z0&B~by1$*gCegZ}|2M|eu=n0&Etg`;l|z)Ggv4=82KK*qzd~MKxTcW_#}Gt^eM1wpoe*@nIONf z3&KS|tH$WY3_(c6!Mw3scDnW6SdwqkmfVoc!iriZz@m;1N2%2%AIRnGSzUG!UCwlA zitkz?8f%e#32vAdViq8kRoQr!H3OGqWZ1G;xgk{%_Yl*OcuIZ@+KjFPZtDoZpdJuL zkzhM|P*&C{_#imILba3wdsYpqRgDZDDO4|SDFN7$(qy+aG%=P}AAJ+e zmpN$DE(k<`c;S+OsBrxl7sYkqPjMUU9P)%u+2CPJf$|SwukZ7xU$R)b1Ye|eW*&w> z-X>fE5EsrNy%~y2{g|eT5QJF|Cf-c;B)hMn%$@q}|ND?K*R@!BUYvjp+1xfd!Qfq} z($e43q|bA#GuwfCg8e{3G9$Jnxuf6ohzsisDMU`b20s^%VqiPGwW`?CFKG1(U-%${ z&)gW7uE}0Hoe2lN*3wy}JnTxARoSpP*RzlCYG_x`$LL9x;HB3EXkM%W-B17t0u}Gf?FLX4r&S;E&%$cti;7_w%1*^^nb@YOt-(ZFjYcS=Sog9$fW`D>4*0XGv2`NXp?g2QcVt>ypbHbMuD+U zc6)&_ID{qw1oJ^dgbB4UFd;YsoD872-I&iDGr+z<5XJUmRzO%&13pTZQJVDC^;tFa z%)PAL)kZ}5zQJmEE}W786f;CPZfLmxJ8wNEii4KMl7Zq@f3tnxt20+r*(rEu9$>|N@oSte9x(E zn29QuL3%ZfuTq+`Uxh1Th9KiT=((mq>s|ZAo=lw!h8?*D`ygHnj!AHsXAHvRLQDh~ z!pu4k_awt5gbw@h9do7t++@8U6b&hmqgo{~c>)NUS^i1v46m96x8weO|-Ppf@jkbp%a*iuTyS9Hx)Da>Uh${T;p{Hz#UEa6uT8;q{qvM z8eE3vs)>l-%`#OA`v{`4wMnrG7}6#+>*iD=7!NlXB;dW`pYCXmT0QdBffwMPl@v4F zd8U4f+thW6n==52`v^xXDQalpF-3ujDb+wNpE$sxtH5{%%2lgTjplxj@kKILTJok( z{qUSLRtu>Q0ij9cz^HAcoPodb0qM4ostTmqn(CX+u7s5Ej@R_wta`6^n)EfUdC*X;j_!n061#JST`Au&B{ZiqT(ntJEi#;gisGL z=nwR&C@;h?E#8=31MFZp#lrMIzVz%Dg$w+L-Rbc`;4;_La7lSbTt`GS17xSG+9see zSs2lP5l_~ZF23ZAt7xBhTmzm8@*UVvIorFrrP+VDWEjmY_?k=yrc4)u#Q(^KV7p9~ zq}zDQ1^pS_DhQ!F`@j~pmlxVZv&+h@E1OB9{r>j!*XSh`gwCB!90r73LA1IwIaR!T z@mIe0eOi3rV%+a=31~gnB9yvheR_?6wivXrj1 z=`$#S6f9~A4J;)u^jP>e$S7Vqfc4{NY*lUsZeDWw8io#E`f%dQETbBg2{V#?tcDWi zeYtfh^SAHKDElD&t%VumZ%rZSN+;FQp4-;WJ43le ztoiLyn)!!k`jckTlyAQ93u@1bT!aw}wO}D@D0`8!!dE!3dhyokYtB4k2YE}4P7r*q zjYvu~JAt=epWA)Pj`Nm!n^Lgkt+{J>i`M_&lzqt%&Hy2FVNnU2g_o*x(GDwus?rTy z@Pq#d-xk;!7OJxEQ!UHyiF&@M^yb;QwWEM~0^%cTLSw|tQL6o(A%=OiH7=H08Ssh? zhEYtCLZ(lsO}72$`MYRbPC!%yF!Pq7UV4TR0!${-8AOBO$(141=c8d*mnj?Ip@&bt zhc>{-!u6SxVu~S~T`ZW-F166rP8@6`TaozE?qA-YW)N}b?Za@}lB1;YY-Oh1#sm#6 z?hd|^oGeoOVZT?u+Jnm~Bta%buIojEMs0gp(v+2>Y1jV93pwtJ<-BZ#Tgptd(=`Ju z3-)BTN=8e*ted{+7dM^Ao9^-uL;G2{N52ycq!uA_L#wBQpD>d%G=r3@+thfs=TUnO zJtD%$84T$*hvAN9fMNO&jB&oGvh5ptzs;(@Fw`yElcp#K0+5XQh0jCwApScjLw}4s z`^uqb|AG__u+r>khAxcn`gVA*jGBgjCuR62xlo?&m;>J!!AN99*uA^v<|0z7!%uU; z$Q63jxQY*sS-LQl6S!P>>)QU%)9rWn$P*aS2(S_3WqcuA{`@L7!@=lez@dl6gd-D_ z*TG&Ss&3xA`$AHp0+w=gFw>>V{R?I#;;6MVp(s(M?a}CRZcxWZ??qx4+>Zw`?WVte7N2_*) zPZn6#`9yjj43U9l+?#w$)SmvpUL$Faj515daxT2t0Z^V^k~)g1iS~bj)w**<9&Xs@ zhsR8aI6SYzn;!t=30o-!^+sf`T$`U@7s<7{e!DLC@;1iGz{4!7oDS*jZ_3PUrN`?U zmbq_mZ+IsUPJNo*Zu1Q`FpNuI_3vx$;nFL7BGdr=(pX#2LyckzJurBL-sT%F{RY_r zW9HobUHlt;rvigcJm6LoLxjN&#KLf2X$y?FqvRO-78G67P}5F?t{}b$)Hmwj&;9m~ zf5Lrei>95mHR{DvO^!*>6E^7*CLLNip%AJW)MNdu{a8x z;K+QN5gAm&EwUM28U5*HI}|!0*-~2zfgZnr9RyLhL#h$};eeu^7v8-2Kk?59+iY*> zM28l3Llxs!b~}P7@uSk@c1fC@Zh!NxW3ik?^|fQMl{{NYg^73@9%< zl9Fah?axMy0IU)L%f8r>Z46RqfWe?lB}WDtdHoJ8ZR664<5BeOB1kvU}<;jE?o7()w52<(p-W_%0X(RH5CeBie!*U3Rz?9t7{orK5p-T zgJM{MWQ;L3NRl4&ez1ih>mW%?hQMQuSS*MkQd06Ua9v*$a=NgXt6f9Mo%P@c?!dhY zSzLGMFaPi*fCnF94Jh11(Atb;tZ!iIZF|kR*#{DaxX&=ftFU+ezl@(oxmnA%MXQ@I z@2MPpulU21;?^hs@tzKwjHd}?BnQ$dwF?^FI;OnCOMc2as#3ZE^!)=Qo8X^ za-WRQl|8>y!>d=3i4C&EP*O4!bRrd}U>X@r5kV~6gI^SyBd;#|s)S~TMWOitISz_U z9+0Hj@8(OsN=Xw1T7E`glsC$N+}qWWzEp#R3OP|n(+^Vv{yZF^_k81=ZL!_ zVHy@-$>~rQ-%+Oy6|7fwwH35nL-L?b;nrh5yoRBM{B8*L`{t`xnR}^`j@{sabo`;E zc3QlUEizkvbiqbS)+v5j}yqsw7{DEKRHKHtPI?ACMTHy@< zB|{4;1}ociV?Dcu?YCsJ@LN8bHfK(l=aH;as;^JC;TIefe@+u3{JbhYAVBz06(A6_ zb-$I*v)-&2iue)mB#%q?PkrvFU-peZ&qJZrLzu*`h$?yoVB=+v)Vz>>TVyv>Cz+=X z;!S!=ae#&|JaQqU%9$tm%JWS35P z_TU+^ans;Kgvm-W0F84+<#J+zV}Qz6d2k+p3hF#9>b&>(w2dswpG2V)-vohCEENF@ z#P~}x3*`>w-YMFRm;Cix#{Y&p=C{sTsT(4F1*5|FO5#V?gO~gnarn{+KmCxB+fg$x zf&|-KrsznYLdIs-OS%jQNm=Rrl1EsenpuJ;*8&`0K&<^$cT3)K@Hyu0iW*C9Yn;!4 z+rFd%UCtU8y5e!p6UgBK8c^fn6R!JX__jDTko99N7QdG~Hf~8?6gB!id;B{*=X?hN z2QU!cwx0V$ls4GdnKA>cLDGx&eZm2y$D}ZV1As#?Wf7hYO-tX?G6Jn7d07@j!!N$^ z84|VArHEnudF(X(U9LV&e9r@#@aG?mN?~=fB(ds@0V;hA@Qg^^l0S(`BSy?x%L_VI zhY$}Hhhn^eoGq&~(Kv-xeg?noY3s6bbv2q)oc6=d{4T;sB_0mOw4@Qs7GZFBgZQqB zAJ}WsQGNF7{#?5{3gx4d5OhNwRMb_t5FAb9&z5BjaMqgok8#{JK8%+Is8;U@0|*x{ zeUlqmw8|-uo^%1oP+Ui)0JBY{#Gf`%W$@=pG9Szmwz* zk)+=rPWg}|8J$LXp&&aR*_+us>rSYaiq7rLqen}16-g51A{~VPIsN!^@^E93osRB} z*gKov${O7+(IR=<{QchJJ~I)^fe}@I7P!-bBUx{gk%ABuR#FZ@D)1Pp53ug+Lsu{? zCHFKAwA>prnSNL@=MQs*19SL+!_8JjB3$xka0GYRaF90H2eCq+nAQKMo$g9Hc~_@8 z{h8U{{ z^xR-mF>2-kh3zh|Dj)3n1ceGq=kES}kv-L}u%0CEeGu zPGZF?#~%{VLkG3D<@}P39i#zIJ(}RS%bVUC`kBQ}?$l;2y7jzLkB^!b$<89hvTd9H zN>ZE@s=zbxQG2_ZWe;SHK~={!RxN65g+QfS2x*Q=?3uOvPcU9gAq61fDxfzR1;91 zYZ3fUs1es9P&utd9FxN;?C-G(tD(SO!GLrz?A=a;qHrrIj6n~z{v0m2WtX+IJYHFO z!M?n=T_O?QohOavvVWQ3Vzj3!4$@fq$73@vy0%q3JkPxnm@V;`WVf}fBHTKm|DAj^ z!>t&#urOZ?Wr+d(bvv1NIvn>UL&Bq2}(-a34c3;aV=Ij5nzLXDK zQ0HqXp@a&oM)W#UV3=9L5ISQV*(pg@cV+Q@ynCY(ct-jBA#~Q?_x=01tnLo5EuIgH z(q9dUuxZDD7OJI}a5LEUd%TZhuuGG@M5SK4-?r&sfQ8CNZ2(p_FjE!9rpuXFw@75l zmXzJ;jmNG!B=b!Ub&P3>(b_BVSq^bUGb{I9OLt*H^PlhOpZSb6bm(D+u4@;k^4I)! z!|tbimMy$tnK{(V>Dx|lCkL#hrtGnM>8Z?hR3Pn2TQ2U=>Nx#5W|!KT3=QNNG}k;t z>+L$z;LX#`I3c<)#4+I)2fU}?7Sgc95h-GIsQ&1(OXy7I1~&@2$#C$V(Fo4!W;cMl zB?sximYw?Q=UC_(C;{S0^u!deT4m#@4I}lI|M~cpeKX&PXc8FltG1_x-zSIaa$ok~ z2iMbJo@Cn39X04u@LWqhtXkSPjdSNh3QC`AJ>nS?>J$(gulnYZ6cB{r7uy}R6b?Dp z;Fz(Z6}r=y^wmMmzVxm0CIQS8y-FV}qk*A1GlkX8n(}VGtZl?E;8&HE53#5Tl2-SB z<+*214LD*7-MoVBS#!R$kX?;jQhkew3{ZD~w9BSOy$dj(M@MI zutF!ZL*C9dnkYCz&_r1whpjF|@{apds0R2L>uQ>K#L@*3cj%|d0k;K7!-yy04%Npv zapM*vNr5U#y(m*fS&0c6a1~?#OuYadl0j=(0kXQK+Z`jZ3}(;WQm&0IM2uU%$}%vu9I09v^_&LN+oo z1Sd&yFge;(N$bq1R0o-@fSL+f`FxMUX%OXVMW!(9RXXIY?6ziX5X2s1Fkw+bn1)2Q2( zd9l-ff3_D>8>87mnQ`DVJ&PLCXHq!U#<4=f2Gpr%FYiMv4P!RJdsi(e8Sx(uJ3rrz z)tN95Y?I6pHtj(hR402Q3)`1IRx0GBtN!=*RFs(mqkyAP)W(9&$}O6QeD#_)dh;HW zzd+d;VEDmNSkW~oZV6|_k4v3oybkh}<7f4!QXJLTiWDAK(xHjSH>LH52f6K_Z@04pvw;>6jVO~W|#pL9*EM>fN^?&W%8=t2E^`OaG8%)fXl@EoS=5Lo=`{K|v_|7X6 zeRcn_dF#n!+ts#Xujk4N*i*7n(Fc0sFg`QSWta1PW8I8XUpt1e-~#rPOjJ~XJ~WI= zewztkV!r8GcH3?G?5VS>@Hwj9!sBkt4%PBWuR;qi`~dTR&{J_%J&Y<FgmVc7GXpOSJ@dweMt66&TTW+mw=z4o1*O&A1^yy zTde#04c}%xOJKpa`ZwbVX89QU*HUxx^ep8V5}RePReV}gxj#nCEuOpEC#q*;a5=jd zsM|u%FU#*VCRGC^QJSCUG;huk%OXjtbyN4)Y0&!&LR4nn@W>=9YdV~JQ$BPkjlC7) z_WXCM1cPC&X$m8k!hjrXJNpI-t5&A&VV9qM>7f8Kxj~X0Y!uj|sfm#WRT{HQ3T(k8 z(>K{;#nuPto7gwGl4Dz%`EV?lZ~$_(jmV9}&m^FddKqD3p4saR8e!)6m`w9X7Q# zph~+vN{|FGn~5<1FP*UXH`jhdBtS@$uA?oWGfLo>XHeX=%P4aj5jaXo)hber{P+ns zc8yfq9r_B9f9o1y^bDkO^H?=Ny^sCj84U5;CGGU0d{kjCR!4zOY1k`SBvMTH=7v}Q zm86i7jqS4>YH`&Sm1qB}Nr9q)IjwxiV@BiLkRtf9NKtz3k9Q?0pbzD3j4HKR?^Tmu6v?$7t&wj*!~u25Wvr1(;0>*`Urb-M-soJrdmdJCpd=$X3;)5)xQ z+4)SyT1+fQ{Qsn&X>D`az(M7C?30`?L+#@F6?f32bdBQ$p`PRxq{~U(J!g#H#6CJ52IYgek)T%HvY;{&N%dKB#MA;3F{|>>X@u zgS=n%{#Ab??{`rmek7)Y&JMmg9Dy0AkEc1I>QTDuvM?Z+Q4~oJ-ZBoDYk>n+@;tdx zB-{M#k7_;wl6BSO2<_r}@ATZUzZw7ikBDU3V{#Ob%?yz)6As+=Es?JBrMjgb0qMGG zs_->&{po&I##!r6H!Geuqym?zdm< z+W=B$+oU0p0&a`KgdX9eF}aW7w3-m9fuAlNr}R%XzOue&TY!xcvq?(4v!*w>2*|bM z204K%_jzbE!!=kPfW}=J0BjwJf7?Nu-C-Y4&U6AdU%lX$)YAq(yS4y^Iec43;Tok0 zZj{Tt__s4lr**uPg68wuX6v{f%g$xYXOFGu?JpU7Wdvi~lp!^YV064>M~9%8B}@zO zWg5`i+gx&s0sEro@a zI389IB3A*cx5Fa>+~juMm61<>{RuY!J7bt4TiLz?IlWPKA_S57U)VHU8W^VVL7vPlDj1q zHeLSp+jylj;jc8`1kuPKsu{AXVTB#0-5`iNM19;e6njt14WywMVC3xB^3p~jF_6Nd z{bg%q=1AhYH&`%bw8HnRpa0$$vap@12o(c>A zKxM0Fi`*GIl>!oad$Ddc{>&331M{|_$)*dQeDxZ~5vgvCelg@38)SXAvmkJvtdp=RJLl(rKLekE7iHSO5rcZSLT8H|3|Ni8aQ;KdeJ<{#kVWdmh5Vl>@aH!nng`;Vt6x~`} z(GKn`;3K0vC2hNG%JL>^+mixI;-OQ%D@_t9!LBR=TPX9kIH+nY-_piG^MA08;Py8n z67 ztnsTK%@#RTv0xrp!9u#?n*B+ZZtt?@{g|klh?-gGG75=-(VB;4gVHN6=XFYR^aQpR z{Sr99Tlc$Y1SO}zkTK3eNw4}iyt%JEO=VVB027NQ!CO=<9W-hd+77;JomW>3va0qmeZr{0T!-~;B0ZpyOIzadcOBHj?tZHRDt|i zfRSdj@U7WR^bg4GRq=V{lbg-1Lh+tO_>Rlx>2Ik`$1O zvo4X94z!&SSdYcC0k-H(=SxRgntY%oFCI4K{!W*yUZxpn(C)&LgG#}Yuln7E4|clb z0J|$o?*1q>)yhx*?!zyE5>wb=)#0lA{G&@ELKWSFJ@IGE4!ZLONzsov=j{y&+`6mI%ot^}7B|AS#HPC1Dkj<0vuhNPrnhs}YeBs-1my!93?DP%k!|XQ& zd!nu(j&tuF@?^k~zbbG%R^{7343h3liX~I2o5xj!dr5ZDDJ_2cqNX#7Y=z=Ayyd19 zZs)F8LlY!2PHHIqjgc2W@&IduNVe>jOvZ$l+h}U-!2VDplz&z@t=%4F71gqmOAcjy z;V4zScsztTsWyo{Zveo0sW$j+zhA$&B>lFt#GV!TQ`RKA3-#0i14pNycZAv*Un)KUFQHSqKlYJkh za(nNv@i$O*cTRAXW7=#%;Fi4S?g69n%5Tq4VRX8LSpOKc&;g>tke}alI*UU)Ukbb} zduA33!H7jr;jlwSeUN_J*%II<>3cMCAM_}LRt*(jd+T_hX7C`GxS{3@mO8Sb_K*pT zREFt1m#^?s+>OzP=K_ZYFmHPMhC`V#Wl$l&3?*e5n4y?D)CchA5vKhUfZ$JZsHn8$ z@Kbv9#lK2^v6(uJmZ8>7_6VNMoDi4k-;WH$c!WUa51GMz5AAjSL;J=V!MMH!1mj*Ec1Fo3E zS+6k4r9&BsNlWiYO?ej}1<7*m2bSuK&`O#Xi!f_y!kWlcDAvViw8<}v?pcsJP~%xT4DT0 z5X&Dd;?|?%fvxx6ntNz&{;9G^I^u^jh}U`zzm>rBM^dmJ)}kMNTmVm-{A#bt_ySab z%)}wbzWWl?510&>Jl-&W-&?qxN{pSbhHivU8&JL3HPJI%$-~bMJA!7eV94As7+n=L z$KPKcR%w9td;dHCnc~V-8?G?_#^jX}vcktPft;I@4PXa+R=C93M1~K&e8lIvCaDmE zEk0u1m1M99SXP+t)~0uc(gF?78X(VGt0zEoOYL%zkLnuf->?Zav>k%M`0Di>K!!{K z$6=>wQz{s2(^30B(qOm?$NHsefz0H%B{4F%b-Bn_5qD@yByWjSM=1uztTxNB-KR=M zYwJ^5SI*(Nj^ zvhlF>3tsw9;to--3~pM4)z)}vU`6S`vJrROYrvfoZghtAr|v?D9eZ*8FqphokQ}hn zMvLasWUNRr;fC%jNs95c$PMMcj}nKuxLVGHnvD0nl0l112iYSE%e|woJG>hneoa!u zNbpIOP0JSa9er`coej6TDzW?Sdt&>2eh@VKMP`(}Edaj`3y9;=!{-VfW-099(QrY= z>+X;IVxJL=$#;A~q8Avi2e=8L@1lb)QLBLg0x=l^dO$`gwQ={>rF1obtAh&>eZYtd zv~gVNj7B?nC!yZz%Va~s7Ejg|FFWqZU6}qYvPBsRJ9PC2`PFCER!`_+ix6+U`<>Q@ zK5vmNx-GWD*BxdBu6vm`3r~KO6kR%Tp8<9Mysc5H9SLy`YZJSKDMxdqVe#2@E>a6m z@uS)ztPn`FBcnva4q+Cbsx9wy@$<{r@KNM6Q>pq9NOWx2BFrH3Wh-ZVog?OooN9{m zBar0Ix0w4b6XB&ds-{q?(pgFI_~2!Q43%+(H4kka!b@0pH1OarmEC*s-|4W@+sMGk zy!rze9F_YQb0Sc8A##=ELg=L|5-hx(u zHF_c|keO=G6Om+hg@u8SJio`t%8fLW!xN}b+A@qCJ5^fHL{QC!J@yG_uv=A`VaE(t zq1TIUCAI)1A>t#IIE8viuygnM%}L_!# zK`Ryt4y-zn1FG~Cbyfje4=0-Q?049oR84g-Ik68nL+H0HuDrh+yEP5A+mgnC$e99x zy>*?{u=e5wwi*!FDCKH5P<`q1A7x!bzu7bX$~eC-9R`R$9#SwmL)Sy&GlL~I2phyw z-ArH%VfnYecm-$R`BDk1{XR+Ele~GHyelfD@Z2Zn*K3JAuGsih{->@iQDx6`KaGnl z1+H~kn4|13FAqO1!Ya~NlGjv+s~i9Z8&FRgr>jgBDIzni>~Gg`=VKdS@uL~god-rn z=)AduVCI+qU9{Q22z8CfW0ROM`~hlKUVOk$8If_A!Mh+#GHw=T|L~WlyK2OSa5rFd z1F#F^iAkI3?xJM{yZJ_TSw~3hZAc!P*mBILC=P8^bZb`8d z;NuK}*T+qIr6t83R$XjMr8z_HvNkM0{!#-f__crl0O4o}+Th!!1aK@>Gf!;k3KPQ7BwPE(!ygkvFn2 zL~|Lc=EbgsD}n?R{wv@6X=0rS_Q`EUEe!p!Y~^WsPHU{;-i|%w0&*M}ku%URS9N~4 zVG%jRl_B?Ib-wMET%@fpx$npQxphA&EKoHeS(ck;0Ws{VdhYak8WeV1tX4|7)x33g zjj?M;3#7HPZ`bE4uzrb*`RWx>E!G&C9Se6)723Gwk9}!|wZF$rFb0 z6ggZN3cyZkED|*hJ02L|#8o{qa;dg{?y0x;;npkimd=nFPtU7K?G00`C)RL>_jxd) z3gs(Ou7SjnFbu8b(TZD-Rs-(rt-E~UxTZqf(X02`QhU!7xTBU0xG|ZExXIoDcO(mI z74j&Rr^JHTx>6+AJmc(7caa3mv()A<+tU4RZr8Y_wP6mz<9^^MBdNA9!W@qA0Sjn3 zAVE3;0H;5)&ylar>lz6Rx77d^sb4bee&E6`QEu2HF;HXL0%DaW-;#0}_sdnsGu%JQ ztP2-Ceu7ZKncB-BCV8=ohp{kgfU3IRpVxghg&9OnU{X^fp@tasmH}?N;i}|Xsp zZ~1dZRhu-$N}%jQ-7J}wfnTiAomy31eCB|mYg{|~A`_b+ z+rY{|hIR498&4>}!Dv@-?pZAgJpj9^`>;ltsP%^Bayi zwUezhw$y6Y7X=`Zc%G~q|#TxAs3idfQ zH_kr_KgFsrB?i=*vU4(-Cs1M6E5a;6nt(oVoDvdYQaJD#xCqmK>euh?M_Igl)On-s zDgoF@lmRitr7T`kSeWDktC}$Cf$7papMCZP7EALRzy6-RNWYUcVSn!)H+bn@-T1Ai zym#j*+(|mZDYTNZfFB&A+yL)1C(oW)yJ%q}-Z(MMZdFa_p<^wr78btMLu^>%a5|i; zzIW|PW&|o?0tt5)W+~QbIJ|fQ_nG5mOLC4SM2uDJ1o=fY=w9PS!uKV5tSxB6`yM4l zR=ra5dH$CQkT8-v){b*0b@fg8)9Z$C5Cy;8c+;Ow)VEH`_EIVu1ob*wA=UCyK)#|`w1{?Yh66;b=DN=LeR0L@VRo-`)lb_ z>#Ck6_(?b#EguA+j*m~l#jB8JW2qwj^%NQ`3>Nu#VBU>klFrmNBkw%thDioHFpxu6 z`mW8TNip!!7zSKb-#PS@_7Njj-I8^Zb3<;q;}{C8xCQ=9YvRoLOzezyH0;{6Ithzq z*cDNMtoMo{Ju0x|ZIx`11k}^I`BTol;ysFC zw?h>w;_0#LRjDy(H_@4n31cpJLY{wk^QW%b{nGSXi@S);(r=Ac%*C53*0;$svMXnQ z@{jM+DRI=My5ZD;&)_oh$yy-HE98%P7m*=4G>CX@Irq$mSw3p8Gf$m&>!cBzr51sbv0 z^#!;I2<(N&aJ^H-LLCgWS@N55KkXgIwVJT}t!`AP@mphE=`6lFuK*XkZ~Gp98L|QHZ@97U@Q<)RDW3)63F>+0ZJ@P38I+ew{e zi;nmM8|4TS;v6$%==l(4EFhskG?;P|ylqCmb(oNW$r}>&{eJf5gc^XO);ZDYgLJFN zbJ6mo_(wm~qAHY&`}{-xs5xIG;77b!5YLbtc*mTgD;4(sdONS#>qGzE!t9*1lp%GS zj9eJ8Qu-5X4p=>5FRaBoey9~Vaz8*aVXvE(ysZ#N@8`!aR)_-uQ`mfL$oO-jOW zL!Xs6Jj@^`db}tHh0XEYZpvoRD_RtPPbUV7-Np-rj1hcYO%N#=FhE1eExQCy_g!hh zRbTq+EnMzIxFHznY=_H2D~ViqS@!g)XHqStBmp7yP$8rcM;(kt+z9y-(_8E3u=p}9 z(Hw&(sJzHu_Y5ph_&Bj{xU>}+aq4%Qo+Bd~uCy-GBoB-{!)LyQ@C^VV!;*3(0iiXh z>K;a%siP950^S37jFj5r6AyfkZgV9$-|`HyowOE|1ZX-twJ!chHgEUe&{_ zi0K3M-D1U-Y9q`M|MRtegt;RsD8jX2Sbzd*@1~{p{Qc(FsSiL2QveJa@h!d?NX#Rg z5Gnwpr2!%T7A+MoNN7k@1i)ORIZ&YHm@TUh@ojguM1!iB4EUnDod=BedW=bf z`-dL5Q)luV;$if}^3W$pH?KBIb=>bLAUc)s*iX!~&8S>B^^!w|t^}DnlIIW=z>RIF zf6%HwQz@OnUYhHgh{xhnwUFoHZ;!~ws=<)^>a?$V{gUsnNwm_|=slk?Hh@r@H}P9_ zAsE!#oGG`{$Ovqdp&FpTyR*9FFB6%o4KU5!O<_tWFgIr~!4U?t@OSQ~bxRI-OnlyL z&%d&h?D$$tghZ#5RzUyMs0*Rgval9Lu$5Vs+24n{a4pdEF=3T{rsv;j88|G%YZ8F zr{Ab&Iez0tCY$lCrkLj1p+eR3#dp7UXeX#d8DOHCBdQ6Xxa5iSoAy!hI}$zVE8dv4 z=%!uB8{7dbADds#dia)w^^igo5Y=ILg!an^FVs5^nGo975dy}h^}ai!KwCb$^Qy6c zX|x7d%+si?p&N|XRS32;2e{}k^64EaZwN1kt1ljQLAyt5gWYZN+L*+{_elL5L>IrId1PNQG60YK)5NE z9r-`kuEQw~5=ln9_4S+EC&@=99+fL4NxWP|^Whd;_t%|%&@M^-|0^Doa*^)6o7b)& z>HJ!|Wit>GCS(NNU)P1BtIlc$qy?j4kry$6dju|Ms>idEQbYO*+MgVAC}S9Ukd9`{)^wyVxV>j>f1b7 zxDhUjRBTUNr}Y8)KBuPSqO-#dG)Vf4K zD&nSr8F5=p74{AjHRR77x5VSok5QEMJK%{KV}Z89U$0Ny5O7600XS zu7}=3Mw!?;y&0ODq2s`F`QcYN;_|dft7h=BX>>@>;1B92)HN|`&pRk%o*U7+qYsXL zgUxf+e1B0M?v$<>ZyHW3oE&qrNkih6g#a@xVE6s%htx>fn7-XXE+doL2&1-uzFRW( zF}_AH^&7)Z+ZX>DAD&Dpfw<(*bp8Q;TlVJn*5>^-UAZJc)Pv(6o}b}5K9t8c@Z6=z~3`amIHFkkay&wadXJy@mmsJ$c?1Lt}>p8T|9lV7;D?K zZ~RumT8V33!J0;-CTch*E{!b5euF~E>=AD>9=($Q9V{?_{rxcAjONzqtTDi*!}tZ2 z-L1cU=kuDeb<1tLNKWymEjvh7D*f%-FC6dU8FT%uB6q$JWqo}i5M{VxzK~{xx4I>h z6;|lc>n{gThq%6~Z&=WXuvO~I$Hq916Ne|KhuXM?ZVXLaci_)YpjBfqAVvryA1xRh1b{QeIVJKM382!xYmlGEik9JTqG zvyP2mz!zPk3Y1ZV#`8Xse#5dpzq~bt)phDny=0%ZPO3$vrM(7ftfxy}46!hEg=^&j zXLN(uQ;TEZ_`4<2n!Z1~4HmEimEI<#AnjJqqtcNUPm>{EMGf zHMFjgu76E*^iQ;yka7(c$YDt%{cDD6pJyU$yWIwx z?tX~@FR|kH2wfgD%lib~go9W5(H6qSanSX0jb6Wb>J6!zWA9Gz9mq5TrZBA*MZThI z)a%_B`=#Gl+6>=Nb9u(iVyjHrNZ8EP!CiIFU+>}pID0EoR}@v=xgqvAv`h+#2RHKW zlb##|T+4@JCyHGy(o*i6`){XFq*cHQ#FHZn(+U&kN{_`$S;Y_o=zOGQ z^|+L>qxMsG3PUx7kp^a+`#hlT`S}PXF6GNoFsr`5e<>BrSg%~5{f6vBv~#PP7tNiH zSILwqw|64V8?LLa$5=SMWzkX(bvi0!#+7ZeQehlfYP#aJ6Ko**matr=-MjFKMH$?* z>m0Yo`V2X8AF9$b^telyWS1j;&g{Tb7Q~-v4-5-{sR(tOEBW zAONZqVaiml9{v-WGKR~pi3gga-+ehCeJHk^RPb9^YZ^Q8O$Z|1QLZlZ#{ru0G~} zch3P;g{PN7ry<)$5yDXKFvu~OHsgi^o;+oL`>j3IY7NP>2*PhA7m`aA!Z~VU$)+** zd7c=etBwgVjSS<1kH9O{npBDZBAbvkT8MEqd+#Dsd6Zln(m*MG>$+=KoMg1sV`N-$=}(o{YifvVmn(F&Lo0VCYe67mXhkVTML0Fa)HZ zY~uj4Q11yQE+&X;FkRPOE2C!ih`-FCxP@G7#E{!24=*a309k_nHP5P^fo1u_;`k3Z zk`^8`hKCJJM;t&t?sq`*TxuoAN5S%!JmAxWTCL_>fM!pHLs&#;xG^_~dZjO~{u*~W ziGGEYP+(*_aIrf*I5~XlRBc&)kk%tl)8Pkblr?^GETiEW`GhkL6la9N+&g-09@4hJ1OGFrJlOOLsa)1PJ4Y%9SZ$ZhXhL+` zG-@xvRG`8B%7;mV%C}2lE`DQdPb$nwIDR}!f+@G)MlB0$n(fXqBbNdh{D=5M%t=v5 z!9!(b9{x9{{!G53!kfsLt}db%;Ns2$~ zC)&j9f*fv!y5}@j&*;+Ieg)ryBs87)r#oA0K6~E6Ir!%Zjm4k&(*Z~9=5a^#U482> zABaR&a<>xALq5~E=R}~NLC$s*Uhyzk1;7HZ8Hj(0hgu7R#w|f42-!O&-H?rNj1&>W| z+S)wCq-ejXT8CeQYt7jX*(4-%=d{$Lz75aRV+u85Eq1(W7(iuE!_4qkRWvi^QAiJt z5kYhbjoJJ5_glC%@*&%_ftxj@VrAl|`9}QNi)zk#SOdaZwgx;>Z)@;FW{)cj2ra_? zE=QXMP&ok6JNxdFI5 z2jzvWdU(oPnP`qDLNZ}_gasj%`>G?WKj;HDb}js=yso~#o!9~v?1Gt^bmi=pP#oai{$ zpqLLI#s<;SJ?hQ1y?98{Z+S2>72a5Ckekuvcw9?(>FcMe%yE=Q>N$a_5re7f8xYf_ zYj1oDrYa%Es}vB;ZO3k<;>qb z?!ANYuN5#I@YlS5?I1FJNk^;ngy;YB!1Nnl7<|(fQSyvXEO`FqvD}T#UI;}Ks)nRG zh-wwrL33cZn}#Rr?&Pw<3%Ay1z ztINl9x9lUJi&}(zNf06>|{ZzvNRP&?Rb>VSXQWuPxc`VP?5HGCF zu}1HhrK4swq6Q@MmvkZ1RtpP~s;dJlrc~q~8flf|8}68geTtMb;yMD2zl4sr_Z-uI-TKFz=955;06|dd&?q~kiRbJC=V?Aqj0aSTi zW?)t*LqZVVEUFEfiKeLg4jO&0Lf07T36nj58nWPwH=>`l9L;R=K0~?{> zf|sy?W|sGD;a(~htsf&;1Tmd8!bu&5yYMp=Ci(OM3W_1oH-dHD@Y@TIYmK-OmKJUg zgoIKHUPAO1o`r|-3_E$7{h0Q1M8F(-#soL|UMEc}wV!BHfZ*M3OfrKRq$S9jxx1$s1y{ zVYv;$C2k1&Yf(FJUHC~2o{Ju;i7TK&x9*cFn^#?U+g;yENfK~7>jO9KWZ{N&oW6{< zW7mZj4Z?=eN*qsaCJR0p3hsbP$!_cDWHfzj#pS%$ldy@*vsk$P2+f9TnklY!DT2+K z7e*LMe|Xo;4#NdG-D|cW@V+C05a49WsSOTqG8lOGbxiNA+OTy51DQpaV(ljZP|*cN ztAMa>-1&8fReVX-QWD^`voI`euXWT_SDpF8A+(FKQ7Dblhv_eSUuG4EVu9Fhw1-Fj z?-397);DSN$iFfBp5IWL@4Jr1z^<45ekVraxqD=i(T3}Rtg4ySUj7y%w^fJ?LPnPj z#Y_->yWoKCw=x5OZ}Ct#+`{l}j^D}_ir?n+trPwL?XKtdd;M0LxD9G}6u}<~t_7!c zfFjGU{^ajyinm2s#D~J~l7qC%jW?e;Tlt2tIlV%Mq(q0iL|zMQR`%4)<+N2!^l$Ze z1Q-m-kp4Ji1XKHWxZmW^bv&1=`c?fg2Qb02g{tAhc8~;gKB^a1qfHx>J(9m&(DRdb z@(l4?Wp3=Zyt#BaO7vqos!3nTn`y0Ic!u&uu^!yV8TNoiR*_m&YZYWEtz8@R11iS> z)A(msx$w}GWD4t?i>wh>DOR&JMr{O5_|UsQDQR%rlm&)6PXHM19C5=Su`xe5t$>@p zpzB)4-L?!;ZUFksy-AH*AFz(0r`eBQ{P|j-uV`+@#fNxn4ng!PRBxgWPK6Pnj*m`= z6)bQ^NK-{FQ)Uwzg<+s>cXzAfd~Y5NnSeMi-tfii*n67d44nfx;fz&@EfkM*V7!+G z=ZDl*zzG^*rUk@Q!0EnZ#A%lWTOG8HG1Ib@-~PTz4cl%Ni0pjwHb+Ss>n7R!6YD08 zD%g^$D*$`N1wTD-vp&=gx$6#GcLV+9n;VY{&dk-IPeg+Hm{q8UCZb-Q>7`^ z2g-zh2|2~!pfjb23|eSv2!cM$`o~tN7li9S?%U^7!unk8?XN9a}#}QPJL5i?X_ijn?~mwGQGl+U)yKT!xb$DKRGLz zf@`lW%i|NBZ6rBbr?d29f8WHuZJy2?_P-*PIRjF{nqWcpOg%7oHIxi@{b+)A0=b^M z{Aa_a_~{VW0OEpFu;y5hJ;lX)5lV!+d^AagPUZF1aaT`je@k*TAQbR;VvBKKmp8-N zAz(CYrSeZ#T;MozMYfVFfz(D0o%7-&p6%hWWVE)jqVmekooxji7g5AUpiVgqqey%! zW9oYM?ZQ?X>vnW2W2J?k8}a_$eSs1dUqfvzQe_3DnrzY$ewC#uAa%tIOqwGkLCQTd#DVZv1NBFUEQbO8kNGg!kwHd z)az%qT;e?AB*4MI3es?^YGrU@@`pIB#spLV&-7{8|0gNW*yq&1{M@Q@nShC(mnNsn zH#uzdiYs|F#v<7n>?R9lf`t(GMdBL{6ed+LedI|UqK^3?7>yH_J+stdct=r)HF)3_ zaFEIq9HoIAPSh^dTFT1H&$)yy4wpjPM{mqBS%E9m@nT$iU@6;vy}yc$0Yml^VQkN0 zl3Fcg**UGB;)Uc=!lt{lk|6!Dl+D*x|Hxs4;ndr+64Z}sDT{CW-jzJ(LURHJMDga_ zpv!Se93n!izUL(i&l+qn^2~$|nsEaZW6!xVy;ps6-ih@9Ga6Y3zV}gY>7kTWVaPxa zur?TO|8yo@TFmutMvOWPU<^j7Vc^N)w$encZN?nyA++64!_U=QEeN&%)BCbLU)hg! z{Q0=U%?6)G+{mTpN(_04IsY6xLF{tU;^b<>PlFuzRa0RL&>U-a5!j0-^R@N&=3Rdc zw?4`wlS*r2ZDFDJp#8wUupd;i)UZ{FD&I2p+RzAtk`Y7f7XON&KyXR)ERE8ynv()_ z-MS<2bJY9i)?N^C7ppZ4t`prC?nE1c{Zvx80(M<7g!l}rfOBGxG375UOy_Slw?g*TB1>OYJrnT~c-qo9vE#uQ zM~TtAG+{P|3_VYoX>}&x28ss$AxV1dxAZ|~q=DO<4&k=un20;)xJ};X<={_02h2&7F*qnhdzD^mO?JTbw}i~8U3x9VR~^^ zhx8bB5wtp05yoUSr8O4m_e9Td`0#QXpCfMUwlUbzc#_DG5S1D04+ zfh{@!Nn>W&lJmvT%l25ABPGWwUGH^Wuy0y4UWK``P{@X6G)>FFZo(5LVDMT_nL*zY ztn7J|AM~%?rbiV9dz2^~p-@?%gS|!VjY#Gz``7=5k<78m7JE4taVsRk)MF?wL?fG~ z>R^unH*YGlvuIlixHJ9)OEB1?MC8!IK#q50iv18J4tyahH65%eMU^(mg(A^S-yV4v zNrZQK>dyk7s&5Ee2aR|+8er!7P=&&pnyf45n;J8ms}}VWZQ)YUe9?Q zkc7d74nVhWfx|RfMoYY`ane1<=E2;tg(lxpi0Pqk9+%_m4ci|`lsQ@uK`a|)lVw0n z1Tl7c=kezPY;s!H1BKDgDu@zdq4<+rt7|xImuD{GHPpeYW;IG#i?)N^!dt7VeX^zp zhzShGTnJleLG7He$|f>DQ#S|l2zH2t1*3UZX9ignb{2h8T*DF9U$)!1j@!fjkQh<^ z2K1ED=_zO(&@w0@pWuN4gp!vGulyc=)1@AeZSOpnr#*&Sv` zwCb!lj!uC6zotd>0dQ$@qe4%!4}15ErvYr*S(`=`VJS?s$x`%&qxa*!NA!qM@D4S^ zb5!eb*rVdUI48FX5QJPl>8*(zR~2z(BbdH!LnwziAPkNQ$Ja76%L;or`QC3F2tb79 z8QdaaxZ&t`rjSXT6w#Ge*=Pu|dnq&SKOA*%A34i)Acf%`LofX?VFq%BaDb$t!%!F* z<=9{mgMkl{+jS@QSkZ5z?gUH2Fw3l^`m7KgSM0_5Y$S*0z*kUIm_3Z*`m(h4m(_xN z(!xDX+7A%QRATxiX^%YQuz^uEeW|i_>Vq%5eIu4mQIa#WPocp&MSg$8yg^*Oc7lCi@`u@sR3|0V{EXE==dW7_mX zzR4g=1rNrf#|$&GXC2BEhKyh-5)onX-?$t2E8quwjnlkar+Mcgx$p2ak5hA!X;z4+ zH1G%8%1msDZ_B(<_f5yUJ7==8BK{QIfYz=$8F8@vV5CqwHezxmWEH+ZG3YQ2r6Kz6 zvntOT^WrJrU7e4c4pIb2+e)};SmBIHj)wfw6N@&5h4;w)86=})U zb+48)Et&bP`pLp??e2kLACPcgUD5H!@#I)YSP4RQg9kId9v zVv{a!x!j>NND#cj8F-Np>yj2`PHw+Ns~%wmCfhCxgY)fBk7 zwKRYw#^tyuj0-78of^u9wgLnng{>ot*OHyq*OqaUJ zI>iX4tvG9+=>QwscK-eww-T%boXT}4K6iJ_pR&O(7N%Hr-DM^N`YV5E71VW5q668$u_gF zxC{~~5I(J;K_XbM_9rrhU79w+4x+Vy(-<*%($QI5eto?NID^kjGcy8Gd@l1L=(OX! zV-dcn<)4=P-(2=%8Aj0*%qGhD3b4x^h}7Dq1-KKkCfO>4rU|s~wzN=8SqXV$6&+?F z2g;;N2gIO)ant%Ao-iL|jkt@Lp9;ug~D*t(pTpm<1m~V-1JM~Q2%Acd0xoZ$7Lih z>cD%va`cN-_+xFz9AJcM5ehm(8jb{5ju^yy|>t$LPH+{&G}JzRkx!2$ku>x zwf?DWVE8Bhduj$o8_31M)J*DjF#ZpJz8f`*)ZWsSeXFZ1N|a-j*JRZ_A!um^o+;>qmcj z*|7SMr#EiPo?gWL|LXK!TSw3Eu*WYN$kMw}ndA#Ea2T7Ocd=0CHjJYW@Q2@yd#9#_ zRSfp6B~RkFZb=mMN2&2YdBaX)=p85VG%7h)3XhV884*g0D6yYbQRxu`g(B&6NkWbu zHNIkEO;bI(^8zdB!WHIaXH+hD?&O27`C$fknA|Ab<`95^RKbOP!5K(# zQ!J*q{X-0~qBKQpr4YTf*y(x=QItg&OxG$i$Rd5YA|jGh!UeX?!T)HP1!Mbdk#5>M zlNBreSCP)`9Tf)mJY-qZvj6v_D-hD&aEEmz@5<^Jxo`L984(&=R8qtDXFrq1CBS*0 zwpINO{cl*UZn@Jng9@G7*f4Hl2zV$TM^Sa8Sx&S8$DI7FL9{vyS5{n6GBeKLxac^f zxZJ@qBZ0>qaM1_Tj9Z$LbLONf_X9Z#Z|-^371U{kTMPN%5M;0(&@j~^FJ*$|V_F6D zq1?OPr_4Mrej2SqN)s_ls#JDkB z7A(~R&R(Q3&?{TVRWGP^2}6d!Sz;d=h=ty-MX5&tmF>BBvIT$md}ANfOw-nIU*a1c`?{(NYLSjrlI|()XL`THJ|0Nc(6 zW+T?;k8A|+7XCx5G0p!P4qj(EP^dLVAP%`6h6|))bs>0t(KoQ3XqJ3+!;nh+>lc~p zchZLdnvTYNd~lp;iLhe+XGRNIEdt{+#yGvweOQ)NU0n`WLjGE{mEfL z8`-M)SBxYYp3IbmOyg~`ml$uSV?X_j?wG7RbZUiCBX6nG}-TF7Y=_Shdniw@Uxn({e_npB_ z&P(=P&zi8ctIq$BHDO&5Cs=VgzuEjsUu~(O=1_XzVXOK#GMtv zW)e9i5#dpgfgKsK8=QmLJU+1F`w37U>y9eu7$&hge-T(Z=e{ zsBgx|QNBBmF+os0O*}lzmLC?zBFHP=xX*Q$(XRq-5IP3)EFx7R$iACT~GC(9*I_kUov_}$z>eQvP!<13; z*KtlBNkkjc0QB9FGTXG~EEhf4?L04r1@j(uq78t`I2OgR9%CNTY)Ht<{xr{wW{bv4*ux<7QA7FQeV_i6I+jH8lb+9gD+UQF%JnjVkHwg)^OL$#U1;N{rDDgM_0F6 zxF5S&a*933gkR`iX#|CM6GZ`D%*B3VAYxwS;tgBR`j7S7wdKNn+1-*`EU#C2BK<3E zB;T<~3Y-ucwCF&G5Udn~g5D6AjQSM!g6Gad_ocrLpF0~`8TQm%K8lq>4449Ghl{jJ z&bz-^7cZQB-epTqwg^8lC`-1nBl9Mqf+E_oGL&v8YS)ZM?LpdTikwsV<>kM)dg|{% z+DVP`28aU+E?bJd2wyRNK=$#%(&FJdj2bLsXwKHaf5I~0x`uGwpds;}hJ}CXIGAT6 z=Y%+xg;*UcPEN(HC{R?9$k6;E5$rQLT1xq-^H=|pU0I_vK^U|A=de06lqSG#B~dMZ z>iLxr%8l7-s15zf<||p8e?dJm8VpL4%#1)#DKB{PZ%;;C;4I*B!!DFDz<%X!44-FK zuoZdX5g$~AekFYVBj95I)oYV0w6*81?{!%=iV&lAVjmh5K^pB~6rntgBD}KXE7P)3 zgosPbqV-$E;vbD7j9$+iT%TQ!`BpY=8_@;R9NM4XK zV$BPb=XS+y$11AOEur@Pa2a8iqgU5tW{nCh{5cm#? zw^-w!YB$0)VGTkg4=NCF%9s>mf4bv6$f-+y^XW_RuNA@I>}>63sd{_}#V>~6_*A)i z#1=rSaHUw2(3U{b)$Nj>rv%%7F=`2 zANXJ!`5Svx2?e+TcWmVQYla0_E}bEP9iB`jz_U;fR`2k%C5IPO`sotFOMbLHhoZ&MSqe)1)HDbQtQF5vN`LyEBN(m4e~2@INfPjv`o&Fb%MH z7%LB}^JSQgm2O}JC_J`lWg{pq!&BRKyHHi8%Li@?(HQ%Y2kSBkM9Z?) zqiTwE4dYS8gmxsJXUth3SH5D!hvzKBmJN4^1O@z7Vj}!@JSr+va2f0w7GS@!NM1JE z=$W5_6)5c!CK;mNOeAQ!@w>mGZQ33QX3lAx9%&%mE3*jJl5G3!q6$e6133^sBcGXe z!Nn1GaS|AQmoU=PGLzhd|H!F=)j<5r6=}xZRXK*F8C8&n1fLw&sQN-y&QUW-k#PP3 zV~k$9p`0M)(a6>Ym4G$On~RzH&73?kqjrIQ3rP?Abk=nI0;GvWBZ#j}ef-aRoEdRL z@j$sU+-*ccWVcbpf}u>%7hu8B%mO69uz+J!4|WEYkgLxP7{Emgz#?+Ta>dO@U;$^C zd`sqQ>-Rr$;gkQ$t$UM%LIn>U^5{!9{Y$ql@ef6XiXybcm4F)Z?Wnp$AGhhjc-Ab{ zOfgYEGiZcX#maiz+H1iCEW1)6K-MEsNx?wDRpC=Xn58G_o*g14lPLU@4hUy#RPiaB z`er)GoPEPs*_+?q^I^u}1UuW(dCV~^oydTP>P-h95q$hL{SAW8Ie4xwt*`=iXSQ5`B4J#?VT={~prWo#rT%$&i@gc-T& z{MFF138DqlCyQuEj73A2=X7^|aJ>2aHW`iVYaDd{&{ zgD1Q)Bq6Hv*Rzyh?WaET=K%rLnYe&F%=B~RcIGUshRcoVFDDL=&Pv^q3x&GwskxsEedSdhqOL>xk}xZr!hrrBFWj|X4Cr@=THlr? z7l{Y%pM3LP;k?xR&91dDv>6f)QciYO6__7FPQ9E=O1HwXrq7ALtZT$uMWh1b59EQx zz4{-`GEMOS+tC3NSq^`Q)?(^rE#hhku0?2IL<+DRWT-m<28ohOC7c%A_1CQwPT~Rf zspl<~2Cqj;Sf5}^w~|~gT4M@r*>TB5p54{G$Bww2sI{T8mOb~m-(1%2mMAd{w`wC? zqhX7}Z=2I_Oxs@ETCs^(*3h}*~(kmEa5qq8q9*9WzE zed68umPoMq@m~yxB!D%DcphU0j6n{>^+mE(*%$5J>)O$f&HT$eu*W3mL(i{k^JE#N zxfid0nx%n@i$-l+x_ zrb@tfg#o!; zzwmf6uciN#z3r_nq%@CLk13E^{Dodv^bVEcaOyQ>B7Q8ICO60*?EU0Uw=o7dnYv5z zv#zOnCWk-8p#ThDUU`o z72dg01p))^u%ScPjzXz#^goRGP4h;wRZA+VAuepuaIq@D?^uefGzVn45@Lh9W9X=R zOKjP(PV^R86L&U_edti2D`HI)nqnF#ME!cxT{TM&@^1=MdcH-2-|$azyMl7PHnh|* z^kO@67t#{kDig898zlS!i1<*GJ$RtC!{R#z_NE8FdkFE|Vd5KeFz zSPa6Y+#yx}-0=IJl3J#>0y7ITmhE{O8weW(0uu*m@oq$*nB=5f5M#GpA1oePsQ3S+ z&(Lu|LHUKt=P-R)X8Q=PhcK9VfoAJuy| zyYMGJozRt~NBBw{b-MiIUWHSZ?fQSOGPFU;6fQl6VX)XZMB7^G<3U8MUDy@Xad&u+ z4X@tx0}1$cP9uXilXzzgWoHkpi-h@qT&(L5rTmk zU<4GD+bGI)xF}v2<_ZII!MT8t_mYy7FEueyDJ{uMqEb>~qGH|w!PJtH(l042>64Q3 z+W&c;wfBDCbLI>Tm;UGXD>-xCcdfPeUVGj5+D)aX()DLTT&aNiB>2f~?e5C!!y!&P z<=ZziAlK5~GJO7AgyFN^2p->_?Yih`4RP6*mu{ro&$fzgi2F1)7o3&c?{@CG8Z}XD zfnnVc_jzp5vnp@f@skICKBa}BWgS!447=ZiI!^{I{oJhlQ&pnc=)0Apq11|>#fS@?7V>_9cs<$ zz7QaIzR?jPyxDPQuLg-$uxd0y?q6stwR-Gwmn<$1g85&{-{ zwwyz8 z1}eJn-fBjQ_H=08b_^rKC=@BeY79GQ)J$$u4|7k4;O$)(d(rS^d!Zz?R@ZU^1-io>E%;sqK!{878vAl zC(|w1n3R2){Ou@omc=gkhWwI@)O3Az@>@(j8E$Vlc-*Zv+T)S!86UNY-q#*k{m~m= z*BSc#GX;L(lKiFMjLgLC&rYHZwV#LV7deyfaG#7MShpkEe@@?GA6D59E9OU9zNnvH z{HxIj-}fE zKBi+yZft-#X=2-Y`d}cfadcZ)8EpUfL@Y82i)wqeo68}2+?L-)ILUB(nP<4&fSpO> zq8JKeH5+a`%w4Wnbm+_6q6>y?`04Z{8T(@jny+Mk>Zuh2qZg&8_V=b*62vBYUlaieu3eQ zz+BB%bx(%ed^u%$06sY=e|FFQ67nss>YffY`*KD{YtP<&-XGK>wy2`9sou3AYPMmm zq1s+-SbAfzi^BB``5knG==^f@=|T+Ka5r00>(^qq_hDr{4ZCw$z~IMeO4h#k=*tvi z|CuTAtM9Z6rX*M~5MyJfEgjboaQ~;K#B)S*trMLfhF^8&nM=ro`)>;Tu!Awn$yD(@$brx0UWxL+T4dp-6zdY|9D_KVdzBuW-oFn z8kE!IexR6BGITgO1hI9+zem4TAEq;`W&-zK;OEVaFFVkf4n0B|E(O|9S*30f*N2_c947vF-CatdJ z1!K;SamA~-UowKp_Nqg*noIJZ_{GfTL(@@$|G;t%;A(cb_oaKXNL=CkPwviWq*n9u z*MC2QQSOY@+}j~HUruL6ac?f%v3+ffE3D?;4mHcJ#6a+gMoIEc`Pp36NM#Hy-RaGm zE0oLG%yUofPH(;XAU}3ze106J#IO_QJa`pI$=IXmPH)!y;V#|XAap(cU_Ixx5bz(P zskmbB8}DRMLi=SZ{E|C+f`TeOH%@VN|CvK-{)1ED*&tK@L=(i|`Rkr<;#tf6#RY!2 zjvFvo3qp~>SD*U)5izd7;CsF5erU0bb_s~dTGdytoZ7bKRozdVooDGQuBuB|$yPqO z;l0NBVOleX`u~Bmft#ObswDT_<-eH58j|d*yq8nqmpaMWzUl}b(w4a&U0fM(?#C(c z%bvl}(Z#Znmy~YkJt?3K)|A`JdoWZ#yqJ?!Ce48DhQD6(Sd3#IhUSNo!NF09_U4w~ zEzjo>yo^=LI))JW+@)OhV@fU=tR}41s)zdYEDN~8kg{@Wz|~UNG00&*zKo>;o~S~r z_4%!jO`8zVhSl1`A^Ks(OBJjntX8*EAGjsPvA09>L&;bw4y8(aEVuW^bFwp^|fkHXAak9T(p6T{^vsk*eTago8G9(~KzIBua7N%xDFXfp}YW6Q696T{pC(mkp1DoL?LP10+t-@jh9k8xaAg{?_{aZghee)&Dy zg`y1TwfH)*;Yvd;&Z7spD*NS1^d-t6Got~WO{V@f8*lsE? za%I|<4_f+mdZukZ4cQMdZH+O+>!b%4-#fW5J=135X&+)f&6hW9O&9a|-j#2qXWHVS z?p-h>9$O1>A8t{c*VC%bYC3l!<1m?wy>~-yzMR3DffKdhwui1u&$R99P_r**bffm{ zsw+2_(!$6X+P=BQEtCRs&n7c%pZEUY?yo0t$6{%(ea6gn-4m@7-J&UZuVnJn|J0QD zwKwUZWHOIP>(iDY>*;S!=MluP9RGnS@%)e>!9+uJn|UT5{p+9f(l3$%9t3yt-K(lU>BaTHthNUxDFygj*kG z*mm4H>cGo)In=EF*e+vYK{4$Cr$={5l6QOM*FR*`Ak2K)D(~e~_@z#8xDJ&A4?UW8 zmu(z9D~Yq&YVX4o_+`(++0i|+wk|ke>mBs$_j$;Em@!Ao-lDa&bj6#|`AHo6GDJV5 z1Sd!LX^)nEZOA{;_KmGx)@2JGeRk)xW&Mkj@U)E&c;-uQrs@26NLiOF;A<&@5V+Ql zFMY|P2Q7N=$UWQW*=MZQ9uBejQib#ISgmX3wVkLE$xzu6^z9xF%?~B9RG>Y2M0;&% z-(&02X3$ovm9F%z#Z0s40Zz4ZuXVisCq)cBhv|z~&Ewr|1R*Tco`{!6wR$)G=DeG; zcs3sOZlVXf;7MmLDdhZ(n+7qbvR{Vi7ciX{0UfLOn4$>F9&d9nJ^p~MwX4{i2W1E` zictUHD_>LDV{-=dQ`Pb+d6E-67d`PZ+l4Lp=KuVRmc>XPpK8R}C%dy9L}ymwH=|Fd zg*kG<>b7Ry;=u`67V6N=>yD*bajpdKM^B5i9dj<~bSA!zsHiMys^(413?%TZ6p!CK z#j#5Yd3a|)K1rK$*6&9oV7b$uJR8Lm@opt|Lme}r(X%hJ5_sz`&-w)`fwLVM^s8Y} zOpU%3^Js23Vb_24p^54jy}LtbTq(QPzM1&Uvfgc?7sZ>43;z4Fvq1V@Ew}b>8vC}Ee#<$vi8e`&uUz=o0clYp zlK^|0Fm~ocwm>PDVPGER_{Y^>y85Lme=_+}WA-dqH~EDUUwWY)?6){A5a~Nz{#VyoHiMat*q_VVfo{x@x&2PhKC3q<~Qh z2Yqk{SCX)^wMGdV#^C!qN2Eq~)tM)1yN;e$yGcF!(es)FuY>P^X!HqUw`98#!&)>u z6==P*X^>hU@0zAYyDpV4BH@Dj<7tW@8$m|Oy zr^O)q*I`0~!J`A$aqFzP_Xn?O>$o;cX_kk(4=P*(1;t&W=KP#`US+I7#%lzcgH{LZ zE#JCrZUQ#j8wIPw-}_|BJw8|O;j??byN@ft58JZ+I(lkrzH!*MPQ%Ou)6RIrAYdH3 zboeyErj7|~`C0!s|BNK8cqe*z&Guh5CXNoZS?xamZH+vgeLbjVr0q^rgnSMp#sL$q zE=`MJwSv@Ei3$v#pXji4R15ddeW@nRaNf=ACWo*)>yOVu$L94x%!0Piv}cw*%F3^> zl6x^EkJG#8vVF2PZNLzZ6a%$;Ve{40gOmn|>ze2-g5|5~%bKc7>ebv4u44r*kYYHY zrlF#mcSM8wkY@n{ZLWIb84oMv+u`F<74W=c@c<9)+8jLYP6sdW_R%73%bOKP30xBz zz}PkmKwCjtULUR`KJ1b9-XGeW&22e}uw10Y_3Out54$HTI!-)&&G2p?>P&Nd=omLA zU?yZ^s9hvF&L_&c(FhyeGku3}zp<_D9pWu{d6vnb7X19~mq#hGmwwn-@J+d?9VFn# z33oNRMx#gH0o)_74&WDR)-QI`8lA9?es5vNbDyu`OzSm3qQ)i&bNN;WjK91bo}slyadq>}+5;C&NF4$vt}#ZKSpbm7uD z4!0M6PVA|X9|oA?k>g$jhdt585^EK(JNM2wixkMgGYaSgg0I|l^|{eW+Ri)AKK0S* zdp~sQF%HtVSw}cUv9;0?gjf7jV8ng7otIqLehyt5HMzBE_IFISZm8^nM_uNzu{$Fb z8L=gY?pL?_I--5&scn6#eJ<=+pzGunW>_t6f57t7(c4Oufxt<)}Ym~iMp&9I-JL4G~|Fg?s2g1 z!_U6VQJ#i=LEoRf6o$Sps|@?46!+z#7pV8E8hNQO7~Qj7G54@@*;S;S|Je(dT;YfA z7<(^=3+u*V_wEX@lkq+FT`)q_UsKWJ`rjOqITd>#&RVsn{hgGYieQBxV$*5*|8|<2 zF%^3x&RV_4v)xS&6+Td6t;c`Aek`5WN#5dC%wNI^GTcoD%vLmLtwMl(0nCo1Jcl{z zn+s)q7K2t=MWMtF$9(a9hs(66asm=n=j zO_{5k*w^T&JEE?Pmlv_6uDf?5p}*JxJRa)q#2W@0tQoBCil>e}uQ6kF6O4`yyTHv$ zkBHk{xdHukrWa?pEC7AY9eUob>wjpaoQsBP3buDH7!{@BG{B z#i!T)^(uU8g_!$~PDeCSQ}OD>7uAKSaAMD~tf{v>n}A)LGMI``Wr79?fnXoLxxk_{_=srtj=xZn-_ z%cy+wIk>Ly-7hK_)0#a+uAO5o|y6Lh`q5tV7c`{woGdAJ4e3n!sPdDEP7 zbg705FppdjtU)xpudiDA(#@C>j~h3QG~&{m*qar8d}S6BqxCdci*83mDkg#f97hD~ zU%Vh9a&>c!r^2Lfj_$GHkkH$=*9hsShO+vqIgMxX8cf&ZJDR+X-qw<@|8Bw*kU0_; zu%xx!hVtx#&=VXVIH(P@f9dqtq~Q)x+;Ng$JP-Y^5hko0z+r^62sy+lpRB22xioMY38p>61vP-gYHb460OcTw|)|Fby_5SPnB4fq0nAq{Ax#3xW(=uZ(%5Vp|rA% zEvi_@AWN5=nLQ~TX^56Jq0j&}+042Ic+5UNfJ;kNl|rtAFp^{_?%$`6i-G{gvdOP~ z$H7QB7%9R?1rOiB5M!D1Z*bf!S1Ag;v+_&rhJuvgR>sAPvc&2%*LeSFQ)zisJvJ1t zThNUZ40w?$yYEA{K#;It5F)&Fz_E|CWx(1`rjEf45dK+lpuPJsnC_3G1a*Jqr9x7zPxxS zE>EbhYM34WV|ZOP#@Gk+GX_SUEPW(O`xAl?t+aHRq+h*!gvIwhw^ zkDTsa58$Nr*W1lWNHarV-CFPnL zgE=Ur+uHGa)FjY%U%zr;SA3o9BoQV%L#6ds*IR;K`RT2vC7$KN=-D(CgcXS9i(SXQ z^yIl@yy4A2`s*?<^(Oe~QCUg?*n+kLyDr%9olD5B9!44}49;Dv)01vWm8c zpNOx+!`)4G36So)Nv>mGju)sg;di(H&MhhYTkj}ZrFWXGXLPML<=Ce>w_!2WINSGd z26aV@<92AAnkwGbsXIX$>YC~iY_^T4JPTH{h8nB73VN-7AM_Vux@lJaC0m(FHJGU! z2Nd10gKsIQW+E6=+lrQ5aZqu+P;G@a=XJ86?*IJ8?KCPKsTo%$#y53>XcSk0X5PqM zf$K$LW6QF?G_yd{T!d4RFAA2JU0}^0M^5BmLRbM_*VtdI-gbGwjr(YEA5h}PNDVDq zNlNT?ucKiP4A`re5g;SWQ^53-Ny}7@ze5|UA!p>o{62a8Gj7d}Zcs?E>xRw4XvMgx zP$Decu-ek#t7fAua-15zl zGpT_RLr5}ZfG5TTJSUb3FDw!5Loy{l(Jhh`A78Oc79)6l}65S&?{p!0XPh<+; z&8mzi?$ zx&F96N!y{zQQtg>J7iKC8M5et(P_8*B?w~16-sx?#)7B2+q(&Lx^*lVn|e15F&6ki zGco@)q?Q|eR4r_Ts&Z#3(2fNAmo<)s572-+`CI`6#fYp4JPBP;+h~b3tY|PXF-xVL z+y9#7Bo3yt8k9io)hiurPOe!@K4u~lqY6e`08U?SX0+XP{5`i`+QQC5oOP9+G*lvI*4cjXmvgdtrAi)!w&pA=o@ zaQm%pu9i0w9d3vu!yQW<_&m9}Io%LLI6vkv?q+GNwfTpf)(lpQ3|5kbzu_Q*|>zWz*o5EO5vo++yIn7i+X( z1BzZm;Y3WdUW_)S=Fz*zXhX}(X`5wLT)Mk+W<@m((-|eT$cfQNr#He78PuBR#Zm;R zk`YllAm^0KEpY)-4$hKeSPcX}D|_@Ed1JtxUBT(vPU6Nu3^((}!fj0Ed6Br2fl%O1 z#kkFh$xV&Mo~VWFMYIqMI9^Z_-t9l7u6`eMO3%%w6-l>!t=Y zP`SD^^&FUxW9rJ;=)h2BnC|q_%q=S73#NU*%xD$Qpr?vB5Do0##~r1{sv?Zm`7cM7 zGi2xOW~$;0bS;?nygWQ76QNuWU}U^A$Dx#&AH8XTp*PxojTQKAb(-PY-O#u@-GFqh zal?E>h-&D9HPw`JY*gd;Fn+u8``^(ea{Dy2v;h$%G(e7F#lNs!bAZ?!ndmJ=brz?d z>~;)D@KK%8CPbLL$HKssJKe0{(sxp063-$4j%Q=^hVay=y^(vU?w-LlE3)-!q9zQg>BfxSlB*1KPEFlR9Gt#SD?+w>GCg+NLSl zKKtswb4pBSSW&K3_*%g)WF6~IP<^eFiWXyIMg>-&x2$+yYY7Zy#z0(--=$}0Bs4UY zY8?@x!;}QAk109eq~BIv1KbuQrb~${UMn1Tt`*LE3-CDVxSnV`ECFP4d9@74?eyA|Y*lEX>I` z6h;|rM#;Sq#$Oa?QQ7ece+_7?|*3$Sm75YYZ~_P!i##-UrT0>~iGO8j+ZD950FBick=C zyk<#m*Tl0(gyGr1oQM^9iSUs|RXO?eUnNJC0lN99oI2yEZmNF%kmRVcpvpd~&xHE( zuhqQ7-Edy|Xm%-#c=It)4?FNy9q!P(X`|bXFlxJ=PSL`i54=QIa=5Y7Zk>3SY)Kg1 zvB%7F%M6h$di3FXvYWx&y%XO#J~S7#U*GnKqOwlEyQ}7?Ff(Zx5YU708=hITWLiP|3>Gmh^MP48bUhvIVbgl7 zRA{WYQH9Uxg)R^cvPo@cK(kPWz(i=|xF!9_J8MV%>f2OgLsNp0k;a59kZtK<=;#Dt z>ywl#530+)WQd&{XnH4GcrwXlLVE76>{emE^@VwgyO^fn1hUbo$`v*;HghQx3Nqg2Sz}Y6Yw&9Akz+cl)BG z+6!clJ4M{iPh&iZxHV40tr$}o^JG0!5WXxGhTRVqpl_)k$!St#N3N%#bj3NNO3O%A z>!w62;-(`pTFrprqo09DUpy0HZA^xx0*G_$OQ%obk$_fC1#$XmfX|wn14idOYdFN5 zKGejIZmiMKTK$EOkNzuHpD{Imy4yCM>Ig#RKC6UnKO*mef0xZKvkQ*vo62y*3@&%K zeHwHc>JH|j0uePPp*)bQ9~`LhMfF=Q8Fcj`!wr24)yici5?@!4gMH&p4zP|+KXIP% zZ^O@G1H%uS1#%*x00aI+d17t~dxY>srPQz@Y87P_4GoYuWC47l$gJbc z5>p(ld4hmmhSOYdY-x^5DnYGKh<2U-Nk{5`g8_xj_>U?6bn7h5$!Dmr03 zos%tR)E+$z;KpLVNe-e2g#oIWSRSl_;*QnTb@OODkuVk(Pp+@_E<7CtuK~dBBoePR|!+0!=Q4hw6r-rGg>pAWJ-S7h`ljs`t}G!aY-8 zhbUXf8<#-4$%V2_tFY~Wb5 zP1H-IwCl9t1%mY{Y0rv%bjV(Z@IZwn?znMfsBw}kYAI3<`0COj+?F0jFh6?r(j4$O zy>}-p1IaB2r$fNd_#;oGti0#%7jr}&OgbrU<7A;EI5vzsUo-9qibM}18!2QhD79>q z;Jy&q8jGK~?9Pb*liaz%BQp}XJIq&4c+j5d=sv@|sAyoKlwX*VWqjP7;pg_Kf8d8z zaZDoK*%?zsPs#-a(mZF6N-0am1`L_L3R_s{fl^6hpLl&soFqls4Lc7PTBGX=_U@#H zkvxmj05z;_l4YzeU0qu@5^f)dME7rmbgSgxIQWZxCNRzdS_TWxJMo}dVH#68EhA0$ zIc;e;a+jvgF|-<7P8P&|X55&g!4h_z`sE)?VK)?2c^auCUF==6V#aCRK6V(16JUa` zWl}lFgs=d&aWy;59XxWnaHR~FBpoHSttTsHoaQkZuA@i9=cGl;2sJn_M53V@TDLEq zH7#Zyn#75aK_-J1Ws6?b)|FS}6+q%60|Z*QqNPo><*fh2@&zqRW9HFe2bU#Q(W{`A z88t@e7e-dFp=7wnV|4)^huAw}BV|~up8kh767dlU>GBG&J!0{Y+_Vf8m~CjRn-dyY zQ4b+IGzmJ|1zq!sCo0k~A1;6u__$3C=N{(H2th3FkHh5{B0r4GcF2Er;m!DWn%0FV zkMYYoRB)E~Lw6|Phb5AX!?Hpt)Ih6rXi)cR&EWfhIlD5<;+ z?Q>@)N@T1>PiX}t6GBNy;g%PVhqlCdGyBTJg*tjTA9W3wj@rtq8BG>U-~?ESO#M%W zj$z8plc?2ZB8PBcP6=*q$^g6XJg-2&DFS9dBL z>ZJ|eT{OCMMB?4_JZJOQ9UH+gn6REEREMA6JMYXeBJ34xU>J&EcEpLqM`llDM1QI6 z>dbP3LB_M?FbF!N-uNfGkEP8H7$d+FUl_a$`;0oz;6c{=QjKV`^v!#dU#vJUfGtMp zK`FSQ#)8um=OZY1X(HN_xxc;Gg~pjYW<>E&{13Z%q6Kn6Iqn{Zgvwp9eNl11k;=)2 zBx%jWLO7fH0U~Z>;BtG|&|&VQsq%3TI3c>S6VOct`(esqguzVYJUaJ{K5o0O-+!%p z#_2!zoR{MlOA-v}S@2|dH6gp0R?U6KNYz%ZvODjRto!N-u(BHqocGHu_1G)SI1?8rYS4U*=` z7MRqeWp~rten%UmG<6p?Ifh!LRa$mc-!tQx^yXu;0Vc!u znG`1WI)`P)))~4gy7se$lfpE{CS%qCr8$e4D6$VCs*x~#^{;Q-ej3o61*-w70}&KyD zK0f&BX9c3PL5<(699LM@2n~ubN@-ncXl(- zXuj{&UA$rUtUq#G-Cz~IahC@pDU6^5!ZsJRZJ)&P-D{` z05x!-%&(&A*zn$~9jH3I83UXtkCWQ~-n_@@Yf}to(hw3(r9~LZMB>A620#r^9hXaW zd_3iw-|h@_76(t3w1Fx_EvY(6e|*e~R2>%Yj!WtQMW5(u@x;mJZ)!)LD5|jX5YGo8 z>_d*=6vovfz=7Va;E83$*DfJX_;)=Ygm=jioQm+SR26?VG_X^}$Utkj==PCbLg zo|Jfl0COca&3rp^)sP|#7)AyoH@ZPPWy`F?I-Zz80t|Boicw5a=hp`qEw93y?&9rt z|M~a~l>QyAf$dL>@Z?inp&bc-yH=saOYhLUCaU@_(qd(gI5?&1wQWFro;V z3EF}W0$sIX&EF>0c-$;(C2r?jqD>0TZHL<&%i6l|km1fv5qW$R#*m96l)LSjB6h_y z%Vt)T&4%y7u}8O=!Cv>?^*!-*;_Rw97P$2fG52Z#eG@t+Oh7#Zn~Am#9k;%JihI}M zrT$&+`^3AhEG4>AC_8`g#glsBHOHP%Aa!UQqwR_GN40tH6jt+VP%l1Pt4TkBY`OjR z(+1Ke-QvfVwE3n+{g#n8$~XC+9L{t`;=APVrE>(>>0 z*}bC)4Z11unj0Y=YZKigRGpvt)d|!UUVx7rK4z#tL5Yqv2&uv{S0DmYN@+)3F})(M zTl`Kceh0_K`A*8cIi?N>*5!4#MV@A0)+wTYW<7V@+-rg~; ziQmCydr+J0oe8Orj>6;PaY_)JY$xmx3!gy=hW-aiWo`Svo`?Hquq0qgRJ-ySBR6_P z)b3n!{P#}qk7?Dt$6kyhmGPXIV8FycGz|L1Ykxb=zoXUm@8sZcq`f1iepJeHLC5=U zufXHOEfgRmuAHja&SO#+Fh)&Tg&`cB;#vWL*Tw*F{)$dqE!^jb~n_ z^C3TN&qI$6r-*bo?Leme1vn|oc&IbYfffU)E(X7rz$zlr5K8h`b5#zn>5LxJ!o0e? zN$)OWwEBOwrT& zdEs9^`C5DY>?sfNexQV8$ww>z>wXDq3Ea$mmQjT#*+}e zC=e^II&dYG{5ZMc9s=Gs&Df^xAw?w%Zdh>8LGd$#qV*zf$M+82&=G&0;`pWysR!o} zYr#DWYdCE88?(&#hGB1#mOQ!U=!Zz)s9GctD6A7FiTqSB`ebs?dO3OPwl8xZP}l{z zr@u^bXfUv%f}wLmE(m}m2ttBp6iJw4ZDEz!j%X9Tt}U_f)|%2BfSHJ0u38@e!XY)2 zyNGkmQ>6g5D0f?5n6i)+pZ}oqPbtd$J4Ei^p>3dds5&Z;^}}<~n=)6r9lhpl8smjD z(@~FIiyd5E;Tr0CSTGqARZ1~jVwd#C=wa9oxpdq$+@*xY+HSyt)Hhggf8Ag}OaPW7 z(#XQ=ZKAidmRm2lzKD!=?x?XNh{+67{QS0l&OPC0m-FYsQKLulXUI%_qIdOk$AkM8 zk&CB{ESflK+*m##X%}tN&oA{mZ~$3w^tjBgjW1Miq@5K51Oh zRK5>mDEh#D9zEnvzCCeL(a=dF&(g0PdGw(ec-6+|Kk1CGB5hnzaS}>DvIuyJ(Ei5A%gf! z+%#+C-_A|*IKbNp*9sXQcyL^PfR$baH!ZDrVh5GnS>E5nN+sTb%EMa;Z!BuNf$Dg{ zl2^8~ie|V8+HjJMadsFxu=NnH_Wjw2&#fun-SO~wqmu8&Q8W7D?tSbYWD#fJ$ak$z zX+;Na;MQCE_c1q+7bn*ui3*y5 z6#s9`RehBXI2Yp26(+MWFZd$LWd8#$-N9FX^u{PAb`6K8Lp5>mhKM6|x(7Uj6`k?7 zUZ)2{Lvm9WdzsL;Ttw zy@3(AbI$wWo1^e}QAriJOl>HTRU`X@Z7k77C6X`|oezPH0SBX+74dY)=p2AG+#SzY z07Rj;uKM?I#{C7`+$|(yxHUX%DVHQ{XEC5_guzBKu690h41f+>?zjzPZ$1IrJI;U&-Dt2~*1iGtO@eJd#62A?-Vt@*K)zV^a+~`K z0Y2&9Cz(LT6?eL{>u;Hk_O1z~lS&~%FokYrGWNqn0_<)W$Bp%M)e>js zu_t0TAv9xFIes5eRnOTUK%9#@E}J?d#woj7r4s=sWmqznlmb!;5rh`K6K@PQ1|JaC-t?7I&ubp!2aI%5mZ~@drHT5&EG5Axm?7giZ)ex<&mr5Z&58 zUUdgE>&ew)HXTc*1NRMRWnhhf)EkDWBn#2gwU!}kgD}-Myl8YGx_+QEu)Y}oYO1Ze z6l=yyeu-=O(J$P}^Az481*ahBr3fN%AyRL+NPH;kQ0;)jWkxoRWfBj9`M}g&=M40X z*t&}`2X3Hq@Z5EOx`yWB*pcUr9yOK*rQOaO4c21ju9|np`S{sf5-7-I;=REq2f(=5 zPQYS(E{jpyg!P0vaE^h^8c_A)h`V0-k_x8`hg(kI2?_Ur2h5uNNHipf&tMQ03MXo5 zVzVVeXkO4J8Y+XS-F?r0i^6rJ^sBL7eU&?k;6$b*$H3c#TFa4>SCk&?MbK}>w1N`e=Er^7`KT?Zmg9)kVMj31#i5+A172V53B)LMFw9 z2R^PqEe}7(43TyD9hXE>TYqZx!A2%f&Pu zy~4xFvQ#o|NT|S|9cOfPZq-#V5lgDcU#I5N7_U8R`CrMO9tKvBFyj8H215&FFu>vl zLqaQ>tOe_G|7(AyBAeJapR45~-CQ0QjY5mF2C8#b`r^na*A2>s#W56+fgeGatRXDe z+*^h|H#~;v6*za9hlx5f7KI3=t((D2ostA92?XnDsupZk;gc1d^biPD8zk9~5R$Z` zBvDoJta<1<(F*1q&OB6t8#osuGb@ zR@6SS8?00m(yg+owoECf`U+IWHcmw&;VEO5x;gx`e>Mg*cj5S!$^bS^TRH23HTCm}zUI5ebLSdI7 z0K%?$nm^FM-~xBn&b$W>?yh&Gr!7(9DbYfOcQsGqWjr5MX*LRf)BO=PpcsKtR%Is; zO+iAa3Msu>Hs&Mkto8Th&$OLrWHs=VEwwNA7p8zS+iA*0&oK7mVPVKgSiJ-btKwl% z)lv-;R+_x25oxs#|NJ{#C~qB9SC#5GhUt9Jfr?UKsBU7H8){0Bl?YOiFqt-7sOLJ& z93%lE)jA(LTd+*0JC*NYaivIB0#MHDnZgY6VJB=i9^~zJD?98y}K`8 z+M73s*)*@=sXbXqN;%LQ0-{2DrDjmxtjf zObLd~(&Zaid9m}hQ4^{F#_H`9lc7N$ALRxkST@>o4Y|UpgsR%Pb+a*XRP&~LM!K^l zl2Dccq2%U{X)Gq~P9m!H=%T`3%xePf3uELL%d$raa!dfq9*lyxK!?oaG>Rj^yd9}d zM}n1s1QK+|NLgbJdgYz3q!92%S8FwPUb1NigGy$I6tj`GoJR}I*kO;jQWNHbG$qC& zfoekIz3<)n0jJeqq#O(%dLay37M)e5h9nHU5M8TnFzcObFYk)ii?sa2ZX60mR2-X* zHqX~^;TH|M<8#j9VE0Ldm;54IG)lp13)-aovv>_TXN`L4M;Cug9vpe8`Ox(9gPH8G zi<9hlHL{T9iA=$OQ%OqUT{pf#l^erIoD4S@$zc;lS{Ih+29ff{wol*Vh{o8N?@X-D zF^j1)Ye0LHBhakWKlW%L*TZ0BV)gA+0*szc{cEbYaM6t-Wmdnt{>aFZBSnkfM{PkH zl17?HWF05Atk%Z4eDcg!J$&F`8n%AYQ;sHafvpXjekK9>J&RK^s2Ep)QoLr-EgKkk zoHeZ9#uKxWMoS=2RG@Pnc#aD+tJ|vOoR!^>LbdM!K`O+Wvtz7$;9(P`!rb^MrB@?Gy(sfaRZGyp3K?|V% z*xub@Sm2o$mieDOS;}qz{WN%h+-jko^&YWzj|0ANIQgL{Q_eU>_s4ER9~NekLffBTFi439USelQbrVK`QVB_f;@;21TjUihzC%0%}mWO2oaoPi8k zjIOiON;M_~5SWxPWQUhrN;eGEi$_0HFqKJqdx=h{&q``(F=jgOLtee)nW-@@PdGLs zaiw~LznI0{#hm&{H*EHru(@bIfa4QAD5>7*S3mk9Bh!Tzw++=M*2)S~YR<3{=2J%g z==c6vLlPH==y?h$e4K!-Ouq8RRLOc49VzW$%jdI4i`N{{6B{Tq293PnNXm>fpkc*bV|&a=RRvmP z6xfA-c)ShY&Fwh&uV2BxGmwP#Wv(^78$BTyGel2|7q$<6VkC7|8fwOnJ-R$t@)%$2 zCvZb24pv}OcbIfpF^l3dNMO`BffoN}`zQsJV`%uR^@zgz(X*P3l}AmQt^}Jrpjl|O zV=cV3JylNzSHs)^NMX=I`wJiHxw*gm$1*y~L-}qhemBjv@b6+Gq8BA~m!9)KtGRf? zUC9&+=X9ii_9_i=p01YMr%*-)p%T$d8mlcJzt-U?h7UX}7SP!=KrPYu zbnr|a03Iwl!jpcoISOKqFr)P%ap6se>zX&1OGvfSdSDI-v01-R@VU|!ky4m9=vl)r zOcS@fk+|sH1Mgy(%fqtZ7{cVq}Z(@CzkL^tNW9!}SAquqx(B#W+eq3fb9n7pwEAc8kPd27(^G&)B4p``H2C;sb8 zgF#A00f0&*E847#BZa+hpp#i_!yTN;mKC>Z>o>VXvXeUwC1srJu;XNElXV_~K z=UwA+Qgi{C@7yB|3VNPHnsx4*qe_!QA`&PF7Y@xFh_2d5BJ#Q<_PTpQkqaBld7cRK zlLaLBL~UQ;h95Mi!{HPbyBSitSB@*0Xj$x>b=^(OBzeF}BJGCc7QJ(lbf8GO>8l5? zqxBiq)VSAAT?4ucBIHgF8;nzsxDyG(d2hrWe@J`Rc-6g&bS2-V!;97ch+@XNs{Jb* z(cW+2~ z9X<>H4htNR?Wb)Xig6YS-vUkyKiE+!m5F+J%Uk<`8evee-@Hgc(z|;k1+Ss@c3I zZ{IlZ<9dce9IRB+94YlAu-F%L;KD(s1p7uuzrw}({Ig!CRRP@aAmA*1&@AG})B(j^ zLb~pn!5OehA1G(n`TGif4=HVPMMD~H6Rxn%w8TENrbI)AsNKy*Fyt-H4A%y$bDZ|X zlEVM`<6iFxzXdX;hE{1P1;;d3{_BwO`)N-&?$4rpX!K{w>k~A@m7NFPa6U#HcB1)R zK~JP9qgdA&E3_~~|7Q$z06FQJBM-i<8aVdDwr^>O?(gY?j81xmW^a%G*?Kh{hIoib zg}8$`^l|%8r!xG*r9mxxTgd%szd8K&p2>HhhWvpXyyKO>y@+oil-OOfI;{GSJ;*r2 zP0%}sJcN48ZgyrKoW@$zM%l$x&W5e5#6kM3-VCH}LKdBPwc&fl58Z0V~?t=t$ro)1Eo%qx12`WS?{Oh`H zEZj<_TnJz1dNYy~^%oBn{#RiGU8nRkJ{)>q)xZi|YH3&JPcNXb%Qx5ZP*QrjK2gn} zcXDyZsZcDt^Vi21n`SSZKsQTd%3v-C4ca4swV!+I4})T|((>@|a2SdA?>MycqOE}T z#P;sLnKj99z}*%OSSB6^|7mB3oC#$We@PsY?ZnZ%BQ(IG4(t^M8+Oh;N>`b=orXeT zRF0SoOK4@M;i1Zy!A23R?;%81aw%Mdnj`?|=wpWgc9(DbeaU$I+wCxW$t;xwY}5F; zDG%J8c4XkoF`FS^zS9InH1ZWXrl04wyOzkt$G6k&0b3)m0T>Hc-&U7|;ZY{;7smF` z0WAv>QG+Je(A#6#z^Q%|Y8+L2%+T&&A-!mSq|5;?>!y5pWXaSzk>+Nc2(+yIz2?0e~y4^r88^ z;-a2^Wf(NxXaO{WZ9%1m;0lHfKoaK@rRFX^F8 zOREs>@_|cCv?h+b9!Gh&3q|;XHfTlhjN&5Ij-;2DfH`n3I}hh_ zXndXX+5y$GW1Q0dEc0+UTm7vN8l6v~y?VrgDThHw`huyf7YS zx(y$d$ieCQ{Odmp2~mq0l6z~Cd}QY}upHVdCUAVzQ!*KMhbi+g@PTdeeB+Imw&!S4 zke>vPJa1c1wH_W`+NxBD#3j-60|#M-P}1)F^FHx)j+Tz}ZnSC&&4W%egF zYZ)l|_o2uyxcbo3SuY?I?wwttx8kk{YKIYdLcj5%B`gHyihA<4EF{xv6`F-VI9QO>^l>+Rjr zV5g*bBsQ^u6U$<-Q>QrKQW1>}xf#%Nd@1tf+R?f1-kQT*I9})b>2M&iRaxvxA>lF* z5$d^dKAC^#QYOMPTn`KN(8Aih^`Zr4Vc~F)l?al70xUECXlbM8PNkM60SmKvwG`;e z$=@1Ew#^`Ew}gZM4}6%S!*ecU;jUoBqzFdK1c0ZnmBQ>cWy+A@Tu=;GI?T67WmiyG zLvyN2RFmV2z^R2eHP8w$H=u0rSQ8C=p0dJ+BKe%!htGE~O)vTur98_L!;A~d!DoO; zO;e9za;cJv+Fnf!IgGM`)a%51SB!k(SjPHY-p5ZiuG~fA!kS_;GebEyRDIQKuqQpp za>-@qaKm}F4oaYzYbYJx;gH`BG1!VFdDys$m#|X?JE%pdd%#ZR6npTjxb)68&hrSq z$<-UQ(90gL&*h>y(p?Bp6X{#ABuk4ydTFuRc7R4I67t;2ymi~TSzOA>!O+C{mMRX0 zNxD<;O&zBEHn{N>MhLoe{B+#Zn3R%o;DKX}o!n5(uE!zBqeD#Q-e7asmQx`Dm%kd- z+1w>zPK{?luK5BW8SdV~@#POpj!6bC)OiwDFX4hT3U%195w0imA8>bUZ+*5Nh^MMF+kFF|JNkUK|6I*BkWBqX41Ul=`RH+|XK_6OcD-Zd_ zkIH~6TLo?sAvr>d1vR)pX%3?Dd^Dgin#?t8?mPkuhIpG8sN?NzjwAz(cJpr4J6evw zOw1Pns)D%iY0YFR$kU-OGSC4#zJD;K4PlT12ZPcxFcZ^32^jL(#KFf>I4JZwD@QtRudz$$M%P5e2gQ8F+c;(5>e%A>?2{Loa|g6vN;luNa2+ zxEgY!yA<;&IA&2jMFWLaxNuLY1t++Zr!9fkWgF}?`r0!lDMt>06W(Qhzzgc+sY#Cd z9todLXPGO+?U3M`)M2{-2R{Y)J$>IC@~POY!B^Hr_sOVR+xM66oe1zw_%znzzzYp0JINgc zJLbvs3hl=C`aD0JVJatV{5vjT>)(MQdr&;F{ae@llrN7n$!aNh-Z4(I-V4*TBjQfm z0x9)|CWO14m@c53j@S1furv(CvZE&2T)k$)`UxjaFhmvGpB`WqQ}se))YIO9G3DaUSbF* z<*{($AHs=|c${X8dYpb|`M?Qk0Ub^Z@iF-{oi_gR92Qc@r-A$96^n1TA@Kp#Lf(A! ztqbmCA%z?cx=Kzl_$!`6A;lBgxtq6)yPvx&Lpwl87t((9z6Bz6+!d|rwV20i(ztUw z|9Z&LlnU9jfkTi`1PK&zv;~i$og;;C!cB>u)~po${>2lH2KeF0#y#U)3!#EMf1#P0 zuPj^f<(H3$pY5d+#qL@BxlQz}w#PZW{<49kWaI5_ZkS6vj%~dRv|DxF5U0FkGI9HY zVH&Wm27{G7l7}2Fm`_+NhfBLU;G+4I17yAG zD$#`3(Dl?DrQvH9hX6uE7NQrW6)yPRxIZ&Jt6dk)~6|;@$84gFD~7>tjE7H-&fQ4MG~|ZWq3r8@;P&!mQsETsZ`phClJblkY`r;IgvSX8JU777G4rfhTtSZ>9lZITR2 zF4%Y%EuiyeRzUSD^fJ4m0$08^HS!dV&VpnAqK?|EH7rx2`V5u^++()ovhFK5@ z>%!MB$a5!=Ksqd-g0id5q=)BlQH>E7U0~tTP=L$%#FS-m*EW0$u{V#)EC=Dz0>3Z& z^8Tt>=g~GC3Yl(hqD5LQGnH_iwB9cL3Q}F7p^fD2@DKWO3js#3$3TvOOEW9E?gm37 zd>|fM8I_$t9s!rlbY#8qSg9-I*lpz>ka1dyS&rPDbRJ6`h;% zFH0B7e76pB-he^9RWU(br0vW%mi`})6|0@>^j=<>n4!ybJk z<7vw0tIrz)P(_sZ;OyBI^T8O10a1!z*vcPDub2dIVKw$ZLI+v!ZYi^cm>YB}Ev-{B zTo=dCcv^bK^8*vGQ#k@|w?zoL9h#R05p*Bj;*sK0&?MG@XNJLnSEwWUOm5HS_3xy* zf%Ghe0agoua1(c7I8y{OU|QuBbD6$$IogbbspUxdn%i)wZ`?@NrQ=2KZy|e*vZLT3 zSmGlZR$Hi3Gohk^n1e{fLcKGo@wwhHj1UCzFicD2xZ^Yd4C)y-a-G{9wB$vy7AR2^l}#etzLebcwk$lJBPcyOM;79npRx?eK0<#m?g#%DE5}>m1t}Qvxd!!8|4!*@{SxKMWA-3ofd>0mhU>Zn+WT0T6O(|bX?wH3Aj=TToh@- zWh)|Flxq%`oKkq;t`jGQbeLvq@!M}cPmg-Id)sCTG^&hR1cER$zQoK{{=DOFk4ZkO znF0%!4^e7sAF$6+-%y4;BDeuj2=xT>zIzO0Gicvxg}W)=^} z&2?68s>3yj39?8HV!7RV+;}B>jX2n_gF?uTmtipnOV}w~L+4DkABwN^Ry-t0L$pM?VCCb7wYT5GYxs&@ZyRX~tVrE9gpm?%BIN$rKi8Ma zq$!qsf2@dWHRFrLTvu{7-(k$%mE@%HcXX20s&>V_e;S9!y{jOhz%vqdr(nYra5KXK zU|F7f6FVcRrY_3vcyAfV0+S=IV3iS=DP(mSM|$|g!GxW0g2@$Bk+=Y!eJ%mtSlGeu z<>6xpNRoN@@UOay^d(nUoliDPBR%uLUK$n!UYOBFl)(-V$L)``4*+{m zkRCFm{y<+Ec$^o&*_Qd(6^JM}_`X%LxlFlHuZ?Yy?|x}Y-gtTvX{2YHbpZrZrjhL9 z3!!y$UU2NF_A^dzlXHV~MSaQ6^X~oUH<}ME`>NQ0jMLMG0|eJN?1q-aiShMRVYudr~&g7BdfYaU`Eawv3$C&9xq3GZNq<)q9@CkwB)i3UsaZyA2$Flv5(o23o}!9ru5iz%1* zj1K1^*u)TcA73>zkD4Fv^2RG<S%(ZDO2WLK3+p`J9hBmNtoqd%cXD*;-e?Yi@n$Ga2L20(Xk zIhZHGWSSzt1h0$|X-oe&Z7NBd%z$3=Kql-Muoo||jB{=Hm&LmoCd$`gDeNSWwzlEa z`~a(2pp$lrgB2@g{L5fD5eQ1KaAP*IFX}nhp8s(td`)*^fYH_2Q-ALK{>hs<`e#z! zSpkR%0oae+IeE837ElW{GCXq~hg=`Dc<&2mZ!SG^qV7IMMnvchwBXY;L`f_{&+oA+y z!TWz&cWH8HM4DLlfYcU#cfmHOkL=@29_i|vIMNkeEUDYBWKCbnj0|0h?S_)I>W29k zvOBY%Z#M@(&h+@#13#kF%~0<%QOApB@qEkCtT(ZfN;XoDY@h%3l~eGVPc>4$VEeeT z8`{iiwL!@^fc>cGXIK{bsoZ$n|M_s(h-1xxJ-<&~af$>EOFvmz zYmCEhNbkf)6%v^{?wIf@T}0ImDS~CQ>I-V;q1m zEDB~;94@G#s5BT!VgZ91Gpdr!U2x2mT!QL6&pT&GNqAoKkQhRcJR8X?Z1?K z7VCs(IXFI>8`VhnZ2iMup6P?f`~vx~Fbo5pf%Ab8DM&B$FsOd?SciT6syi8(@DuOf zg~E65N-;|TxOb)gqf2DcU9))cH8kmb*(3rI2yV@=l%rxy!p@M4`89=AmsXUIhxdrT zk77r4#4eaG*F`v}o~c9E@cEg)kC+dsRK$uwS z2yNA}O|R87`ZDaONmKgj@NGBkeo9TuFuBVjYveBJ|1rx5BflJf!Wlo8P6C!DcrK8F zeooM1g;Eh(nN#YNj;8dQU;pih7j`?P0XH>oz-?jAmFenbRbb%JYO%Z z`hD*;=w%|pyDpIvUA>Wcv<-{z|91({Sy~pODMPV{8ph+)uk*g)tS~5qV+g!^Hql&{ zK~(D~LWK!MSdaKBOnhuPIEUJ<*UAX))aj#DgMoHbc9?%KLmxlmk6ej z1?yaup2aUfswX+I%OakNrZw{&V2#^Scw9{K}U!@<&h(lP2_qZ zMxORAk?Z$FAJ*tbS=d|7KkS6*e#o&S$n!v42RdhJN?VHCv}qqgY~rU9R2?Bqp)S$b zDA^oU=D@)($W(Ep{kT^)?>uh+^V}L@j7#VL5EbyaFjV29n8ZYsq;h}^{Y@RVjHVhG z8Er~tzYSd~=alZ&VI#Ul7WGRfe0V%lklu>o`8F<3Y*c!Wk%nvE{)}VvxVYDoth)Es zlY1w22b4CEv1<%;o3`Z}2TneUG5Mh`@L(sT$QIaAh?n@-CH)pgVL8PxzI+hH?i<4U z+?FfXUeP%Xmb2H?xDzoH56f>(z{?WFz_z?o7IN*A&*;{`gn5cfMyTJ^VAKvhm4wPu zMFKe;LFK`w+b7C&8Eq_{bD|W+e=)5Yr*~XAW`*zU3c_(%4?K7B)DAOdf@FtNyTDac z1e{dE{z3r!)1@rA0ds)Nb=DT0k;8pu?u zm=bK8GW4?-??D*A49DFq=u^wHYP?TI2U0lY4D%uFHFnOOWwhZ4!!5FFhZo?GatFr_ z-k@o}!NIgg_lYMv{Or~gQ|XSB5K*>MHUSi~jxCa;E2zMeOE>+ryNBgS3Nd8C<1V!! z?wYg*<$!KJ=*OQjH>C7ud{Bve6YtJu-hQQ;t-lhpmp~pCE zAMM_`7apdbgvT7w=0}>MM>NZYw-+ACNRrT)GH7`eumLhU zkR<@M6i5(Uu8I>Tzb!@!opI74D}Wn4Dn(uNvvu!`#a9`RC_zKTxoY2dPT!ZDm>59F zXdc`*l!mf>N$&9dFE=KPe=!1$Szdj@~RCwf-I7Cis>5OpdLSW^{DOUy9qi}J(WSr~lbakYOs(m$)ft)5NkS^PcPB#@u?FW-7Jz7F?S zyNw73YKl6!`b(9%xSaYrB3QJR$ zxdr#7w5~b3t1vJJDB*bl33N4t#!ct^;AFx!Z^OXLg@Siv;HAEEGT_5sDy1dn%L)uZ zim6{Y)VP9CBYf_YZm)6`)1&jyBTNe5U=1hhnmc;DD9J$`J`0j}U6`cUe<-OyuB z;Py07Axclr5;jeIa{}&xuZsUtVBj|E z@B!Y!x_u<3t9@|O^-T3QCnZmH8C*o3QDLgTzOK_X;n@t7Xs4ptVqi7;Oru=AwkSy@!ZNLeS4M~W~r=NM3<&8382*a5TEc|+?#gnbvT^uxvKp32xWC`ofA`8)^UP6KcN{Q#FZ?tb)J$I^0eskVT_ zR@UO!x_I5=U^2qo5BOP0caums2#3T?f7E^x^Zg6|e%>7V$-ypZxq%U)mmCL6lxZ{| zdg7Z?!WGxpTym=O$6K=9&yvA zK=jh%_6H9GZtO9K+!@f^g*nOAD6sw2(m62Vp_{mi-#Os>6LV@FMbg0>3<)q+ z-}|@57=~lK>@g&WG)TYn+qqBzwMpW0HOyFgBh31iW(sw-Hyqs0rnjTa3Q605ND))(lYx0V+$>1s->I9Rc z93^2Qwel-Jc|na9E>(-5c)s>#ThJWDws>#M(R0)oLNNE^w&*3*uw~TQw*}Q;Ejb#U(c&ijwB0tiE?>sKgP^8p*Lj_bIH<33_+m0`) zFTLpVZi}%BQcJ?kx#g`x;SiuK5W{^MVpc?MTG5cFh&-{cQXV?Cw zLS1&tRBc5?IpWJoDm1~3MU5d$7}n^6`=UHe&6MpA-)qrjhW<+MKXRDzCVgCP1zH^= zWna9q5ZH%(t=V9ShB8<926mB^pSI1g9neUsLD%GRYMlSrrtS2ixm`w#_~brJ%_Np= zqAtoEWexWR_`C}=z7c@2z_^#NMrh0vU?;?40#a!V-_23bjp240dfbG5hE&dqS>goR z%phh9`y#0>xF@V1RoJurHFZm_d!c6Uxe7PE6on&InCaT`YKAaSWlt`O>xER!%M6d* zDu(H5ivq(bPZV~!dM~*uwETMqwI)kWIL&J+x_xsbx;AksHm<@&SF@i;9{yjQGEfKf=HFm77EWE92^6kcog) z4mM)idgU-VMrKcBJj6fTx-`gXq~#Sb&B=e&!hFWR!AUuOqG4qWmR@{k$=Dubth8N2 zC5;4eMMXVMuv#}hwsZG2HUnr)KrdeNLPI{^ZB}BV1V!fSuKt&Mf2dF|K4JBtJ@C4D zX=#tMqDS&CFBHIHy3IHc>dn{QH-@OqQA~f=DP_Lz_M59VChC!!n97)S$IX9dDx=W4 z#Dndju~Dm&95HDCQW~Ku<1Bq-#!2xzoMyh0N`J?2lQNX%ZDNdR#rpT&{vy88*()Ah zf`z4fHJ~4V>#@A?7YDoFQuZ6)MCsfnx~3b}{7WYo5+M-Q=y2g_EcvU=>@4ov^M}(( zPduCQ&kE!OY0r)g(K6Q7PsA=~Nqh0k#zt5j=7-xgKKlQX*(JrOu+YDKe%8JjFA_b& zf1J#Z$KFwpV=%2uUN8ss?hPQhH4VM}`@QW|l;Uk-SYi1=j}AH#uOavXU07Z*%aZ}n z)L@*TS>GMrTN4D{wP%==#RN^&GFCqM?`bFFD`QH~Jo2>Rein~#T14dYJ&-#P791zF zbo)h}>F?tmX)F5Dcqi3sMCwfGp5A-`C@vP2o#%b?d`2S2A^cEZZ1EyazCV&iD%U zWR4}w3EKK`$=dzq-Ukj(e1-np0h7S7VM_z|Dz61kS2A9-C@>hR$Rr`0v!FgG1(tFM!$LhjI6C28S-3Y4!A zXFs}@l^q}ZJ?-fLgATD^z+|@bB@RZK_fHtMv71CnyBErDCMmYwidY2yhRJ#)21*ew zMoA6s)<_l*(oXK%r$3#X?WaEY5L;y%3`HcI^fD*15urZR#t4ojTM|WrCftmq`SLWD zwg1zIGXbv1pO^{dhoGzmp#`{KD2;N;LknV)X!{?h?6Fh&_9-A~R~++k3B8sWmY*g^ zA>pxq8KQC~lTzKdY69485DdvC)y_wkOEq_?`BNEHv&5kK=F+KVR+J_jM74_(Dv-lJ z)3xxNlUF=GtTIQT-?`EGw9fi54Ug=LHI?BQf(?xDNZ~~n9LVqpaXaE&D9Z55+I0~| zLJY}Thu=+f$`g^&(D|OYbdMf8dk+Q#`X_ zpjah$ddhRL<6PdI(iSQ=chTCT(%;Qol?)DcTpL|0@xS#)t8`JZ8wt1q)^!~;5++;M z-T9IR{0%K4z^VM*?gL|X%bMJv{<*K^KIlAM(Dj9r*h9_1j3*c{=xY#eC|@wRHlYlq z3AQD-tO`b+ot;K8>i1brU=(K3?S>=5VZ89e(BQPW zM=zg%e>Wo?kyhyUG|CM=99Fi@Ff%@#{xCGuZ3_d2aD&bIz{C0ui_z?M*2z$q`iv+K z=G-d?#*mLacs3PzGtzf4>*8%AJv~DWIMIzFwa1~4KA!;FGO28Ijo!(=RkKZaf{{Qi zb&tZ`uUyvW?ADS>|0jlMAestB)@{Ch-oVaePP005`Py)1J|t68&TIkEo)!9;&!PIZ9;>_B{E_ zaO0cjxgr#&$d{|YmJWa1$*!@$^F( z`13Fw_hgPR=!CSb!&4ayk8M(N*>$M zu;wxfYBl5{EGL}8qMd@-alkYI0h)z25rh&z*Y2}T^sGo+`f*Jacdg4@ku3C~q{#^) zB|WR^@db8nT>R|ov`L$GJl>}Py}ZNJpT@A585KC3>5=EosmPs&IY~=5#?{q^KlCe>h^)SK@aZd(-o^O=g9~q{YAM z7HqvdoSnz_e33fAi!D?&S+|hT&omZbnZi#QC+L#dGl!*fs?51ApfV3%Q_mY0@F09YmBR7J27@JJK$9D7^&ys4br1ZIZl@J>LTDxHrow@2-FwJ{Gz zjJhFho64_^yppF`hkO4o78;rU41A+{6`rA@4oruB&ORW30JHqg7e9HOdA|V5qityB zohM+Cfjun8!*7YScFQ`HvpRF4qzR5{aUmGyMo}07-7Qd)=v9tsW}KI#t$2L?+uYF( zmQ5D?1i^xR7Dkq&27d=L;;=Q+A9$Ca=xw>g%leM{9$n(`!3Ns3Ge(|f1v#{CSRsp& zy7(qkoyrNyt17W(u6)9R$Q*@pd{i-z+y4jazZ&Cql7S9QtSExwb4(BXVjyVdfZwF4 z!|$Yu$_g9}_PY;c$Ip2mDSB7iyT|Wus!N&-&UkH|8NvyX2$U6of!euN_&^TAnq}S3X7Jf;X*>zi5T~H-R9KzS z&iDR&^1LJPj3w5wH{!=6VxkW^Smc_L`(AN!> zDk>^<`NT?uLL>+gup(hokj)TKQ3zi`0)dcVLf8}(w-!`XRBqKu6_*OFRH!ShsI*|E zN-GE|Ra>o4MMaC1Tk%)=d!Faa%=>;>SgiNn{LxDC&N*jh&YXSDFi6iM8^2`$lL`&Y zS!RUbYND7^c~5dMp5TT&WLbowVGsvsWT!qX(0h;|Q%D{)GJlxD++H*DgP-HShs3z4 zv4~sh3QL?oZ4i~VIpH%$^t@Lx-VtZELe7TE7~*#ED$Uk3+PB{V@q)E6;Oy5oD)X{F?^|xFaEJV zZ5oV0`%*W?ofHrfA~W$=rw{%ml-Y-;TJqvchS0S;OxUbq)TB_`>bMhU7uvgXk6C*M z$;>>Kbj5Z5$d3bio0y0iuUA#U3%^Pa#IXOQT_(8#sbw}o6K_)eFml!QrCz_yk6~Lf zI;8h4(c%_G;R#z7tWSOIF)83Y5?d^^_*zf9V)LgL^&@ZBKLw^sV$%eSjzdKLzz!zn3=I!7QnQoDcmk2d_X;oWz+(7^5zaFpG} zyuVcI77hx5&3H?4Ud;ZmNpS!|Z~_OQ^!VRDH?6@bH+05D17){psB=RHhS$Ed#pNa9 z=ol@*@IR0F@&D!$1nU6@U{U&suJiuymY|*^&`E7_qvLe|HgB6SXJkxXtpo7&xT7}v zGZ4!Iu;a4)Ri!bmS_j}8;D%a9AUaXIzi!G^4>i2|dq3m*b0l~Gf^~!4AAaR+*UfEk z%KNSXzPTd-V(=54qNQo_$->e9n@bZ?niuC!?tk^9KimgWoMNuU4gUvA)4)-QPSa8q zwfV=Y|M{h|qeCuY#ECz&Bi22AeTRm{=>N!4WgMlbjh0|X(=!g`5@Z;j@T_SgWh63d z#erZ5qstqe6W8#hx)QD#2EpVGoqxl{X{LJ-Zo(3TP$bN_h0o_Nn;7GEhG7HVe!y>D zmNTe54d#%M25Q;W12c7Fc{Cy|9c@mg zVjOT+>L#Y~_rlyxajR8Kea)XPy<>cRQ%_{{cyYywRT0do8~3BtEd0*FluHWyb(7bk z56JKn<*cVi-|FZ`Dl(%44a9h`o0t(p1Il_pmDq)Rf`l`KS;;UY%-!!a`cvH~K?A!f zxa!L#xDXVAo7h`CExewN)ut6jb+EuN({;~Z z{ObRd-q|<bG>hVLWQJzp{N6#L03Q!JBPgOG3H(E?g3hBX4uAwW06l>^u zHgE67C>rg@z=3v)ZWDFuhC0UeGzxYf)2>-DP-UE~{T3?ZJg}u5WSOrPA*%AZ1`um{ znK;*<-T65hA`Yk9fCkuaufl{v17i)h%+^fY*vNhoB0GOS;@^~?dAzwX6p;l5fm9_vlKe`sb}j304DSNP1Nj1j=N7-)bX5AhoOckj4! z%wTlUQKdBUHYmL!uMd>4S<>ykJ7m=K`bu~!;`K%I2rMv{?Z5OlU@(8E zPzsyjGY>e30t_$})8E*iFcz5*E5v9=&d6@VOYLiQau&* zlIs5m5~1PFaXr2t-z^4eL zrs5L2unGDkS+=qm8BDczL-d``8QR~u#4+8h<-!lcIP=dNxZ$DmNsr2;?ERhtoc{#o~$ldJz@#W;hN{&=XR`P4G- zl{JFO?WDdboWi!t3aTy!?9l{1+9;`~U>#?C?^Wq%rvjqXXki+i9gQ^e>EpGP--!7n zh1${X#^Q#hg|lH|BoatC{&CA%+;$j0_QnUV!4$t4iHCSLX@J~>FD}h&4_)zhFf|f% zKsvxML2!=OQ<>^_(mzT|&urT~vpID}A9fZbiv?YQ+?qe`m`aE#KzsH-e)DA375g!< zenA%sH-=cE5-H}^C$w(PN?u7Uh9K>FL_h>h4Fq_z8ldqW%8!%ZBGZej6fpv?z@joy zKmUQ(Co>Qmm*BgzR6h=NAZnf0^^tfC&xIS)0Iq1FX0a>(;0`IE>N5>zx8ZflXFxz`%CBip~EnIKGZjm)XU6i>UOg_x`5&4=yj1IMWd~2haJwp~>}aNr zP1||DyF8JLzyWN)p`eM5X1KKM=bm!vA#L`R)Yv$06wQ|CeeabYs*`r&0Zaf$O&n{SVuli^MtHng@{bL*7iw^qX zUY2zajrl#2uHsb09ZIGp@^YXNTSFM@{&YfrdVhe?Fo`Nw6^uAf=U4G&*{kcjt$2k6 zIC~+5Ru&S2Zmksgz)FVPAJ@)YQ5u8qU35%0mZI#1G+I-HY3l>ED{2EwT#vg=!g1bd zTiXof*@nE*z7F^Mt;0isMXXNG2{s@kM?WtTvfMet=p~hjx%`TA3TZ z>f>Rf*+)P){DVVyA&odJ+yWdhrt5v!+ZX*+unJ`og^0s}DcmVuts1`eh5i8F7w}A6 zq?4ylN4Ivf!jcMQp*bH&_{ypMF>Cd~;$pi1JxT9Yaq`1F2ZS9Vo58_`YB0`smI#iU=!FVS`qACdTMN^NBh;U(x-N+t~+wbY<)@ zCj>6HM4}i)gM3NEm{)gR!9U8>dcRCp&f?t%$!v0Ig}El>ur_cD3UhNa!fvH=x_)OO zrXz2?Hap05hVz;ZOs(s#4tI9SJ91eLyXOqf73iHtG(jlzc2=|t4@t6g{B{2&^n``p zYoKXH@O1OcaKF*s0N_;!u#a7h;x<`(ZZn2CFA6;{OxsZtzoI8>Q_3co?fJ08d z3R5zuYNf#{V)u%SgGy-$dqUe3EmqiV{zHd;)ZT-2?L%p#4jb6JaBO*H5pHQ3-Oj`# zMHF{b?0pwL^8ppRk%TsbCSFn)n{s1~JU7>?IVvQktEPa zt*O8nKy-#hDaEujfj&4CAfZ+B-)|=Q4u+ zQu?Ew0xU4SU0+mGn?xjPqNG5qBnvx;!@3W3S)|p1byy|Du9glj_>!?J;>Nz1dV*qs zedBSPlHpuWLjz#?cBQ;skvt|qY=HWl{bon|>oa;h9fgQXiyy;Zw!YS+# zn=mkqxt)5z>=n7AEzm9h)!+7rVM?MCrmGe~Kl!8!nb8x&V47OUj$Tk;=%g7tHZw3Z zs1l7y@iGvCKGxn4%-IipW6`^Gy1%Yojf;F0HR*EvhLI!AW-?xS^`LM;B+$V>-d?B0?h_~c2@`d??br| zrHBGpfG;op-c`RE9&pj;_qgn6U>_@|Lbexxa0@Z}vqJ`gCL=CO3OHPtuq#bc@3>cF z&dl0<nnHuKkSc&BHfqhh6}0j^>{Oi_YB6xx zj4^O$X<}o%8YXZrEML{}?0?IdSM&Bs@_4$WoDXZ*GZ>*V*tt5Y5DXk1$D(jN7o0ux zP+nQ{<5UgJ1Q@3bS={RwP->h<4#ovMZCsisNw`$tn-n7M!L>B!1}p}X&{d>^(4b1dV-J7l z&Pn87kArj}e}iC`wuv~<>p0-x?E&Aq&;>n(gQ^FnpW)C3zAGLZ_3pycXp!fajHzJ_ zEo7!Wx5{j{`@9-dr`ehyT{F3otIG!Ixa&AQDF;9{S5&#uzg@7Jd}}bJ0Ssp13P&}l zBuq4tPrxMg@sV+-0X;^g(5LiWejrWj{1P;pH&Q!xm{^3mFT8mSVOfcm68_4+tfp!_ zn4*n{k6nDGInx*w44sOxHE7QZ%~6GLKiS0;1E$16pgFhq=lv4Httk;V>b>v=GK4E} z`?rLM8{YQ}+n^YCeB^oPV%#v43^()$acg%z)b4D3;LYC5IwL2T;1HZw?#@I$N$vLt zTH3Ro40x(LB^~fcnCwbS2zNbTQjfUdrovtZGv)|?1wk;|*O8}46+#ICrWxE0rtMGk ziNb-oc|XtN3VEZ_1wl#2zQ5z-(l<5}4&}+L)p8;i?+*eGccu04+#}O5PO!IvyQDMG zvVmq-cz@b_xr8wDX`Fyl(m*)Vp!2=mo zP4pv8EjMgV$(H5+*ncFxz)Y5oSs-(c8LeLh5t?Z9`$i zGZ3IsFc{Q?=#Rd%*xAcNhEsJg1M5*I6bl!bHAwL}iwTp@)EHjNs+^67lW7RUBZ?J` z){rTm7=|Mw`j>2oYi>Do9z!BpEwrIV1-#j2cpos0IwIW9ptggZkIjUfOfZ=)d}JH| zY_tpJXw^CnUb~(GSgU5R+e@f>*b?&rc2XP5pGY#gBuC>7G{a{c)ul15Lw_8(i3FRW zk4SYqXD>=A*iNvJd0j%WudDVeosB7QAy7ndmu4qR2Z9M04 zx)9k33SdMc_yZyAT9}hS^ZChoy>J7xvZDV`pLjuoIe3j0)uQ(ZdvWOW3e1#^!qBai zxbee|e64%^`1|wl-^QB(camZlmBj6Bel2^zo$|O{r~xGgL=I!0PCC-88Q6<^TRfwm zT5&Qyj>XZ6QBXkmSZi+{$3r%dKUe(yswY_d(ic~%d5H`eHVAhYgW%TrdJ7*AL~A%r z4P$ekz5Ivt$lX{N!5SMPHL1~V@Mst7BbVSOI(!37p%M2tYD7)vv}*kDhBpV5Ib&#@ zbeLa^&1KYsi`<1UHn)6q9T~)F$N1rm1dNy-!Jup<3=tC@DW08w`KAl#5Y-_?R%eKO zIU?kov-z+OzLK2a5Va^e^U8Qy5J&1912H35wTU6DEcO`ouuzA+fii8^&E<`$EX}V{ z+Mx=)2XF}orbdEE%S*~uKShU=hif(7&m>MOhxou=Sf9cb>f#+%Jxaqz;-mwUS zE_#lMq+{&$j7AC%X;msHesw`wXv#fw6P#A zqYjR!J4@B9`N!MWD)vSb9jpYAEiXQ<#TbCeQ;aQG9!Of3LlP$5w3f`6 zT#bIVaC;#e`Bxl51*iP82be+F7|sQWal!!qlXTOPz`5kz#($&(C7f=lD7_75t;eNO z!U^ev&;y`(?}#iA+^c|;XLKSzYfq$M;cs%QmPph!LQub2s+gTfEB>b5T5&8Jo+%n+$TM##hTmX9IQCr=twnKl{@N%jScExb;jl&@gU`E|J`A@x6mDW8li8B2y(FmMul|97S&} zx4{BqyM#bJkk6;~^f~#ku%yuc3daedfIfC(pT^-ho_%RA$lXmjJ5C%M$zf0ZUTy1qAqv5F=G)`A464Pu9BX-3xMcby;Gi8rEPTtt0=`si07NiTfYb`;> z3V3)n`S7poD6J)E_L~!4P1^A;k6CS%W8F9mTmTvCD5N)4vzZ@OoguTuy0+@lr86Ff!MEktC#b$^_l6p z*@Exxh(q97#p!aJJD${keB#t=0sZTpOwk~HFa|wL z#;Uf#Ub54lQM8t{cl;R*d08aoij~KpUxXf}3Y2OkNl%^>-)6r)y0$)XxM6e>H>L{Qu{QCz6Xu

WGg$RTtPtZE>woP$?RyheApH@-93`gS1*}ls5qrkpRE%5t-{E;rsOXX3!D6I zDahfJg4uwXHcNE9&ccXJ%Z`=p?Oc;_xSAI;r83ObaF*cKa%C-Sig&WnBc{Ta-&F^7 zhgA()8Ww5py{GkL^vsh4*cq8hJh4{|rwPK4u8}_JitAgU9YYOI&Y@$_V90AgT_Jqe@`c#*-j28G~>R|8|qrs4`;bGX$iiDfB zQ60Bj`~v^!6C(aYhfM5OTd3wz%K`P=>an+QlYIijk4>^q9U+SEquVUp`RmW8*_&m0 zy2m$?<4oPtb9H?D@-pab71ZNB(BMPDNQd#v9^tu2=gNm{QglXj0=91w2iX2;xjMc) zVW+YRJ$Mw+dH)gVc!Lic;!b%z3;%uG=Zq#buxjWSifdAdIwj6Y&Nbp;5NuYG2(m%1 z`Hn0>ncwYiJz|)ql1Q+&osCm@^~%yEKJ9EU6`u4kv!lDTv1`sBJ>-lS96TBr>=2Bg zgfO&x7DF&Wj^BV9XeVs!<|}Tymwm1L#>QiXUkNsg75K4E{@tt98UM_6Ta`t%%5B3E z$}q*#yht;7TvcVw*l{RSKvysBW;L!s!yQ8hTmMG+Dx^a+Q*yZ(`$AjxM36sZU> z#p4N>@U#GvJ7_RrUU`^CX`|?V8H4kCpE_t*0PBJ(0jwRrX~>_e^BFb|S=EGHMe3e{i4tfJ3c24M)o1;F3EWRt1h-01oI;;s{gmI4B%dI_`I6v8IxJ#k#e7*%c}R z-JOBqKuZ?S(;?f9b@VUTIddL!hQRl}DkdUd6I1El=iTpIxaVC7IAAc)a>a9X$cD!_ zyg83KL-0EsZYHQ}w&dNA6W?Zemd}(aY8Nw@Dm!@JgBNm47tvba=8Bgz`UmrRIg0yBz?d#1|i1o)QdW(xPri z$!jDv8|E=xUV6*fy<(XDwm?R4atxK+BBcS=hyzuzj)JOqLZmLgMA{H*NO=4YSLhIT7g zIT39yWa3>-3-DhcI^@ytPuT}J5fF^hELiclPR{nDAa*)j0t}4COB(KhO-v1T>!{D) zd}(R|H_E>iX~;rBah5}mR5aY;tnUW`y5l!8QUe7t?c=ICQ=Ksd-ns_`%)%rp|DqSf zXx;K^4oonGKM3ANre7uNzpOaE^rge$kH1r{nBAF}jY9A(j-J=E2mae@Q@Z+i4co({ zKjmP?m(!%XJWNx%+0klAl;;0<^#dA!d00hWYqs}65t>OGzeuDQD}aSEd5WvfiUj`9 z4SjgmJji$A# zSdv$y8_mIZddnj6q4Qg~4$e|`ekg?yBN$E!2N%OVPAL*5UfYR=r1yQW4hF;flYpV* zrArL@HvARw?^Qa1G>2;uuH)b zJu=V04>4=kHEHcMfWvCw4O6fY-j>Q(e{|z&MR7t>4SP&cgMu2}(AQ$gQkxXA4m|Lo zU`yUDeA^I@z)E!KpeWAnzki`@m%#>kfK8F(VB2C;m9%H1!$hviBVyR9n}%;2V4Jpk zS0}$o;q~L0kqQiE?_q&slAIUpVO?cAMr z{P3a}7lfn3#kjI?arK0Yf>XF$&k}{Q&<%VqsPHhv9g?GTQQsHha(OF#_9ECVt{&%fr^A{`9qcGf= z*2R_O6lUkdcA^Jjr{q2($AI1U%f2_FS zl|`fy9WIlH4i{8A8bxT@9+%Ya z81A+E4GxdHVNjhN{c8jJwmkg%EvK?AoIevjE=G1^k))`Cal&s$EE)07mhrc7)W?78 zSF7=B{CwV{zb;PUbI0>0uzBFd^WM1`+?aA#e!JwX1s_;{9a>yYxbuKn!z`z!AvX_r zjpBFJk2`-wD=f)#>;?o*Q^=a}0URe?`m*$Uz^7i>gwbl?cBlIyNLM2CyicMj-uG93oh z>`bQH?(O>IH7_wRtW?NqHHdT3mRMYXPwmN7l3I0Fqd}}IQ_T)x#=b)GCLMt)xZ;6Y zcqIlJR`tl&SUfQ0s8f0=uFmqJOj2opB`yzu7-$lXnbEKRyQkJq3dSTa_8GG1nAfN` zXI#?x-}F&>X^(tSNSwIg>GdXOY-`2*l=M+r9^Oh%(HSg%gC$PP0M;IZ2+{Gc(GXgA zP2>5j2d=~3QnDn-r+~y@dV`r_Hym$23o3PPmpm4%5;m;w_-UOO@N}>xI>q*cE%6ax zQyu87W*rSLI{d7h!4RGJ8kJ6~tpA>P30INfUK4_($UpLAAp|_Lgm4}=kgAU&czPi` znsJqgkXr#3$Dj)nj4wD~-lyaY%+0_QfNTEuu3F6Y(ZXdykQ4@7Ci{tut!{|Vx4IEZ zG=VUj&~=F6n(}ZsgQr9miB5TqRef!5$~lMCB^R-71ffV=9ibHS)Ug4i=8TIEb|pSI zSr&xloLqzt?RsjU>v+1*P}BI@T_=Fdz%=Ow5@*lj*RG?2BTil)H=Kbw1R8{tJ7h>> z&kkmN=)$2^ErwL5&mnD3TY`5OLRnvQn$E(e%gWEE4XLSS9=pl3LFDkj!^VRY=;~7A zHZnNloPqK^JXbBYPWm;kscH&@6(mDDRKmc>kO$*{A$136?&^Gihe8PKLK~flzMs#& znU04s7|($9|A1;kiQ;5laR`#jN0M}vi?LSvD9{Jo30cEcHEbwWi13;mkK7xE_N3!s z48=n#*i@Siws9HuEsj$8CUf*rpkm2^3D^=bQG1cT=!Ov=Fw9m*$7a~}qMmIpxXL9} zJOD;AyFLx=U0Iw^$!DpntwhDWxBru*JJ_*KPQo_o)lws?hlfr#oK&-u6T#Eylg2mF zb6%rmKL4>n+Y**J_}DkOpvyN+S4i9{;pzkf#)uInE-g@|^4e@Lkl=G+a%|3l#m5r` zz>(ec_uGdirVwz*^%D-i6Vtsxn!LQ=aKwfYd@;kJnV?=+Q`vcQ)-6Oc6n8$LC)u~L zA2T7vWs~!e0CU7o^`dJxeU~M9Lz{3kYsuC zi3Q7#$SG}{40DcH>`#dZn@t2~q3dgnaSdzUn5#+^r4CNhMhMKkO`L zqQ^Id2Hz{I#C`A=deF~BZG*96*q}*p*ueOByIy=a2EW3w!BTObf{H_Dv5K6lE3V@d zlem$?f`$#aC4qcHXq*e-=KciSp@g61g8{ekDR7UT9InxzfV`%v8gYFa!u7JN0Oiw- zQP0tmw7h#Q~LUM9VI5s?kUs zKPyDYr#EfSi{Pm0$YKI0xP%eBdu-zpP6aTW13?(cyFvBzBy~`@{81lSmcz?VtC1Dx z{2mHg*(wYGBwpyl6zi=~rhU&<4O$-hWBVVU8qU~A(om6&0-cvpuABhs99PL4i-v*3 zej>3&&O0gE)ZV7DIykdS!Ugt>tCc(}i6EYHSkAJJ9TqLWvSoV}tUDLN!PF)Pm>F05 zc?M#b-l2#FN>6mW;kASMB}nv!f9y=qx1?XJ+Jd3Ti;l}Ex6yBWRR1j3FBg)D+W5|U zZcUQvVS`qBOmHe>p(ECBwFTK{Bq5*Cg9^Nwg^3qRm~QRlTgu2$4;;RW8kNHbb5UNo&3pLup?g^`!Ow3TRrn*2vW3d$L# zBO>pGzZ6}NyS6F%mMYPky=f@f>0AU;4z$bXe*33u5@m8JRvsmA$^vybDfS&s<2DRdE!`puKAs0P4MGBq*u&UcbVWWSI$yQcDDT3b49GgG){#V+? zV?`=0xOl9o8~V}EJREDMw}P;LmP!=tgqc8h0I*XIwo9^>IMT~>a!390 zpi-GAPE=DF6-sc(qu>@7mB3UigMCaZ+r`;q<62QWX&U}na)GqdbKwtt%LO6AeBH#?+_WG0n7Y{<|a_v&bO}}}E@|D;1?EV}rTKFt>a+yFw ztBNuOm8=-1upmFP+h_2iAcI4BIBR~8%ddkWw#W1s@>2iOsIr5OUbXncqe=rPdyucki zupx!ZC|*s)gbLhCtFyFSZZl3Va7YSHk1jZT+Rx%xo#sdym$cttyU^G+qZL1k4 z^q_s~R!{eFXs@%#p`@HQskpWNu=1Dg3~&`))fDLFd4jwL`zvg~(~bjT4M5qY7;kK{ znQ)ibfcx?L3T8IGz`?{75C>B-kXOk9OqYleP~P>6+;>(d02{VahQdl=w4yH*#hbbYNNln|o@oBiTrlRD>|b5gC$Zfm83o;ea!KBS%6)c)YVoQz>8bH&N3 zsZa|gt0f{6F&qaZxbOhJU9s}#Z}VICa78;9JZgqC+P)u62El+v(PGV_+iABhYE>IF z-F53R6+?IVLe65*oNjA4rVffQE?~h|n8l-tX;)0$*_mFh4fF zdJ=9sBjkeCW9Vg4UPBc|b%9W?;$AhAdn1I0I%_f+>67KfqlGn~$4fh7RGvS=nH%0-cpH)18zi}xzV{>T7LI*jB$^u zVB(|0FszZ={}^}lj8>ubl%>;{7;`5h4=seDLD@+d|2I^w5&x^t&|Ge9MPV;iB%`cu zF#c7g=UoOV(SmbNheWb4_v4$k(?v*}oY4S`4n9?>(Ex4#VZiOb0Uk6GL29(afPV)Z zsnJ9-I(k;l&yu6oT)<+hUf}#1rWWOFU~njEDXZWu182~74Q)HIO5F!f*g!$x$C@*N zv7v}XV=X#?@1hqZdU`IX&ZfW{;zEUTY6kJkSBdfd|ttOCLDFVk@fmN;U4L(?d@CRN_=^D4g1iK9yf68}x)&WbJMzB(X zWd&2#QiOZ}Yof|sO+=PtGGAH)NiWI3nw$6ESJA-g=Syo{A;?sFvmN#}+Iyy zG_t1Muf5q45HWHe85=|nrmJh}iKXDn?4R6u^J5-2mOHmf7hB#*HG)*H!1%O`msuP&N?vn zr^n;Zc>@M;{E&VF`s5c59#YV6@F2mV#^4W+TiaPrP9QpbOE~&A<-X-N(VL=b;kysr zL!;rMynSo=mZ@n#b1LiiPssLxXAdV$b zT2g=zGNm0%mr>m=E9Z>Bklnyq8Q~-Uxlnu}HM4u!*?(nN#Qn+agxXRBuyj7HO2#EE z8Gxd%=A&tF;=6V{lcAO=MSmPiGv33jS2CRLfm$_<^RXPz5Tcu&-3F`icll7WT0FC4 zIKcKBTY+>uy~GT%ift`y2EnLs%0}@BZ=bp^tK2&n2y=C1??n1ggKir4F%xng2b$B^ z)I}vS+zyGR6p)x!vVep`;=c^C-FXdPD85^C#UG1!W*Fu`(LutFK&Z}@smWricadSE zLdP8V)|5Zfz!nTAP%@;1_gm|(DMn#cztpN$4V%%4Gq&;&gmP*vt5%Mx1%Xf{W7)A6 zvfG-9?THs8Tx55wX!egGgJT>ZHe*?B;eZ3~P{84RXjSs_Z}m(g`j>ck#gGoedlGz- z0}`jiuc<0LOloct8DJ`Ng*}fN zZzNyMzj)VN$aYT;_A^WRY77?o~Z+d`a zFD>EezIVgC6@!F>fj{B!UFB(d69-r#;AqFo97H0E#3QN0x0u2K8@VDYt}Q)~3fZrS z*08puu()4bR~;AyqtwN(EU7<&Mc>)VxtugNpp1a z+L8_Dh916=GOpFqM^0gOaX=Xwa$pB#U@^&r1T`YS)8KZ&mZ*jeiD;b?_@NFCN6Z=Z zAVy(Bz~KsC(`D{649^iJcT7eTVIuKtFd2(5n6+!`(l9&5)L89IbmT@l9yh$v{?Bwg zhPyi>d@#rr7a-O)4v{w)LOB+NLeYtG5K}60s+oiEWBfxvVH_SoVKI+AL#5~v7qL-v zj5cJ}w$eu+up12t*FyG;(?kSjkIg*kxeYs52_44nBkt&U&35JYkN=pua-c42?q#kx zHAK1e*S|ZA4vUT1M|}7`I#GwZ^>g?3xCsBmm2rkPS-`=wJJq`2q4WzTHgiZ6s?Z`Z zKvCio*J;$V8;qAK@Phq7TW#d8)>MBm;}YP=0uq#J1S~imMC1DRAg2#BQUVT5oPhxs z*WEpa!sFfJlup^mXxZFfp1PG)epo+iA8GWvQZbPhrx@y)Kn~Od3%vX+e7={k7v1pp zQ>g{?2s7!Y32TJgAZt(@!>^ZeK7R&1p(--c17UF5My^!pEp1z|5W26wtQs%Y*PBa+ z+H&-wUT9LOV-oG4~TD~VHU{^k9jXW&X6lCQ~*I!K9$ za&}RU#6#|-+kY|)|MW~^XX0y3stzmCQ>R_gfBS8}q6qRhbf&-l*{Z{WQD;phyI}k< zn%=(gSS(Sz9tn~w@Et43l421S4)Z>=cEC0Mnk%-avrT2|M@Qr*a3opI>zp99;A)Gz zVb1_d;`GsvW|xHBL@sw@Ug@ouaW@28bf{E0T_?4wG_%3BGK&4;7F0$MkyK=|(vOqtrF$(DwFI+l-LfXOJw_Qkx0d7?VZ$vuj?XvQk%PwZ56s2j(@xtx`zEeL{tsbm3n z*QT#xr+_f1hxe(S`=KprpqDv%8n!ep<0M+Sv z_COhqNh}AYw5OMqKmqDBc0hE|J#otNYU|UQ&JZ8*7IS@^%6{_si}I=O0*=~iBOFc> zBe8+ojvkR}sjXRS+V6g?LkFcXQu?m{OHaK>r-ThNT^U*J$!HqMBO@&b`Hj}6)_1i7 zY!Ud?)g<4!oz!>NwD@utE8Ey6)xoYk36Yh#v(j)5dMXQJvpGB1A;cg9H%f10G5dPAP^UK2tuLzxcP9|-(Up$IT?MFJ z1}w6hjNHJKIL0U@rO9iI=a6(tWBfu~F2%i~kWeA>AkdIKG@mJ zIC%a^3gTS!-o{BK37qv?8!_|ZeJ5`;L*OEo7W&-;hb^G4t^dR`)8(-BT)X=@7S!(N zR2d7YGt~xUF*pK!)G)>7_pIKUT-N=YBA*xbD8m$!(mGyR&rmZp#RV7a{5B&AJ_y!; z4Fi_|#{_T0C)!3J(g%B*d#epc?I$MhL;g&~Cpdu}(nqF9e8j&*eK|q*vOvw}x zTy0gi^mfqcpZ&ljGQph)!y<~sNLR8nHQae+dTgfJ!`-u*e0$OuObE_WVVgbw1i~jzEn!87f7gmeVV|Le0~H{yE<~-dbv|&H z;B++t#X^{`yfV9g!kN)2!Ev)S5W-U?G$wNnq!NE=N4RmXlT|bob zVUopmJI~r6Yu441rz5%I{t%y7)DyCOJ$N6yZLwSCj8z)`=+)MnI!E&2zOdi9k74mS<^pz9P zsB_dVk2vhe&$!F|tR|;}uXW=^BzvhDo&>_T@;5)iLy?#A1^UW0k5O74wuV7 zrymI$IPTcsag!%cs&*Ooq1a{j9taKs{DCLCpLGoV0kkw?#?LGTe+9_nXGn_pE$e3Z znLRcFpnXLE_J#y#9~e16c1HxHvP40*GtL-`rV%wo*~OI;&*;rgWr%TcPU}D?&B*@XFXW~GM}Ks3v2Z6K*@WlkC_*zCn2(~b;F|r zda^#Q8q`{1H53PD5^U7ytXVw`CgKj+5QSbp;6v11M`$Xnx#YAn8JYJu+$8EbKOwwjb3qxBklA?y zGD;Y|_sh$6y%_&-}DJ^O8 z`mL;W*n5s}fA`4j9LCqyHhSTKivx~tem>Eo@;JKv>V${J0F7scx|<`;s?SE<-?_=Y zVN7pEyH78m)b%*)pB(YxFHFik8VNf9I*i7cza5zaIhG{hcc<9(o=R zPx^ZmJrCmA&w%dNnq%~YSue9cdYvo0FV0mRDZ&B>dL9|B)}BR6uwJmcODZpWsyu;v zPrJ+TX?^pg@kU1^!}Z#Wz=@vG)I0v7cklmr>hNk~FLTdRH`@fejj1>Jf9Kr#O-((+ zvEJL%pOskJ+;MYO0am{58J5yWddd6A&wNh>Q-oM*vZT`y6ia&!Y$Uwn`nGou(}`Y? z%{XY;KU%R_OAzRmAC$H2I-i)z>s>ZR<;N3CFh|B8%TYOz19j(KN`cLI!^0il>6V0L z_MwbtIZKGbQP^as08F*81SYoa#>+Zc=VCB^qSca^E$-|6CrV70*{U5u^foL2LKn-v z_C6j>+5&LYv*DAj5-{!>jXCA@+*G#ZQ6{3JlX)$m8K;$LvZIsHVsR`)y=@55WRu* zssa&9j$M0RKY$1Of32c`oWev19|tGIT0r`a6({5k0&E@kubteJ5|3l}AV1nDVO_EG zZ=XdVk%Pncdh+3AAC(FZ<$IfA=HAeAB-EA}T0*4wq#_unLLzQP1^3*QRLQwH- zEy1AOZ|VKF{qx2rBLvCfPFA_@dti}E5UdtlQyG5w`cYS53BHCm4g!3C{r@Dm2t{-D z=tj}IvQZyC;fvLyL5?c5s_|-kdYa{9!C`b>!2zu5bPr$XTwwDVW5WOGVHTAT4%6LV zUg}OVl)EyI73fMyyY11Fr?3qGaiMVqagjr@-NMEF7A_N3z?I6Lh@Hm1W&;}z2rgC= zn{~nlYm81;k7nQk5Y!hajtBt@&i)qd_Kx}2bg;7zcl+y4F;4+y+{r0JGhMt`gMeY0 z3jzFg^1+cz*730%{ZOCgUUFK83jFIopEA%R`r^@p{ehlK(XHsijm#EZ zv*WivBJ<@znkgnmv4j6cEFED7ANO|UC+ui~`S@Ofm`M&{^h|%)pW**JzKw-AjvYl> zR*Wq8&~*%hL^&8B1f+bT{Mv%(=Z~L?f4bXh#E*+#h#pzQ%jdwlba2;(nZoOK{;F$F zwlO}`Xk$!lx-mG`f0tQ0Z^QFX(_5~K!hK1>&gMVJcp${6+y?lYOwCRZ>QhsI1#*2) z+=~!}AjuU{QpNtV*+(ol0*+cFc^sh4wdr&?$k$|Mk0aS1#o@pdzS!6p1-{#FZ#;wi z1G|ecepnNB%^txPnKB?ZL7|;Y(AQBlJ!@Kg%8halDWtNqvs33}kxD3aRC;)$1mwgp z?8MndeJK)JRIMLI62MsdL&BLPf6+hQe1v1OZ16s#kNzc_XxnSW)x1bc@)G8c@t8vJ z)_+937xuCwTnUE!Hdnv*GGlXunJ|BnF!z0l04Hj^iSgm?kKTJT{RSGy?(HILOR?`1 z%KRa$b#JZxl70g>&H=-=sbL26+`eufNIO8Jjres`LuV0YT5^{A%uK;{!QmhA;L|^( z05LBt32xsAiw@aD);uZyrpE|Y8w;CB4?yiJa|1tNH0d<{8Tu*ONC#uzh`=EHM2Cx% zyC;4A9%od5Q{RDsc0EV5&x@n#x%_+IJHo+`_l9Pu4$i(26dh?Bwd`B#x$m|rq273( zIVjYsV|CiT_+D?n!myekUhTZ>8$no|W3)P}K7DkxX2ug3dcp4N>Y(KqNZEDk>gRa+ zY@@2i)~F%oz7hgbj@RlG{rtk#td@*DbHB*@zCh#`p0M5D`}jN_i2y5X*nS-_+@la_ zO`5L!h@=gI6J`xu@wOJ)r_un}N3OOSZ;;k@SNi#m&AMV))klnVSQpyK*q(ZKLhWhBR2J*dScxP} zJvGVb(gAmSIqUVShM&Sjr?s3FtonFG?Q1e%$!sJnDSXuZZ=zk^>iTpR3v*)9V7cIV z;IX{b$D;ER1SF99X`t2>HY0Q~S|Fkq6+DKb>2Yne%t^jQM`+csq||kahH?LiAKWtD z<4k4s;3R+*jvn5eFtjN{iOM&Xqu;_!$>8aLmSvqQ}RJ5-QpG?T%Y{(rr55U;1%s%Q03;6&a# z%68m9Wkjt#VRqD2BDMdQf4zco$X1ty`Js3?Zr3f}27!l;zEKvi`WO{9{K;4EyP85G zfm#8>xC}V%!VTLCe$tp~w*K*Egl#w2{2U311wZP43-3@HNs2RRyA?O2Nsik%h}Lt{ z5iU_IF%Cw#`?av6Wxa#YPP#00#GEUwE|G1Ju@x*Tx9wC=5MWJLTAWs}?easyDkE&O zp9$Nn(!5Os|IV~q{>a#qdV16m81yE@ooc0^h6(HwldjQo?s9AV3X`otb29d{{fTFD z*l|g)>j)5fIbuPzD^N28)&Xbr@X#HzIRu)rJ6?Ts@@G_k|DhfL-)9;cx3J4d2a7z# z>J@A|1$llhIQ(e4lFE>h_E9?nigPL62jy%V!vwa6#qJv}>f;sd*}P6baCACQbD=e5 z9HmEH3ene93{kOt;hjmaAkp2k+)pB1u!p2(Pk3-U1Dr`x+ao~tb;*qeYBd)f_2Q+m z9HlEOZ-B088tEi+=WuY7S0WJOLwD4mZm^hL&3I_8@OWyhY6M(99y|d2GKij zgqUDdMDEZF)k6%&>%vWxZEb(^^M5kn@J$@Ae(0AX8Emp5-VUpG8%JKIWZ)0Gpv*t& zkVRMHzrCdRCXQjM-(*~7hazyj{<8uV$e6Mm9_ItW2}JAoj*Ip;I~t~AJL}skmsvA2 zS7f8D2-O=2hr(*uVu={OLCnUlGbYQ@Tl|p0N@xG*&*$O43*cK*eJ~OqKg*ds$5z7@ zi%1Wfb#5LuT<}XIua|zW<5-H`x>1C14VndMcuCr-O`NL^(p;1PesIt37Yty9Z`~+? zzxJ%cGQ1cq7z}>G*9HkAbsT5C`rFO)gD*sBkoAv~sEyT>Q?)$W>h@EJhAIk7nk;vf zPdQvKnZOXBOE*sIK=~YCT3X7b*X%9{k}_1`5*dJt)pT&ZJWpL=pw(N>T2n@89m|vc z>yX7fmU*ttzSHLlld->#k2$%pEq2@9V=+3=Of z#m2}oQJ#cjLV@HM6{7_SP$sq?{?_KNsIBVqkdG488;L?#8JPfA{l!F&-r)f`L0e&U zc5OQHRkD3u-tw^PjXbeFIZ)|za$&%h6sl_bGeSo{6|muT!Mf;Pc@9@YFwhwiwOurx!7>M#&&(N zG}Mri2Zx{D_TygMcnX#z%>8$J00pGhY-0V;@DqNQTZn)5M&A(Eiq@YDr@_*ttg-^( z48B=-ng0dbIE^F2GH0we5)pg_(G4joK^X&|N!J*#3$z=|`cZ%Ed44(rS2#9Kw+UCu z1?$X=F(440LMQo7e5i0mPhL07@P%=K_os`I7JgN*oFYF+f>2}$%gayJ%ytOTASWPu z_~Zho%*Ud2VccvrUzLfXfQMiG+r0&Z9ZLoW-7HNiZ;Ay25BU{47I?&_2eZyZf>IFq z2Og0I18k#crWE+PQ+sTo0>>_erpD@O%8C_c?5ul+t48%N{!T(yWEPV;S)G{_WH=>6 zWwZ=66-DU9GNBNCGQ;tP0>67}lkQh!;2{(Ss5hO=6#GUd!dN1H zs08xwe&+5W_;17U%}pe_S(0_iJ?}omV$(ddkHAZdkeze@-2>ST%!tBgpZCOB+1!{>p~uz0%!q4W_a?ecl61}DlRu)`^yQ1+z$_FpmP-ElL9HmIJ?w9Q zYCwYoy;G#OKBY<5LHOqof3^IZpdI!#NiVwhh<3DO1MF{z_UJCtUtiAb%vB%aug2AX ztoMg^)UJf2=f3xoGOoIZ{S8r$zQ!`bmv;Q?DLnwbPcVDTyk<;VJ zyFFW(Vn4AMmN{8~Wd%+i)?TDcbiee-)+Up$qaNvn5*oEyMUcWj@!)SSS?dd00Pg;h ze}W1X{r(kk{BcYr3egdi43l)l2|>(3g2tUeQFYS)s3(6~oxtt;E%1_#zujcGX%-l6 z-<<)E#0BL1t0B)Y0u^A{la!4fl8|ln*!d|+jlu5Hl5)}BAF76-iWWKcN}Yv!_jA`e zQ!5SRjULt6xuIK=mwRwZ?CXyeg9cGJ!$?nFcG%=2gKWmss$5)#R~414CCXqhC^HBY~`jNW7(!z+&29lr$M%+}YRNsA$d0}-3C zv@YVFB=B>p-ul*j`c4Mh&a&eZ51Xk4Fo}n~=kp#tA(AUj+cJQah_$XbC-}e|4V+`& ziDJ(*d+iI77aY8zE2GyAw!Z?c#WiO3u;ZQ=ds^q{8A+&~FTA{q5~>eG0g7OhwMcZt zz@;pA$r3RLh%i2}E0P(23GFLTVS*Pd5xCXUgLrtmh;OFU;TY_KwP^)DMhus{^?Y@u z!;Rt@;>LQ1ZXB_gig9Zj7^I1DGx^|g^U-hO-pg)1tKI53HS0TMus$y046g^(Chp97 z2?ZSzi`=?Xf}*zsD5hqCFpJVHIRDxzGMIzyAMY@p@{$R$!x;8nNPa;i_bnN*kk(|s z3ilYZ-9ZB7NZG`&aDUP=J8TmHu>^jrvl&UsJ5oz8|H+!8XuJg2=0FH`f==~7rnebj z@0s#O(P~qLuP)T3*gO=i*(<0CT?qE^(MjVQvX_NPS#<_q9Cx7ieM)3Bmq$`(=;Z@T zmpq241>8xw3HAxN1A7JB5WFS00&S7PeVcMhMQaH^#ej)X;G@%@MNLjH1e|8Fx@mKD zookgHy`-hvH2k*T@{A_@C3|ipIOYm~st=O)kdt+-u6#3o)rY0wC}fl|#~QuF+H&cF zV~#f)r}X&ju^(pQuH~2q+`1oGhR9R0s?~7oB`TGksW=>Ejl>=I)ZTY~ieAx@G`sPO z^Y^?YOk6`k>EdTUILUB}6Ebjro#g--jiOgKu?lV06+bw!9sUXKa}lPblZPz_+Xa&nsV>aQ9BzzV z`^$}=x3#edAJul+a6-tk-Xdi7%{$T?g?jvq!HwGQ4RzoX!%ZKVWaod@==|^P4RshB zdozk_S@^M-C<+D56oIhm8ho-y7j;JI%_{R#*BJurA&rrJ=T~q-xfR>@NkRm z#Y#dl1Ha4V*zEhia>rwC<*@L{fQDOi4kg@gnQ42ZUfgq|;cGJ&U~K@u|Vxg|i54A9QnsTuPQdc{qneiAY+swH4} zHvCY=Y|lC0UBR*#BSq2;I}vzC7fM+5n!J6M07inOHAvFdW$DkzqP8kJ5!jIcOZQ(` z-I{D(l7N*ReWL73{#BPh`rSZMJv?GH!$i&Gi%M+9KYG(!CkNE^@Pv&){jcg+w*BF` z`&q}rSzE}So(CNf-=NLcF40P(1WNK_JfeVg>;?SBUTR>zh>;?g6;|mD8mSo=WG`BL z#+${C!~g+t9u;dVB0_F24PtlXlW^h7zx}0kWS-`-BF3-3n zzbDI6eL2WbZI7My6&cFo^!5=F8^p=`S!RgBd@`INR6$3L_QSqAn7|V?ew{Y&j@5IX zW@M^>jPA^G+6c9h8o*$YBvqsDaYE*?5%$V0KV8a*8e#j4ZT(3L=W6;&b;3u>o%;M( zo`&dv*O{v8_~b{&Srx~{=$eAgH>h30=Ez-BZ3vuqi>tu;XNnb$%*08!rxo*aVGjCN zTV(-`HKJeHCw5;&Mq-z>7^X#wR2>2mJxuUDV73=bCBg2WKv;~#6|x#{(C(fiAh(T9H0b&hnbv!R+J_TVTz-& zqr+dPnBV-wM{iK`^zl_nNKj%TlPazA2q`Zv$8!yILXgO#y@(ZvRG z@L1$oNQnK=F>#y%?p*MU`Nlhvwz^NN*7>?1=Uh=M;!>F~m&;rV9*YAT{c{OpBzYDh zGT8nM2hTW_CE&crf#P67*M)WOj9RH*7Qu(7LXUr)F@?>`fA{)$g0H~JrEzyH!R|V^ zT*wZ1QpDaX^`4Kb0ST2!8}!_vV2Ez0AxJ$bShgip*1gOM%xEJY&mma>n9F> z0RNp=0e=U$6E>SkUTuI1C_R4fSvvfo?h5$Z6F(|-P}0T{ANi9B{0ZQRUP2{!g37TP zHang>0mFC!^{kHbg|H`T$(G;RVzQRZ^gRx^RbnTX4jjk5|60rBixJqpQcLE`<l(j)G;VK~6(e8kZ?>LMGlW+Hn%=+TI+a> zW1D!4d!sE#*r@&MD)xT-&>Gq_`?hMhh3?Z}SSTCgYAh3zYhV%eTpn?5{p#eF>}0U7 ztB5XB4l9?P%<6w^+1Uf zW^_sD$YUB_8-UPm-fB_-L#3H@b1-}Xme(HB?E~plZw@RrK~)E}`aYmQn>BO%i8@OH zt?Rt-G)ka>83c-cnKHG+PEq9;)W4F0y?5+lg^JNd?4m)2iIw zC&A)!ee~d$Jbc4JJXVw}9tMmdip^TKNKg*xu^14Y^EyqcB}WgPN3+%2FIcIB{gNTj z$TO6I9*Y(;gbQJSjM&lQpR`6itcLL`{*|OyQZC99L$vIg_cj@#z^tCWZejYOz^xKG zo%sNS*!;_2K?-_fhOVEtpFF899|6@tJ1PN5bMRUmdDD(Rc;WsU!=1|FrWqx!`)(rV6YYAYe^l-o-dkCENhzD+Fm+MYwU;3|PP$xIfbyMyq zo?iP!?)_ZQz036ssgolLp)bjl&96Hlm$w8RjJ?SD%uXv3J5Izz1En=9zIy0rdi4;B zYB7n>0lSG%hDhr;NojJD9-F%j?Jmw|&|bqL{RBBD7}z$2O_Ww#g3n(iqT~ zs300|?U~$+(YUq%wL0X0jfBU<=(*@iuc@p$DChL3y%)p;JS@Ke>wSow}Q z96PJ=kH5$Gu^XGRu?WoBSnpZ5B2JE#ph4Zcgx~x(R*mj~;l0YLifd5zSOyQ-H}Yn` z7bOwcdXUx>1UQ~Qt#R+2TQelMgS@>@N3h%BLw-lwHUSHY!5WD39VI0N94vZ2F)TDf z!h5pONUg@YXS!@4jTC-%{jMDCed(Im7%Rh+b~KNk1Td0{)p+mNs#Ry$*i@DVXCY96 zS}m9nHrfg)gMJePXrr{uq;7BSY|k)^+VddY3!%x4%6!{8&tv|y8!IVp6F3tF8&h5Q`zAv{LVjvwi12oAD<7x5IVAc}OEPGxMd zg57%EH)H{esZ)4u|4SqjhcJ2O({ZC+`~GZRA7M2r0xAKhf{7J)-Y&cm7nO;`lb&AG zOqJe@-Qx3L#e4Ike)^nUpNLAM#-E(R@KN(~XZ(d$BJ*7&!NZJ=mwL%_QMd$5J58vk zm*~H{uI<;-A+YJLuthj3WuHj{DIsnU=Ae7=q#H5Dacr3`5FE z8ZfHS2_C+B-ItVSPLo=Qq~5it@R>*w(3z;`Ui_{3M4jaZyAVjdyMdsHyFvp|I$fx@ zt>5t`QFCAYv;&7ZwoOB;6~|^WQRfo%%9E;FP?U-%8bGMAfk~VwovDTIe_i@Sdb{bV zX&f2n9G8@q;`*QZc0rtRC8?;G5lDs?NWxhZp}S5ytD5#?mVkuLY6gW-V7mTshC0nLUuB=4Qe>@V%4XN)1l%d%COdT;)%i@8+Bw`Q;Kr{j(cB5R`2j>l0yhh9sff^Lm?;!?_{L+@WWwlr z?Z(!&XO5&YijM(O7xi*P%*eJs+zdP>L2G_N(Lv!TY1sk8AB4bF^&AG{i$>8L>Db)! z`p%}6ltD`U+*ykj9ZcWwK_u;--?xwsMtm|s5-Nr2k)<)L_<%NwZhoED*tfqut@8;0 z!=%~7GL)o1yPKb{a3+e;O`Pl7>Ia`_Y2T*Yx6V61y^Wnt{1HFVZJOEf}vS8g)7r!Y{(Jc(1iqe{}?N^;=h{~q{0|R`ZWlbN6?5l z9+nl|CHb?W_YOSCMpflR0~_5h zkjqzpHGo|X`uSzSPC+2(l^TOd{|4tB3pD=zjgKCm{8ooi-^Qfk@6kirgG~n(SMol6 z5;Uw*_!E#PMNq@=ah>KS?PoFQHRGCxzs-FMV}QbqN%=9Rneh>RqQydWU}dWU4kMG} z_BDKBJa0q$6R3R7QVZmz7f|J{s@%b=f6RMxp>a1D8yyb}$xhq;} zcS^N$2FM*1fSS>6(@3JTZyYF|NJiZ7`{)IMTKM;|7m?k2>-=GoKCCGw#%qWGwQ|u< zsyRXTO&o-D=PX(+W*T(iZ5NVn$xPN)G+ZLqk*)<=e0x&)v5zxYOJ=f;pka){KYonS z52{}hs-m;T_M;r=T~@7nQ}m`N)*JQ=Kgo2UXQQPR=aQ-3={oO#lj1QcKSp?DC~yo= zydnVK`g7 zO@;yFR~n2-bneV>;dVc}c|$6LTph%+;#!!aH??q^e|+wH6t<2zafi5EjR7HD3^eTp z{E)L+{@|Led9!v_o zfT*8c5H-YXb}rudr^Mpc&nAEs{av6}rO(<<(7g~nrvYF$f0I@}IT4q30lN8_S<86D z_$?K_eVefD@i+cNUzm~c;Mk`a<)ADwX9jMybu1Ik9Dz2lmrv$(_Fp1MeATZ0_U*58Tvvg$Y zsUBxf8TR@yqb}wR9=E0)m)ysb%LNX~&WbTOw@&;fH}{Y4H@9yRb#W}$!7uR>9qFgnAONg7gf&{Yrz5d&vCk_$lLF>0Z&CmT_xHCBf z)?68?C!{m^>=XCk$@6yi;qmngqC?-}i?yOa1Gz9?5o=)o*K!s!gR!^sO=9WHS9UcZ~i#dD@tFy0emlcqfS-3euGvz7m(#;c7oAYns@D%W^k zQqR3>1C%=->{NbQ7+a6O!0z4&hao!g4N|(ka>lzH<~fo%QCq@Ny|qaerooKab>@C? z%)0zFH_9Y&oXK3R@utS8rpT!y`M%L9n%%0Rvzt*+^|Sb>uRBK#9bzDgOoe+aY9VGw zJ2m2Fl-{>_8aKm_bn876U;^8&@~d%gjPe z?xTKhxH}W|x*whXd^{HEeV{|h5WOTDo!m)p!FHJ0DrzIt4_|rNSQ9?-0cBn%QDgE+ z`#5eJ4%BOM-hCrx!TL~R`gV9lk6F$*9PK5zTK3;{{;`;~tW=w5Vsg-95L_MC{q9`a z&az7NO=1f8eUzhfwy1U9&uHlkn=}UQwU5&!pmo6?@k5Yn4|{mParkfh)-RGHkPFjU zAa@SCdOVLKvZDzn!G(||t~&EL>F930<<-M^%WrgKXF2PPF4y%ZW!TCiJiqN^Tt0AKm9`t!p^w0EOk)45qf6dk$g=4@W6r`zaZ)HVS1Jk`x~u}eii<+WWo|O$ zjU8Z(0><`lef96o4rPF$d=jeg42D_K0fsXqyaXO8Qdag`{7cFRzraZzVR?R)Ga(&* zTKkxjgd-2CdhV|M)AlwA7|BHyK@LWYUxvTH$i9ryTKL=*&&{UwGca8>!7ljvkT|Rs zODS=G&!W)h4>@i@TU+V#XQ^6dE9DoZvv z>y5V(z+0GkeAy2>f`pq&y!FN-Y4E-&ivYS08vp$SOsDs7)MJ?BIl>I~I$dPcCPRr= z6DDbl%Ea-lHhlgw>ZM+m#)~r`*coN>I(EqE>|<}8T?824ljFBF`P-6HRyyOJQpA8E8eBQdnW0b;?)$Bs;b@ZUr;#iVU- zYKFsD15`8XtuVc|Lit7TQ`NT(FKX)L-FUen|%vX`4ipw1{tj75QRJj(?lGV{^B|(gKNYE8R8aPIf1pNGL(U_H^e5=P|woZX#!&Y9X3Kb-iC>1F@oB4Lb*gES_Uhzt9jq@ zx;k-l7<|A?f|I|xQ3>!kD?E)=KQ5f<fuLqAiGC(!ssE>cggD)dKggbG!x-J$@3V90YVOTbFSjVZXaY2aiP3smi3i< zzy$lqz^&}B3BULQ%|yS4V0K$%pXes)#KR@g6JnETxzn&(+nq_j-``B z_%re#VvAA#{?TvE=PpMPl?Vxh}}t07_?}6(a7&8nl#$ z15#FNwlhxu=!ZNzKGNh1$JnORLhQ;<*d|e-VnLhsynIB;#%QX34BK>SjO))Hy`OY2txz!{A5ylT8XvJoy>ikdIE+&@G$+NS7aqt zwZ7`N=Q|So*{S#Z?Hf^DXnD3iq}d~~Jok1U_evxP!%y_8R;&4q=Zzz8aJ7OgGi?jm zu-EmH`Mzdx`$Kr3xLN^55QlqOFA@S$HcC`9{n>3TxacnFLfqr4(c&KY4ZewPCk{jv zKzz8uRczUr9k^kmwzj>ENd=dLAx@2TPnv5irA0iJh=KaeH+U6f;iwD#T7ZA<2Wt8@ z>C8o|)PoU9<{7ahPli5mWFNfz_!x&cc`qcUP6rac3YN;cGG17vs(B}jh_uSpH1}~| zU#mFfs3u)qU76K7_)YXlenkC3nqTKkZG;Fdz91OAmPq=p=%4_UL-ekXHvE=Kj!wGG#A_$Axb*ExU+|5p0L3bn*pjVn%%-( zJ1@4Nn@b)66@0sTl1GM1A`@t&w+D27g$g(%kH91Gf)Am18ro-~P@wUo+z3KK|E7^E-QgYp=DRYrodsUuN)fNtik4V=`2zMC8Ysx_vKQTpJ3y zL=Wa2%^7Jrc+x(gTkVBV&?R~<@2E~v=Koan#h7cCU!5f6YD|ONiyG93hA7jbCgdFV z(36X(ZDw!e`z~eutQQgud^P`^PguZ}5 znX?~-Xh#hwoOL1?Kow?WW64FX*fuvWVb+SC1^Wire%L%KZ$_fw8Sn5!Yeh4T@^sdb zUKEMP4Phw$^(7UaPYy77~i_| ziRIyH0f?j9I!JQqTdgzc4QeNI+*V=;;x1QYfEnqDNa^Ob_o>kZV{>RxB;2+J4WlPS zO;hmypeS|jpWi+V*%n6DTCRs}j?J=QkTt;Os> zut`JK7Gdg&vPW^-HePab(nx~ZLzvi<6K}I4Sa+%tG*I;8N^It`pSA<}zvYH0ub`bn zl-Os>z*P+xOpjE;gu#eIdE5R`43k#Z>(5g2P1r2Vk8qf$U8Ii>+8|Zec zUD^qX6?C!KOi0yrX1AOjI@8;5q>S>7U^6lFA+?dBhi^d_L`fgXSCI(w(}mEHOe=ouN!SAH3oqx(gd7K0z)^Nzlc?1PLa;cpEJ1k-zQ^ zw5dg>fANre7H&>XKRMcx%k2uB+}W46dT}-Y-4|f>**ys^fTRH+2Ex$%`rD} z_BtI}=^iqJ@?9m(h*>>)6e3Y%i3$aseGFT!c&e@&+GNnlW*q{1XO5m{7{gvz%6^w- zK&v^81|S2_KCIHgz(Ur85aCvc?U7T4-dD@eII2^(^zugQZnwXsX28DJdrYV+WKIUC z<))0Kf;r0Te0hflEW?ei?Rb*>b}~RMHI-Ir{B4xw`me(^(Ak&BfGAt7$!(5*2_mH)UJkZ?AdUv-;``Xj)sjbU~G~WL`#s^lOaKFj2YIB;8oURUysl1F8aN z$h=Sl;Y{p1soHCJMV%c`dsdXQq-^&BrLDkEMvc0QXp$p$|BE8n+kWCnPXFf;_>%T z(KFBdG^om6YLsleM~oeBx@>rfy2En4{4d6D6C8c0SuGnUB6|6gH@?BX38w5OY`h7J zMQ2$k*RYNrRKX&;kCtu2s>`ZFm7y|Ipd_^vY3)ro=&>=kr#icV6eLMJR*FAaMU;E~ zy=GA>1uI3@HIJ1ed!?v8;l*=mMVqlrjxJgpD@S7HzeqDOcK2VWyZQoW!Ov{VVR6e; z9=fFf&HgDw9_@Bne{|Hg`p8rn+kmbuJyUs1SP?lyMIIh>Ni(;|5&}dZ*;(_kp*{Abf0S3+Y87eae4DbY^S5)RxqCfSuM$=}sLXBK zs3z_(^U?}+g}p5UR4cU?v%4em4v#-*YF2*L{-sQYO0riK(bn|cSicz%_i8DVp^{=m zHTXj+qQP}5@3!s3DtioRV4`Jj_Rs{#>;Vtj$Na(LI@;ZGJ8ni8tkc^tp!Mgfh@+d= z_2CGy#6Y%GG17fQM6(}erpF5lsRAX2=Uy@XaXQZ0+5Yrlb`{DRuVv_%OTVcNYad)z zB)wlGM^0^GkZGpMC>m+`7^|)S_~O;5xe)0X&tYmNf%Kc$!bPtqa&d9ZUuEBG2c}m$ z7ks#-lVKx0^Vo3S1i2~0j54FVbPu!480nTNcy+G$qVDLFj1nr1WVVYNEFf@kQW-Im zb6i>wb!M!cX$!)ZwTxS$7uQHUwNNBiuw@FZv1DY``&d7m_LU%kQ?q18d$RnPx_jKIetXwv*9!NE9SQ!|Kcew*>2ZBvl^&)EzkczJ2VI zGO*79j-mVP)a9 zZJV0+aCoZ$EYt&xwYTk|Z{4GTXhKn2GlSN|PDUae z0wH3e#iUE_o7(hI_h=v*)+qI8Sl+89(Z}PzNynEHFNiCL!Lzeb%CVK&ScpG%)|SQf zIJZiaM%$S<M0knuY0g7Zula(4$`L0z*PcZM|d9o9cm2*m_3|_4oYbW_8sANoZ z8zG_=pgNFfr>zZT(DQFcUSJr@5=HC^r6)h^kV5wVG-H~~93G2ew7V?QIvrytZpjoo z6RBu2+Mp?Q_-o4h7+qQWT~}DUol_iDf*RP&uU2Y1nAY}|h-)%b6b=VvNL_$C%A$g# zV?>u)?db4>W`|j4fi7B`SY)4M;A&0P_RX`mn}Y)&*N)p21XrPGCl(cB2MCgpb3VNM z6V%EQ^DZZ9xX45;PMu=&KcT(&h?~B;9fjdiqqu#Er24BEGk{7=2m-E z2WV$Q^X~VddL)@uG3{tYZ0SNVRDD@9Zfo_*X>NlAM+wQctS0Mxji6>+qkY%^8EY6; zgiGlp2zi44Rb@HechZe@g^YW(y6|X>n02<_-sy;?ET&}EtdD+#d$PtdgT!c){x@{| zH|2@6&oKTYOXN$`z~34xV4cxgyz>$vw^&_dknvya+4Z`&cX(+&IlSyXXfc)^9bViL zc125q%f6eYc3SM~kHw3VnS%1Nry1>cVo?9uLR+kT`?bqmnXO%&-0OjFY{?-VSnY$c zuzp*sL+rwh52D^eQ;QD6Oi;Mv`d!!II;m1vPNQuyL|p(KxaE!^mvG=_zl0Zmg&bp% zDgg%Oa2(d(uor;ZX&A_Um}Yph$q%l-P(%-sT7cb~LL6?F+W~Yegw=(zMp_aX?Eu3jru-K~4W%!tK%%y}>8T@2M9nb! zQ}is!^#kwR>+AfsKl3GM?z@XukL0>PTe^6Va{r1LIz&h zd>&@OZjoL!Bd_IrnWl1HwNsx%ySAycGk!D|4i}_4qJelebLOvm?r zxEM8kSR{}y>cCU4pde)V(Nzc;=BS^v0LV4g+kFS_yaF{HJdUkb;)7+#C=blMa9l7L zkuZ4t6?UQx@4IErpJ98fQ7NOEA16Ml5Rk0O!S51Lh#I9bs{2pH%U@|H>?Bq-J!{@z zVKWr>W2R*n7Umc5VALSlj>=Bczk#u$wF}JeLa|Q8v=UWS_8nx40DFajbbMV!3ynJ^ zEzK1hNcN%&XH@B6Wr#8m86BE87pBc}3z}Pg&=ue(+FeYUlwVZyOJI5YRd=Fm8d==t zJ}y5=Z^H8zIAm(>zmQTh=l3yyYPRO%N9#yXtR*<#LU3q>WsRNag2IQ2mtZtw2CP4| zgsb;NLWDD#3D-aPlxwid1>;hda^n$JMAqJkHG{3ahkTSOBDF54)j=M<8I7eWm7!x1 znOWNfHC!B)k)2i`pQ+BlrL3s6oSD-=5A{{PI48VgKl$@>eaTz+#7b{GjKt*z7F@)GOp#>^ z;W=mRWuJC+c$Qpqml&1Qm&hpLX|l@llzX?pQ+VL@13*m$9RbMPJlTaF2w{H{H!u6{ssH1T(nO(bcNr!}}6lJj>qk)+GSw+bCZ<6o*M- zOI1-5Z-4WydcscYglKg4rI`?IKUoG9HFV>cxwS}(p2U3xrg+PJwwmE>-$`AIqg75d zD+-$yEJY9YrAa}Tg=%(a?cc^>isDqWXjm2JmFjP=)$H&kefPVD8{SqY`D+It?*0?W zQ|0JGHQT?wauCsGJ@K|OSyHz=T>Yoe`#Y(s8Ckpebl2=u$49c#U7VX&B*r23bHNa4 zCVx;h13SDr6ni{Yq*SPY4dY0Ih^|^zv;Lmc*0XD|``|L!mnfS)J$(Z0$LAsQ=)-Sz< zC1vQ-18X`o-rX6AWwO?+*XnK#V(yltJqxa67aMk2aKq}fz4V}zXzScmaS9qvpoaUfIbgt_SfP4~$3nH$j8BCNS)lVSBT z=D@LAsF!J~s?}Mr9ph?Gs?63Uj-$yM9FpXGv`JFc=!dKH{xS^{ElU#y$Iv8y3qu5xpFJ13Q-;y zZaF;6u?6sON5h}1vxioBV{2pf;K^`jO$i;I86-;gn`OjCH;kE%rKk6;7=)cVBk6aEZ%OuiiM`w*H%#KM!1jfO_2Vxr)vb5Y315P>V!u$%vJHAroQ(1vKmIG zmQZjm9@c={6NRao>g^S~4|SS#Gz!*^m>-jO$=C5%>y{9`{D!43mC~cwu*b^faNIvb zMX$~IPOp8Is!*~T<>Y0BWVbo3Yf5)3Uo+8&ZUxaxR>s_*qP^9KVdEvNm2bjohRMFB zX8guM{fd$CSOXoO2kn!WmMS^{_r^GP7SsDY@~w8#6FiSQHWS z6l|T_Z!(8)02ujZ4A@GHf?HedgvL^C@fT*v1UyW4WxrZrd6@1j;6zBH@PM|{o%u%| zMP5cbGaNLF|+$#LNGXikyAoy)zWt>ysXTTL z<$pp4Ci8Fj=z>IHag5G28%3B$WXS4Vn4iPNSuVb@M@yj$+O)=6;fQ)CZ|Y!pmF&t* z2+S*7ykuf0pR>~KD7#5%yMuLF)At=YngLbejP~p(MfI=f=;#1nl#=7nX4?kZNoxAE zVexmMH*HLML<8E1LVR)pDZ}+q6YNbi3<= z;ap*JR?2K)yZ8I-mXrTkE&G9m*Su*>G@u24N}Nd|Itx?wM~ki~ceaV9ZZtA%v_ZL| ziahqepjv8Z{u||?x|8*aL+$HDo7wYgTCO@*!$@$=BQf(aS~RrUa9y-=@cdQ_CcTh^&Hyx1WWAV zDsLsWJGAC3|KN*fY|U{Orw!)F(6VgX3N`2OXHBlNHZ17GY%!K1OIwQnZLK*y*Y)vZ z50sVrZrMRUjj{&IP=4r|4B7Oj2Dd6%3QFR|r#k0(zO5|vsKDId#JivfSkF0|Z~ne*<%4D;Bb zq{Cj@IW1*X#Kv|ra-z{;)5GvH-YrJ#=_;s*4*mYT3?c?fUn~=Lhp*iYH(6)$mDR;XA8Ek&G38k#xEpjF$7EnLymaxPnJK{J2d zT~H2flhW(g6c)NoBuTP6vowy?ve=S0_s1%vK@vbCPC0kC3)7@|899|PD3zfddSNvM zw44fb)eSQR+?*{dZ+3GX?F>y^9;CeIhhIiC<;p|$ckvNyBk4T(rV^I!q{4y;23BMu zVf|-lDmGTH`ax}RqB^i4BkgF-#(Gv#y5znEHES3eJt8djM_K}?xOn_$Yo*+>p;LBs zae|fd(3;t&Ac-=MDl7Z|2bMyCSCcDb?8^Ep#tX5hKDXznTTnr8M9X? zfQ`e-<+u$M5JscbR2QijMq<}g^LpUBLxPxdxbr$XnnWeRHnGhxr!>VL);!4Th7}X$ zpf5H&OkPKMAlrpU$xPS4{+628eOJD{8+qNwTxu7+r%&=4?H3tU?d{5^!YJ3!L~Vur zZ8W2reRSPB&Kx4T_T#XUWbn#76rk*VNd0d6R?D@}OVJ6P?jqPQh_(r16-E19m@AaK zyIOij2{x49O6Lx+{aSNLBfAV!gJt8~u*%d5gt>ta6}`y?Cv-q_b9iydP){ zbn8dZr8QvXmb_%_+DXfBbnUf|gC}}JJ0y^A`^gvfu;{^^VIIuKH~++@2p=Ui^-$y^ zDr=gx=#JqCdPO?|Q69P&!X08B#EMAwahXy{Cza` z`(}O_wy}q*w3+D=BlV|!3#4>agMu4N8?i)bMXo&lWQ~30$C6`g?CmhabtARSnfA{7 zUqmdae)p3$_BKrPa7Z+V{1>T+p5cj4ph6>qEO}H!G>42$tQ5P5D0k&-rI^+}{fm+PVxU&;H+w8>6caMlu7F*>{0w$G5+)B8j zBedA|I#Abkvc7UgKe(J-ZSq1}J?64^bS$1o`2*T9@0l|03at8~taN#_NSmb03Z4GO z8Tx7rRA)%4MR6U?E`Ph7r_0fD{REV>=cWjpU^lOv(2@(mU9is?x-a{K)SoL->Z2Vi zm=$f?*A!;UXD7o!tTn>^xusas3hyxsr}gY_c;mGDaiWA;CESngm874LuT6rxd{$1_ z$v)UFe1Ld!*7DeFamqfJS-=inE}sI_k}@|bLeFVGl`WmHe(v0k;6>r|kRDK!k6B8P zuXf2|pO6ayzB><#Cf8XD{iI)O$ei-$nUrakyJg=4GEt%`Q}v?uYgVRRu;mTb&Ekz< zYH|6{S6KcIKSUKz;=K&`*ct{w*#Vq~kp4G_{S_HV%h#@5 zcRJupgig0IX>XI15RA-X<0oS!ds+=;w$Z%~e zna^OMw-XsUcraa&l~LCBMI}pRSu{{ApBOFD+}ytOKsRJdD(^*?d>5l)$$*Gl7VGrQ zB19|?3f#I9v6PMkJ>&kXuc(FGB#(F(5Bp6{>65_&R}DpVr<0q-yI=@5`l5%o^cFT< zB_s4Ai%@zod7Qj`IVCp*BY4SlW0;RE-%jvx__4%(c9ny+#9>)mT-4yRv>ZZ-McNtWHKXwcEaxnmYGe`Vd8VOL%(iWeYi^o=eZI!GzMF5WMoJ=yntJ` zfF;9J7QV=dZ)TTW%%WFk?23^umQI}W!b4+3917Z{FryXGNR(WZ;iAr6TB^OH>>#Vj zfoyikcU)X)lng1z4rJy{kRa)D986*j)r(TpTFcHmX7Ip*RT(e*INQkfC{98=uy{sgJVT(G57GTFA$ft9q4kbEE#Llt`kmiCmTWTkF#p&?p6`W;!`%Bo*-uh-=t zV?r*Owia;1%jAKPtzBunBimj;p#7+;9ccx{d>0o~8rja3Rk|F{E|yAzuMJ9sq(@YRP)Zcd zo`npj4%P1eLtn$Y^oVKs3V*a5@_DsL+4ob-@ zFRk?uV%Sw|Rpc;@>_@W)VkJq!o1w7G<~5J_{dHOC5Q8D@ zA9YPiYJV$?Ij}M^aqQ9Z2%B)`8Th~^4TYTHf~*&z5`uy048Z_Z-v5Bsv5luay0DKp zlVs8^Z*Yl?_G)6Jh^18dOi*@EGAuV#ScrDO&N409S*D394vT5J+q@x=5m4mQvA$E=LCL zsTIws2lUX=qcY<4s(b9_8X1nVS*gRIEh)7pV94@w^q}Z+yKv;z1_c^z+gzk>ZvAx8 zU(7*G@3=-fY>z&a9?tS~YXS3p=GEG4mV7kzPPAlcHg;M~3oo`D8R5V_wUnx%QFA`- zB`taVD`$_=6(FgldW(^@W#g;=}rBnkGr6+VuG4h2qQ|*`nN-0u6D@m!XGR77o zkD?5t2E({P&lx>9|kRc)EH!74CRW2_LpY*C$Yer0y&SgZR##~gy$9ykD|lnlmA_Teqf=cQixTNo0|@CG zsc&>jEP0~M$N!9~fpYPmmK%)dV4$QOGG>hCZgF#62~ApCV&cV1^<+^WlL}FG@=a?w z(ksf_4yJV4Z>p>em5mw*zk9)@!zhC#YzNAEhdJb+oKbyPrdk2U6@{c0==|NvD$!9G zS+ctNtd?rJZ^Y)5!MrgEbC`yN4;%J3@OjjmD#ZmhTcJUtFhWqjgH6#?O>0#xj zfu0PFsD!jay9UW4#($V~YgD#&7w)4P@su4km|*ws(Rs2aBVrQt+y$hYp>IM|y!1}& zW7zmHfRM|#CB>m&=6UhKW?9YR6M9AhS-4#$x>E3hmUfe+n^s>e(d@&6j%JYtg$W4^ zWD^>#4YZezb`e;YvA4sTRSwONHkcYUs66ud5m6ajlo1*6nv9Y{5+t(t$OuxQGbfOX z(M(hnR$?4cP0{61`}<$kQlJ0WyqPFj(OKYV z7H@@%&XesgbbT0YxP?b~o3{I^_In-1H9d$UYvu}y=*o|Gj2P0i$z0n^1DOlyIe8gl zHAT*7L;Ag*b9X$3@twV@BFfb@+tXATt}=Ty6Fa6)xk@DcZ)m@_>QCePB?>!KF%!+4 zygWHX%3jZw&hcW{4#UNvQ({ulB`2URDYFFmWSDUKWib=`8O^=Tdu|%rUD(;SAc-<_ zfJ76T4sY-@$DHN{eD1X zIhrG2x(*BwjX11HS#jv~qT+NXxu99R$YWO{uLGiQMW*E}FsZkfp(?;I#M7*W-VYAxJ zv()5>-I1=cP5kjey zke*$#|0`uZvdhC?)D?2W+PM!3pA^s<@2F;BJ?s2h&w9Ll6G`AGDg)nVh&18(XEtT1wpsq9Q~&qMEJm+GI=P z(nVvs?Gj7M)ax+BsONl^LzHaH$k@L{5D+mnDm+Etgc+uLMXKjy_Df zqPhX$oC+=WPrI=~GJ5?vmmt5B>?GdIt|XCB9hMauFB5qgS;%cSvL!%6WvK#U4-Ou& ze|xitV!GGEk4h*7 z;&48~Hki`p5g2PqSUaLFWq^gjsjTN?kf>BLimtjFUD%!)OTOwQ!A);ngu~Wu%jalT zXx<;EXOyd!!ShfkJOP^13@W5o6sE53=9hNhVxP4e!>>ahJ+>S}KPe%Z14_@q;?Vp^ zmt!<~YC$*T@D(f>SW^|s=aloZ!l8*e!C@e!xZJFBgk)b-I@rwQyv!nWPBM{0Rffqo zo|x_f^BNQukBZ1|Q?O8Fj%?{uMRrq~@H=;f(J&>NG13HFvVT_c{dG0z)2^NJo=*3)?Kr74-M-Gh zO^>HbaAr*lQ(O6j1Nv0a8)n)kdPRCEDQ4azCmSQUDk?86SfDFgqR*rTp!BJWQTpS% zJ#qgyN&27?hYOr7eHn+a=f->`x?tqU=2DmFr-Y_32N3b{wc{MI`NV84C{0p~YD@%V zzfBY)ez$57Rq^$AWAxCoN#OvjDs|@(VY~N_-d3maT>RHzl`N2(qdUwYBnb<`@<4N~ zSy;a*>Nj~YEz!7hBLTw?Jy)!}NTtJqGgOJrd3Mt@a7~efM~^xE>6FLu>q1yg;TmfMv$BgCVUGidtZtcSo`K=7 z{dQe9mqxtZw{pZNEVx&UC<9)SRQsch<2RfZbr} zpD^tj8!XnXRTg`9Io5&=kKCX%d!-f{ts9yoYy(S$5E*;#zwmvm6*yx85hF!!=I035 z)QZs+8j7(7mNKyyNw{fNRD(3~1cWf@QDF&P{7rYqOpaTDJv-lN_#+QiqVQCde=$`C z1{z$px)pv0(nYt5{vIX2eUs%MUM?Toql3QGO|!hnDDn4QBmXM+y1iUZT~oysSG$7# zo+7^y?8xwxQ{&{f`Dk8&PhVau9&vRI;dS`k#{9k)zmv`H>GE6UzPTW!g8WXGwVJVl zqvW3trGb}?Cg0K0FTXPa<~aN)`K`&S;M;grd36mDACL}Re^EC0==@nwUk>HV&TsXI zL*isnQI=MVg8CB0fsn85-F2Ua#M-9#>C0jABkuMS@P22T2>}Go45k-lWd$Sf+xYUj z#Ync9O9Xv!FIRx~(V1Uu11l{Q!3w_g_sZkN;j8AhD~A_(@<$%{t?G1pYa}k1V-g3y z*xSCiOZ}t=vUIa{os|#^I-S0lu&u_leG~GfTFd@gfiG@IYz1G#N#*5FpnP0WY}_@m zaks{TW2>>ldO>n%H26tqF)N*Pbbjy3@>?pg8MGAWD~;9sYhO!;G*iKiv}U)g-=RFv zLCFKY<+_e9KvMPmA^e1IqohUalrO&D8P1TeSb*BRn|P$|pDM|3U7(iryP&=??~W|p zd^z9A$gLoh!FLsWi&|**wR-uSdJrbfLV3pZi1QaUhR9V{y*wX5wQI273l?`=T|Z)3 zbK9P#&Oj<~oF;^Gd*;pM!Y=YNk(v3k*%xq4sk=g$!w- zg=#h=i@#nEWt=Iv=n`D0$*mRs_Gjvo0F~xn`kAyuVWKnWye;j*Jrh zs28GXGOr(Sm6FPgG9z0^&!`mFvQk`|N^yNE;r*VDi|<6#3M0kx7ZNn<=AGPm27+Py zhfI$PD)rV)Q@?_DQ8+XXr=jCkEA`>F&rE<|9gPZt@uCX7s)Mm?=g42U`AnwlNiad-12- z$?_*1e;PJ{Uz3=)s#^IEY??X{F=*BdohsU6!v;byw{zuB zdM*6AKx5G4+|fURL8C_UM;$E*PJyv4t`Bte*5>o@-6(DxctJqUt5E8G@&P?&00LL zKj@mf=vv*n@Im;U?edH5w0axDc4Tcp*h43EA7Jvu5W;VJ%;T@2Dob9eC;UXqeF%Py zT>KjC-trLqu5|g$?9nC`ej{CeYxCBQhKwso2!02qg$v-Ac;=@axjW1eW?A zFS*vRw)`n~o@8CzjLM_ry$=Fn8u!=V&@(YrZqwiLQ}kMLIuJB?v@r_C;NMTsWr0{8 z<^2a8)B20@L;e2w zPsx1fUn2jJ@AmP3@WfA%A`9%}fA%Ly<1L+_fALR}Cb~&K{YlcvZqf%zlFmILx%%xm zjOKfvRcjfkEv}~9;##^bZlHTR@h#$E;y1)+$bU2ORpL8DEB_yKTRcFwMODhevk+}c zp7F#9#EHbKh|@f%ieAFar2mZ`oJIFS;=RPLh+h-0K~n)AVgjpHOX5)Ca1RFPzMiOk zrtsNM_vgez#CY`H^7JL@@I~BtbdMvxPJF|IAJDyzc!2mB@d)vI;*TDzj^UL&7HiVo zoS011@wL#cq}$>ay7v>mBz{Fa;z3m{!lyP=j6C%`coy9j8`Etuk?xC$3p}`p?!OTI z7`4gMp4iEQsdQVsnC<{^9B~4%n0PgDD)C0*&BR-Xw-J{Tml0PGS9x#^-A@qLd2lP; z7I)MAHu3MocZr`94-zY2Mj%gR;)%rS#FL1PiA{(th^>g{6Wb8m65A2m6NeKoC64r9 zgziG(RN_s#BCm|fFrl^R3TO)RwvdZHYPS9o=xH5T_DvB+eq4vCK4|qjw0p|!^C{zWa1;lH$AwU zZj14!nD|T}P9`oTF7x0Dy1yd&U_~I$X~a}wE-{~2NSsW3koXKytr5Ve9{%`zb%^oA z!NmJLSOEqS@|;iXLF`AombiepocJ(tE%5`Q-99*z+X%_T0mKo+tB4;H|3N%JJV^YC zc$ny`Z{qtj(Faord1~X#w>+m1>l5RNJ&3)D$;4FRFye3zUP8CU%jmY4M)y_3$;3yA zYlu$~*L!dy-J6J;i7yei5nm^6C+;G?L;MHv5b;amVWQrGEcr7o9=R07tK-$_vv>>L zcM;2-VcuJequXL-x=$k3_F!GQ>l4rNU?aLMHm2KR6S|ucn-kj;I}*DPdl35&Q#?3| z?&(C|c_w}OV;ws`R9`r3U{2CJziQS0ZiT#M9h~tS9Jvf=}hlwja zxSH-Y#3zXBh#Ne(k#38-=zg2{4sk#65b;Z5%!4LdNlBVuD>GvWr~ zUZU?WhX1{98eBo#Oni~}9ntr#q02pDFrT=C==;w2#}ZE>o=mLo!PDt(Ol(4IOKeB% zNbKyvM7p~YlZf4kJ&7xcuM<1{VB(odyqP$g_#E+b;&;USqb6J-aXj&R;u$}h_Z^5` zi6e-kiR+0QiO&(Y5MLp_MtqaFlek;3#4>-VYI5~pRS3VBOTw%{K8tqA+~QgjQ=i2R zbZ;lVMLbOWhWJ}8o%W;PIB` ztH+uGsz$d5wY1)m@+@|yyBo0=QH_fvs{QCrCSK&h<1N*XCVdg{8sfFY zsl?xF>HaD5f0pvt@{~al(JFyqBH?bcv znK+a`m-Tv|A~|=(cFLRx;>6 znwUrQZnaFI|Fy*5dh2Bi`C5F1?!OVYd+;r~|4uwa{DNq=ayoEp!fxH9(49&gPW*4( z%6WqEf0AgoaE`Zi^E~NaCT=6{ARcdP=M-+))Fs9f&mp$);01JBY)`kv&UBZxRWy$8 zuOm()-a(v8e2Tc)gTCrUKlUNot)#o@UO@a~tH}zJNHH_&Twyt)RF0{vG1S#1pv1Q7o1B=#ZNtth+ow2l6+5&uT?ZZ++zZ{qbm@sF)0 zlOM<1YN`@%=&BOC5EF@mJvfeTi&xWa@fN!8B5ot@Ccf*z59t1!Xt%H|+AS=L|EpVB zzt`5))lGf+%pv}}x31n{JbonFEi8*=ZDloYVbbwSx3cCk+&tp{m#r=DR#x5;lRt&T ztBBsMt$XNykobFTZ6z!<@$qg|O{V`Q;x^(A;yc8$wyt~+7`{!3cI#>o-QKOM$@Jeq zv|Cp@=(Y>~=#AISBaX4`l(YtkZ1^p)zkGIwI8R_g+(_y-QudSy251RD-gZL@& zbKzAGyxtd#L7XMYJNc1$Q zcrH~#nBj*K(})40cdO*L-Wsu6 z8Nb!3lxbYzx2I8Vr2BZMO8z)CV(Qf&r$$UZ*wZ8bs#7Chackil;t#~%>J-U0o>L_D z)X3>vvj10~8o8WX0sri&kp^6vPbL1dOZ|3f|JR-x>CdJ3-}}_aWG>ZDC*DF_OI$~M znpoB;lP~z*d&=Yp{Vo1+>cr@S|CUoHr|@*guRV40Tc0v{|2&hA|Ldnn%35mwXHSj% znM>@y^wdamo&qWB)X39(U)HISr;|;2+EXLH_bHLPxpe-|o)X!@(;wT2uM%JP;2U)B zARZ$AyH1V#u+-%9QQ~iXisT(G$-htBNBoRS6iGQ3*G4=ev`BaKYL5#u)dLx+6SC!a^*qGRX*pb+Y*oBx(OeJ1MoJ>4IOpGz| z3W<};8TVbp1;lMcUwQNXbYfFtUt%h;h&Y*eH*o>+uf*qxuMl4)zCrZ)jl8>wD~PWX z-y(iT^i?qQ3B(q}3}QZUBJoP%OyZ5i*~C8)ZztYCTu8i^xRUq?@d@IS#HWb9SQEdt zaRw8KLx`h@S;Wc2sl@AvD~PLzYl*&!hHnL8MGsb{yAAOIVmo3-4|bt@2yql~JaIB{ zKJgvmA>x-rxhB(8?@9)16XS{PiIa(s5!Vx+Ci*Iq53xG&Bw}si$;7(EHpEfHD~Xed zcM&%bpCi6Ne4pq$!N_kzOe78=jv_{glZm$yeN{~O-o(koRm2U%LquOyLw_c5ay8?A zfVhIVlIT0pyg!YY;K3Gj_aNpIuOY4=ZX&)zJWMQK-SEGFIF1-07J2Ymx*sNPBYs3I zU&HWgMC|UtUUXYbr8`0#@4;*7UPXL{xQV#agS+Xr=&NbsQ-gRC@myjTVj^)eaT;;D z2WQfK6Y*x^-Nbu{ONlFpdx#ZIGV!TGJds$PcoMNOu?evSu@&)rVjE(6VkZx#(rxiF zx&uUCEfb&C#9qX{#57`nIGOk`aTD=b;&UFHT-)$pPJD~Fi|DIk-Zv+9_uyo@mk}TL zpzmZ8z8*1=*puix#k@b4xPbU~;*Z4Gx`wWj2dmJ160tV14)GLXb7B_{cBR|m0=n-f zE+cLuzU#sF=(gyqXX0t`1iD)hJ9sdaZi|=FJ%f0+2UpPj4bgX+kynvenb?e&=)nlx zHxqsJP54-1MPeo53B(q}o*o=UcaWGx%qET@&LH0D!FL)M`G<(*PB(6ge!8m@YZA{V zuJE8Q-h`ifra|9X2E)Y3#H6#0|1Hf8eopkYFz)894R$5=B>K7=|DHV!`mznS8)Gn$ zIFgu8Tu<1tB5xf6T>F_)kI&OarY&rdhj;7?;*ZJe3$s1 z2Yva5-`Ce0^i4PT#*K8}WUwOf^M{T53*uMA?}$GTj}m<=41L3u23vVBmF|JWAs)=6 zJByf4{BD)u=X=WF7-By0O5$YVbmA?;TD1M$zqg~WS_i-`{qA0$3Wv^S;yeF?9RzTaT` z#|$PC>pyPX@x+B&je7<09sSOL&-Xd;GJ6g6H-@hM5rcB*ROhLEPcoQFeE7`rC2tL> zaY}M^b3UnDteM$zCEeHFIDK7-*3>eMZi@lBf3E4}W2CoeO)xEP;QN1-DdsP|g~Xa* zTC}E^ztsiaW!*yZ&zfTX-nWo=O*{YFZXx+0&Qv&yKAK3Dbt}m)HNCu({N5$LM?Bsw zCI4S!sQ*R;J$M}6l{Jn1_`Daaef9WkDTWKQv$1NeIJ^XP?h{=avdrL@& zCcNW_W!)O`uQvUyO_N`1`um@HYsiUZ+#2FF9X{TzA=Xs*Uv+E9&VSLZAuH;cdb*DI zG;u4@-YQ~ElYj49MSiJia#^>KcukN0xOK#o^Z(kdBd45U(p{f;mIoWrZLu-k7MsxB zp4gH2YcI_Anlk^d-Xc<#Df6$rHNb$Rn-gf-+=AGiIJp|z z6Y)XfUx+J+*7W%Yx{qi2oO+_+n@^ldTtO_$^w~#~<(|aRL~HVVHQj588$IYXeg2&9 zza{!=n0U1&wk38UTGME+DRcqfdrhGy)8Cptzd-j(#H~bY5^YVOEtX{peF06AI}^JS ze=k$${p9~S@mr#gCduWAzqhIMg_P5s*o)Yk*pHY@v?kORhtc1nHLcE||7c#|I(w~>Aa(VAYbu5I`}PTWAWrr2-OZB4Ng>lk{kDRwIT zhZC)7wl&G#K>rtrUeoOQCmX)!5?c`46U#E)?m!c5Yr6gMZ*02Vh9=e*5Ze(iBqkA4 ziNlFynR36$_dAK!q}!TszfXT_!rhss*1bGvO}6LK|DQG0ZhM-^_jW{UvYkrzaH7|A zdp7;=Cq6)2L;Sg>-EC=--NAz$>Fz}ALhMCMB@QS4TvPB_q`!rDEAcj>H4$Gx_X9+0 zDt?%z;=a>OzIjc8Ht`!m{H3PjqsEx2N^C*wNesTq`a{emMu=Asrx2$SZy+usK1A&Hn&H=-_=jn{$q#EH|NqT2e(Rei zeLoU?JB_<7@j_w_vB-nh(QWb1bl*cHl(?`dicgZ#|~}-~LXf|J!&3V7uoifIR|` z%A)|oiGLggF!lS7qW~r!KL5_(x5WRXqXD{b%DV_Z6O1`+VDRvJabG9Mi;v=4lvj5XdYkOt>xf-=*?#v z;)3CkXP(gvV&e{0tl--_puGQ$9x;h=E7HZwS|R#)%0aEXV?^AT8k3q;m-oGb;b0_? zla>@H3})p;iv4ZlW59d1Z`>C+Q<0ScEbBeTP#fDW>g*pC)mc^ z8k4LJ#2)g-pHz4e64`Z7QlCEXlgQ6`^rRv92$K4x^$uR1);*LJDpdMqYp(bQK4jWe zyakm0NYS~CVTRi^CqFxo9xMzo?%R8gvEN-f&wYwU?#-w znRcgM%-JoJ8A?ma3l~Q6a)d<1u4(o4K;*G`XT-MZF>VuzRQ-}es4>Az($9Z5?N*0A z+0aj#`c)-Zqa_6jf@ytFuL9{gLBqduMy1Py+sDoblN=b}4|M4NP z@JY%JgtLMLX~VLEVJX~#;OM-ZOrb)!22+mQhAc`Nmef5wCoyKY#}0*$3D-YwLNJn{v4Xs#4?ffyrJgjbKZ5yUP8pav=hIiN!DVwL;+fL3 zUy?hrpq22ETQ4oI=^WNiBaHa&ysX{$W8@AF73Kt^ayMtU)bl?{!v;gFrgL-OmmfbS z+z?5kE55B(1}`x29b&>s9Kw(7=#R8_4-{l8FG<7Z&TsyRG;|M+4ix1SMvF`;M`kcB zH7^t{R9Xo?bZ**>D8KG3KmWq^NZPbT9Y#S^_h3FKwYHWkKfQe=_=N(}oSe$4S#jjd zOO`5q2zAwD&zfiN`xe>LBR6kM2!2vkk6w78elAS?Bs#hIdEubM1~Ny?X}U`3;Z6G3 z*Pn<()idBOEk~)Z?_&P)1>1 zRwR(0t&JJ!T7K%iU7;m~25*(u_`#i5qZuljg#(e|v;k5_1BH1~C=9pd&8KRhT!f~Z`bj>m z-Ba8cUkpIF!I5JFLf)84{$&iO?E>-JKle|4aR8uCcU5ema8a}zm-m~ZhsFAIPl6bY z^Pw4S&qlrq{h-1?q!4`jPJX@~(jl@zruo{fZ^w_6Z)VU{T82lnZ|C;Zc*r|Tza{h0 z+hCyGSK5wO#V)yVcO&Ghc_%!O-oze5(VY9|WrZ@N8ygVG3?kbzg85ph5U;~i-+mPF z>OY`|{5A0!Gj{bWC=W+d=^GQTMg1Q<6ZKZ#2}+-OPk9L2f8RZCL0C#&I3qg{31k$C zELT&9u!dLEI}L$TM6sc$khyC=8>-(TObrxDh;Z7VVyW}eIe~sk>()I`x#S)EHBnpo z<+eoQvh>5ULxsV#LHU7#=4eTR- zfi~hZ=*r3xPb4GjnoWEGoEVpO#t_&L}K$R0!lOfBLIyP_ZaR!!NEn(eGp&!Ukm832;SR@Nauo3!f|u}<_zi6xJR23o=_2Ge9;JUE_EDur_W?hN z-|Ur3J0kyv1|lJiRNA1R^hqW1Z|@@~KY{!kYTAj)*?(c32ayk@mGzcydiQ~x0@eV; zYlQEDYG^1Ids-~wb*M(|$B}+{r!5xrYp&mzi*$fqTde%X1NxDdcN|N4jZsXMTSws? z;uv#Qvi?Rr0iOon=3I_=icSw@q}eXVr2R;2#fMc%qa#Y|7c9;QXe&lv_ah$%70GZn zOG21ek{MIZ(#+U8_r^nrX#XITp^51HO4GK3QGczjM)0z}d-cP@Nf<_^S!zSS?}>Ys zD?POfB09U_SJg40aWp!^Xhxhn@k6DD){zJF;Th$Jp$B)WA4PzEe*R}Ol-`vQGp|^j zjs~ZN=!ws&Pm7;dddYY^;6I|{))|P8GbVM#XHmDqn$i7>lw9c}SG{;H=p98vdg*;k z#x1J&T?nm|3S0xVeX!!K(D*WvOAm!Z;VhTNKXh9DUPLO{)-Z|GoO(AGAQ9+GqSPi+ zU+eYW1f^$@ArbRumLClMJ;E6|c?HoB$fPk({;8$XyK2|OtPP8isnSD2SvdhJ_`01P z6ZdW$!ZmqyOdEtt$&^3xK+dWud+vgq?!k;uu5>ynMY-v=a@2WjqiW?$o#L6e&aO23 zRpsHT0Q)->uf`D0l%vB!Yj(FUKijPIt^!>&zwejP0<{v+Y|Ax@L8O-N+xV8^56>)l4>(t)YJCmD|$dBN|O$2FW~8~5N>?c_aYk3l_8bi zUlc%Xz;weM1oRE>dHh19w*@6~s$KHIesph1QcN=Q@Wj}}Z)2cxeGO;ThJ;&w{kj(s zt|z+cGQu5sv(d#GE+mBVK<)0S`S#$Gp3y%PmLXy^kQTrXr;b(nd_>6pf^a z;unA?{Ms(L;SHtB%1#$AGnk5;@g@dSNr9pa8E*u_X>Vaf=Z9bWM-)UYyCRXQo5fgi-zKuznDD}y>zZ3&PY z2xWkNVvC1fCB4P~e%;c?oQ1(a*GO&8h-2ypAzWD9TT-g`=;1`zZG3Z8juBZzy-2%DVV<^7|TD^}vgrCjB%@>W- zbj#pOiw-sV$XShcphCefkTx`sEx+-A$NU-(zGLJUY8!xG=c(n!7`}x8c*1XD-_N@! zT`-(w!&dGb+J^LW6F)>BbgMp`8LxENV;%Y1>pfggo z-EytQQ~YvdH4T2tGd?_`{ED(1x_T2{KB#iV&*uB0ZcQf|y6jAw?ycii>eu+X6=jFu zsWJ-BN&#I`V60OIch0EzH^fiofn#%Ju?BRD_V>Kq(3zD{Xwukm?=M7yObUc$8ip#K zo)?j>&Q6^WZhZV5OH8}c_@Yw1;%C}g@%9_^sx4orbgG(xoNco#~JpYD< zMWE{*9Iwl0XeUi3p1%-jg%=9jg6Wkt@k6QN&pB2T*S^ZI8#7{jzH~ynb#w)*~ zF;2Z&RP^#onqNg@B%XyK(4{0yC~xRYrPMTTnRm$0MRJSAW-Hy2yJISVuD9$J2xwfE zJpBEGNC*6iq_!dK=&pN)k}g`FU%XebyrDx-rHc&Smqt2ApU+Ou*pKuIzoKwnp3*fu zxp`+pSLmc;UgJ&)MsA_z7UWK8(e$rM7fg5PW@o-rP3gohIKjx>QzxaZ(uK5Xsf=!I zhqp(1dy8L|v}o{~=BqnF>2iuBq=c3BvFN>ylQ2T>8kW&k&an*y;mW;o^lJ^58?8^X zU#N5s^i^sgJ1`+Tt#_`iYeK(o%ZsXCdgq3ncDQC}^^dR!ky0#5MEXn_#Rf96i-I;J z5wH(X~HH=!cNm#^M=4n8m=fBzg1t{Ula9r zKu%^_Z}De0q4b?Aefb9H`xKh>Iq1Izqi?E-_5^zPqYmRiypH<&3bB)E{fUaMlIFOyzU+dlGi{9P~e*Gjp0xDy4c6N6|XVM9| z^+s3IcF`{|!Krsm?o8FT*Uzl4sxBBB`(ho$9e#NQ+0;6Q@BYHO52>u|T&E5#A9BiT zXj}cVbDag5`A6sJ*r8u`-WVIFqbFBukGcjw3|tVWtviAplwYXO=uau5J$KGH4Sje& zS*R5aJ4mLmIca7u6s$Ddq9aRH*Y^u&I%%$R;KDD_HY6XU^EbL+%tg1gQT{I7d9+WJ zQ#BrekoqDXO|IV10CfFbdAR0q!$!!%!GTcPU>O5|ZvW8NNGFX?9{6>>@ZE>ecKZj^ zOZ`^m|B!3=6*^HM*Gek zsr*8@&KP|251&+0e&UDT5_HQZ&DTCfCNNIE41My*Fr)*Mnm{D2E9gdS7_WVW_&NOM zH9xJUi>@2!rqzy79oaur;Ea_ciTn3!T#7TCdQ@lT-U~F&;%CQCQ({M|zUg1AU4zDN z{O!H#7&?s3Oq>_ZEjS3@`dlfj~jo7@lA3tQ?@rlnX;LL zS4zKUVQ`zFXMBae$a4V}o}KqyiE)fWU&=V9SHwc3Dl9##5Cw0*5w zm-rX7ujELyeQj(!`x~Vz3>sO&w`cX4C!-HZ4hF?n*6xa<{r~#2KA5WXva@VuO&hnm znUuXM#x{kFB$w7E(rAB7wMWm>n2 zPteVNp#Ie=zW_5)Q*V##`tCl|kpbct7LK5coijw+^?)MITcj-ZpT6}W8_yxdS=mL} ziy9tBE1fzRd{YBl+Y$PDOO7U@?BG`{Yi=4b%1k+-m$tW5SUyxY(Dexys1cLd{glyNPGCG5Eb=E36RNNA$<8t3 zI!SNCxBfBXMDQ;N;B>zj4!?MmsaQWTL=0~(efHN zCOY1qa-{uO^lt-$(raazcyznJy6UundD-G4ev-~=AO58|(kbJ3*+ET{SF*a#dUN31 z_ckLw1B=4W7%R4X^-E9>#ZQX}@!m6ic4N>D3Q$)G-zoQAyae?{{6zg=Wz0OFALDPS z^X@@`s7@L9ZZQD$I*EFpn`JfNSyWL6^SjouNzqg9@I#qbShOLwSr1Lsc5R=T__M}FjlIhjf) zel|T5*HqPc(4f35RjSC>@o6y=kWbKK;%^2?l;7mhF-e9m=Vd}aa={x5(dP}4B_#Z?mx%-fQAfESp?r{y1^f@GRvkeU`A&`nmj#&&FvzLO+HH z30}y9e0XhQuC5UeD$a4@J!|r^KWjY1uP~G*U7%}iE&h{plz(w_+|+1d6`kJ?l654k zWP$I956{!~EnCjcm~_m#N;)5hUm!fe%sVeh()sS-z*wh!9DV+l&S)R-Lz#hJ__Xq> zUj_$Y_GkPK)NOt#+9$^J{lyn_%{q%oz5~atsB7fshTsXldm4SB^Sr^X^?+GD5+@q@vK}BkH0VY*ud8E&!GZD4*ktD! zBN{``LiW&5P%VN|)=xF*2{N7%cI#ci+m)aAWl8M8ufZcbHfsDvN9)4K?qg0*wtnew^$VptY3p3??-!|zkQk4$ZI7uk>raL*;B0q0Tq<98n_4m4UyTqO>epHzWG1)-UU3W z>gpe!fejHvnX~p@ zd+oK?T6^uaFXw&oh0o|WGjHKDlVm(UKNPX+vFo0pz3Knp8M8_m3x*_QJTM=_NbA{z zK3{DmK0aGkvt0a@zW6P&Pq=_u&WsA^rO0WlL<@ zC*OLS)ba8V7E9qN>C8X-=BrtsE2>i~zV7?D=iQe5K!46z9vkoE-!2oqy&_R#_n!xM zOkC3qDGF+$;YbBmJ4kAcf6Jk}`x5_^;gvQY;&0!T&vxfC8b{Lc@V*Q7O1emjUu*AQ zE3>y(>S{^4Q$N2ypXnBa%$O5=n7RMp298_s4BqKV3{U4QuL;Fv+_LN2|CD}VK_sgA zi}dq1{2 zzW43Z@x3xqnioz)!*$8LikiyGyz)E~@5q2d$^eet56^fgg(H7({)HXKLEngUY<{!% zH7*=OJC1|=CXRkL?0eLOBfsM~s1wBT-e;fP+nqS# z=RKF_#4%)K$8pfk6UW|Jmwf8NF{0x*C~L%Vu=wL|x^P_7aU8Tq#8Ld(^5LS?k%4e&j_MOIqH}CwsO}2d7(s^MD9Qfo}IepynV!3E5-`9sIa5*i&n*Lj$|VEK zXhE4b-hJm5wmqID>8SGA!+ndg_$-+K=kTfgGyOS*ORn$T5FU-6X1fcvty~-rS6~JT zOZOHf`d4Fp1D=h+jr}PXEG)jDsNV%wWiKqwF6x(^o7ZpLqCsPk8$|$D(}MN)U)(mX zWPNow(Gg_C75&%2iKE)ag*tQulcp=L`=a@mxN(K5$_JK*;e(cQbw~#*%I!f1i|Z!# zZH=ocR$fy{hH(eb9PlM~oMw%GKw)ImS#RDx$(_f^<7f<|$WDTR8LNc~Yi_eS5B&YW zZB)%g=Pg==581jj;YfD>^YDG)qD9$@Y6jyP$d4fvi)vttN66oid@^EKk(KA*mksxr zJQs8*&v}K5;}chx4F`fn@kPs?*(&PQC zVPF50zfu7f870G#pA!Ud^!%T!6hN)ch9!m?&^+;X-2_l!0Xk_^(bFbIz-2Q}uHneHC7;mwI<$f%*#f zDT@l!`M5{oo`E}ry9W0f+^;M|GIeAj5-R^9BvofDLSmJ>2+7r$MaZBoTZAksya<_8 z{UT)ZziCwMLMhDWvrY+?Vs{o5WaV~45jh3w(;^gKT%ZQxo`yS&`#RjWH=%JA`{R?ptv`g!@I@M{suwq8hlz<1Pvo_ydLruQrt!Q}yH*IVQI)azyvQ zX8jc=7H}ctEcsP#Z=2#~A>(O~u>Fki8*Q{>i$! zq-8^MV}W|B2#u5}yRLyZ2Gjt=1It%MXy%NS*w}U@=)^6pe|gpEps6+-N0dHNg-H~2 zeb(L<@Rsa8s~Cdh4>Yw|RZZ)9&)RBQHx#I0#Sq1ev~EhH^>ocMI-CuSV3!bcSuvzJ zlU>ooY)ld3X!_<&1*)nTs*sV6rZhSnZ3ikg2pMaNp-le3#&&qpoFcHa`NJ47}HnS-K<@udA7ZLSXI`kM;W z>%}k@8HvmwWhx(iEv4x5Vwju%u@t>rykTR3I->;UDkCKgX>I3deD)Ft_79Nlg_t!h zTQ)tR*)*gC2JmZ%l!HxBM6_lzOneDUXhzx^+omn+MkP#&l)wi6PYqiGEW(W?FyI-f z*w8)|+e**_d@bYTU~?lR=CKlV9~r60ATbs%UMWHU^R=YcK}FMg^nxYmyfRYJ+&(W( z38MG&2O8VaAxBKcLB)F5y@5e=of)Y}w|fo*7B8j+(f3k-yY8R&7k2mX=vFLB3@Y-U zpv^8GQG10-YBH~W`olL4pH@22a)&9R4Ou&>suz3gC^tOo!HjO&9@6rH;4b)A^$zV`=S zOm`_4j|W|KUT!YF^YZ8D{)L%%(dzSJW$7Rs8+75WZ{j;QuvCV}CooMHNi4$-$8gbP8RhiSL;dT}rDmd9$a zPrq%vaE|(7n;7EjwOhB)$yC!Qj&4q8||D6a^Aig0KIxvqNl_p5H927ium zn!IYKdh{1Zzh&7it2wBb{P=7vKnHPF$e*)bTGcD-2GzGe4qE_QCk*HzD&hx? z5Opa~!%@>wxW}?GuGr=2I(8{>BO|S?q!2{3^f>-%rZFK(k)osT&iVy;jb}P`jC7;E zd|7t(TEQZhWIGHqE4FUXpPrZeWmeo1?RR+7L*ym$%hQbc$N%dg$zPSqAN}>wER$cJ zy7D_zm*p>rz1M?`h0Mw3cvo7-rcAW}oLh=}CGHB`wYUp$&nGt>j#XQ{Osn_WNhRME zUM>|y7GCzA)2&(Rvee?G18*AL`$C%^(Dl{`Tr{MmU0O0r<`eVEJkqvB@7dbVAa5F* zHmDX{x@a?t!!f4pP-kX)s4-vcDOgg9G)Mx@C9wS-ifQvt)dB^vli1Qx?jxte$g09GWa>dEw`$@mVB{wRIi!X)UHMMSUuFCmO$-9DRgr`>{zRPkhu8g_WtYdW z%sr}c^!ViC&-g4{%YF?_^TZIMd82IIax$gDjYY^Uy!^|ql#C_GWHmnI*{QK1vcr+j z^3Aj6#<|~TwfKyRNW9I>NB5C65p1q>Y$=y2=gmGegdqhR=BfZltH~j^Ub=8}khP9t zX8|uh8@1whDlcVs{qDIxA?M@a)Fz5`TV8vY4TvJblpdBn zed3jIx-I(IAj)A4mI;g7^6;BO`<;}6*)qa>J#YR;G3#yetq4okt8sQ3Ir4`+Gz?WK zDG#%vaj^Px+4Q&hA3ZN+kx6U~8&nl5TUJsT3n2m7)9aem-zSL}VS@;bZqyHM>`yZE zvs7i7{^&oO=d*>AmAW}e+OcKxeoMK=q8FUZU`Z=*A^oYdd!HlelQP{X&&DV=KUNl`U=Xnb{@!<= zhTk@95CjPUCTSErN4$DsXSJt1>(=A5-s+OIvyi3kY~qZ*qfNFpBbtW z+9h+-tlu6=&x~Ei<(Q|}dCk{QWeO#Uk<3T$>~e2zTFKZ%hnahp+D1;vjA-!E1-ETI zKP_{rj9C@<4nCPfl9)-oi#mUW`;-dA2XT+AC}3Fm)jQ5QBd1M7IN4xg1dHq{F`Ovp zOn690zi*t)oYoRgpfj=a>pJ%Qs_BzHX_@5!qGM?ES$m}#yCsj&AOIa0ju|qCwav@y-hi*N8IDQJ%(Wz=W^(;9!C{(F4Amolx zkYUuXuc-a6v<#P`N-*b4IX=D^odiS+8N$$Qb$b}vt?my)ztt0AXt?@#fM0!v`zY>X zxL*lF57dz`G(q_*pbP4Z3TT7Mt$;qLP6Pd_EAH;Nd*D75_m~O@h3biKmsOyZRWE$& zjXN86U){)w>;eS6`)<^55$avu|xC^c+rR8MLR^(_TH1P_3DZ5=;{#6P0o#3 z0zaV=eUaf<{>c7kaj@GeQA*VTWny6b;DyTXo#{+mPzmobl~P#R>rr)hQ5O5}*Rk{>n+bXqSeq+tK8#%#|!fYeOc}b9Xs4_1}eCY8He-XeB zJ8?Q@lSLBeKe(Yf6U`QSN!;s_ZyxW$+=|+{q4?r(63#fwxF_SDj(aBVLfrFl zFF-4fFs;ZAmsc14p?i}P7Z<2LxQF4MhPw#&=M@d=l*Nte{KZXb9yyvqf2s{UY0_SqQ3%dhb_a!PIPA0!&^v4MCMqmen`Xiz473cgq4V!V`r1j zd+hZ;9!qB?QHCC@9E*wBOTRj?v#e<2K3+`V)CZBk%)DUh_rYsq=$XVqBtG}g@E4vu z&7b|`W%qE2Mt9EHSyyc+9!QlM7!%0B+UG=|Z{Gkv^HQMPz*ScTDvVVR{75TWpUA7kD%RwZ0f9K4mIW>j3=NDkr~;z| zO<>HJF@Y!+QUO4;NT=m255>(|5w-esZ2J|S$Kp8-QgRIzkTkj#HIWKb6paU0((vU7!egTqeL4trYD6Ru2z16Y|C$RVF1sA-D9hEaim zfgz&-8apO17@q?N<_1`ca#z}gks?h|j>XaPoH#jA78olGE_bpBycaGxpXEwJ$6J$# zjny^rs6hnzh?7aCJus<@QH9kWPIN2+w;SDR%q~MLN9YiwmvXMBR+Z-7a41cys<4){ zJcMPUywp;tI5ex`(aT@c+T#NIRD7U+s;V964>EOU=2(dw$--5qF8&`zrKuF1+q3Bl^3p~ z*${IXgFvowwF+&A5j{)k&@d^BgY>D)PQZXb3V-hCz-kxP4ocur)non{sHnrPhZ7T1 zmVE=ouk`)Xhj64=QsRQyY~v_vlmDa6g(n~By!+~tfAuVQYZIe|E|t-PbtzyP|9C>4 zHn==NI2NB4b?^~(8ND;R06xY+S3GkFgCiPjJdB6TF+}nxo#{5GLuEV_GN*!EraV^! z`oJdF#v1EIs^*=QK6anqZK zoLPoV8{mjtkvu+-js{OP5aeW|JAi|djc$TpvtjO1utW-muY_gSVhcbx+pRV%O-mIp z_hD~i@s!mhr-9w3V_`PpYzUEM7x5{9u(0^ZLv$kcU=tr43< zd7<)hhq91|mK>)PgWPRH>I)D=jU@xX;!?vvr~*Q3*)EJF14}{)RJ95tF9a;88m;s+ zuNpfpb1bE##8Aj`Y2I3s&o;p@9{XNFa?W6%cxkbKhb98I+sNL4}^~fa7Be-N- zJ%f%W%<}ZwXfv&TXaEL{r7>EGyj5HYxNBNO<0r%5MWr~y2S`-XHp#7VfWK}WWcb&_ zL8A{OPTQYX6HP3MR3vjsJQ~Q|MY4Y4ma-vQ5eU4i+GI(pm2qxtB}BR7G}xu4*e8PJ zU3WLd?q4KZTl8GZnzrGlri=k?sIx~+W0EnU$+%Pe#FfbGa3K~l|BB(Kb9n9WB;qwu zhgnGgh-(8K-%&TO8pF_hNr`8gG|%FN!jqr5{@DEH?_zPE-6T~0R@uSCbe}e@fHV(- zW5Dt8#p(WobfD=1z!GZ`;rZ-bQBe9g595z~w*uHb0*5;s(qIhel2tuG1fYNa^uj16 zLItwgg^qJ&hmFfCL+Dd#ER`^1kII-Vt;yXoWqcJ!iJFK}#wAKsP&=6Ik8$!IyzB>T zYKL4P>Vy5W6b4-4kT-9+XEOs&B_$eWIpkc;xygSbM)%$P-9KEG?Cd?d*O@#TUjx$G z+>6y#!N6f06NA$c6Ua$= z>Pjl$`U$4ITHvfjj0u*A-YrKICP_{${T|k-9W{gbU0HL|{QiB ztH%I%WPITas7%)|_HljmiKux(>omBOI-%zP%qwXC;XeAOx^pbggX3w}h(gO+Ek-qL zA{w5&i-JHR+~83L zNp5P?g`&B%IwzZZFthDn!i&^TO-43Uh>}()u@w@%(2G4Bm8AUrG{J@ZA)lLHc47y1 zJx(yyw-Z2sQ+j~l8P`ta!H0}h)`kJg!*pgervlNlFvIrZ!^=M-u&_V(E6d1<_hOm|C=~jhv2qImYHcBH1 zu!_v#7ed-1+KB|^CFAw@i*+s4IiwyOkj!PFWZ9BH4n|i1VlZDB&RM>2aM8k{7;JGchjC`CY|vnah}}(HI-PXS7|o`jF6Lze2@_*87`%fD`cD_IT{ZKg&`NV~_1L8>cqF z82`EOV=_|^y?GeIQ!%ik;n*z1rz7yRyffc{n5ieeV^~+ zaW%Q4Ti03I5+V)lhy?w=(QmWKNqQB?jd^!%o?>ETxC*UTrJZ3Y!Kgq^40y<1@zH99 zudRFNk`fKckvw*>w%q1Wnt>>@c4Sfkmz&c8JxKFrJQRWIWBM%70>pc5RdK0-cXw$z z>{R~%6%m$;UP$DOeO6jBD0v>k3H%h6u?yjyt|BgijXiIL3o&sq@acV7st?nvajfcS z7sJ+Nbnm1E(L7CqU6*uXY<+ESa8dftwQ}^mLmxqOQV(aRkGlFf&S)b(A?w9BxEK%p z8{^;g@=YUR-h)^s1zXdtHy@{I949#1GTTJaesub4j}$pa5YEHgM7VBNMGg$*#eu=z zqsNS+Mw*2LW?1y3H*gw#GtM%?h>n4(c2+SMW;D|h*lh1o?SZg_TF`ZY4VjSUBp`~6 z$5fli=XM9>B;COQnTG}w9CO6g*3<~ybyPsMZ@M(?O+{}RIsMyQ3gnPyaqs0QTs)WlWz_QnkLNle^e-G( zSTH+W$JHmB{2L+ZjsDMu_dfHCHhLPJ*+bY3+BA9h95T+%Flr<#EP=ta1P-2eI+)@| zh)F`6%pR7{Mt7he>M{6i$AAGk$RUe%L(J&Ka%)?8#*TKNJky|->LOgfo%HS&WK?|dyyBhjW@8wxGMB^o(>!q&^RM!I0O zMl#_h|69<=*ZuP!-*?|nyfxBf_RvTJ5sh?+ZCl-sPR>QO*j@p2wjAYv{~UC?7(wRn zO3!61H=F^ejGk_uRRJ~f%uED1tKhDS{VgdOg{TY!-l5vEgph{16*Yl!EC4h4Ky4{B zg*9!noYn~Jjz=P}s3H3aL%$%VLG_HlmP!ORP_Cn_rHrzAO>(k&#;m*8q#5cKET%<} zwH5g$e=F)+B_sk?z;w%{vKS)O_mYSDnas+1W+w_g)}!^Cn_3$ot36FqCyc4h4)hPq zk)0elU|s(}wiT-jaY`JUCTe8u0hS-I!1TZ!i{jNGzA&N9fRd6Nnih5}dCr2cU?JTj zWIkTysURFBMZ6N3eYKY|m{1=og26;di93Dj7v=^e#GB)3D%=gaIyWbGbY@F|*l+I> zWDt#$bG51Ntw*?S4p24NKz*th zUK7_U);a}Cqi1f9x6v7Mf(MTlA`a)V)-FBhD3 z{rBGWHcO5i;9B~K+5t^dmL#;L0@D=enQ{7nm)fvt2&2H+^HG;-KshOy?aI}76#3dPHR||bIwQ7gH^xn~m%(P=}Aznw{{KM2V z#~GejHC{aoV~tB3=ZeYuzo#8%7fi$p^S}p#XLWPU18ci$(s~G$fuspkUAhceywG9B zI&E+h^7=_G2CReYkezMusm`IpMEW^2e=9%y4 zWjLN`gc*+%p8V6d@B7BP3!JK^xsjI1gDYt%(zt_J8@(P0%8xx<>Aa#io9>CpT)fJa zoG6hH>`KtxD%TidiR?m~gXz!#f$XAe`e)hMZh1EC;_Xd<&TL{5PXaPr5m}7m;t|ss zg(9Tm{kAO$u7`VsO6keN7GI6qy3LQA$Yw=P6`=?jIE(-zZ6*=9(nJ&KRt zZ+TZQrqM7)ABVwW4)NEXP`&&zZ;wJJ^=Lv3aJ3v`->v&K$^?ge?RgYt4%&*C50Lo1 zY{-c%Ou!istLbhXS{h12%Ez!B3Gak4Ist}|nA1FGBOZsB2tx);i?^-^3uE*IvpMz* zmREh5ju#1&@?__VRa$O<(+PSVd21SVPJ3Wy`=^2@wh-&Vzz7|&?h|WkRM^_mV(e#O zwm!XvVhM_<7Ika9C9T0IrK}%MYa%OATMH;G@$EpAQFWTb!3Uehc*1ssC1sjB-{0+c z{3X_)>j-#F%~x3*4pW)GpZVJ^AWo`Nj-D8)u!uSOTt zK)5PU87d7|jtZa?O<-GMN?2rIDW(f^_<_EGA(o4kTsQ}QWWhurcr7a|xW+qD0UG{MS8{)Nmq_uz;f)L#!Df8Hz|i zPu6$HB)^w?YiE?SF-&c&atV{ANNiH9{qWMK>?u~*m6{T&q$^vGs6@C7XCOouw_0#y zp)@0z#WYqqY-$KotXjkd54a+EFsGeMP%2lBc#=h2LK;$I*z3xo4hNW6$m3mheRFeQ1_v1-JQ@Pih{`qeRPu=62=(MPo@a8}W5!agE5 zx)`(?%1>GyE=J?h4==QzV23A`SW(<{OLN3DoYI&2na?3kK!o2>dyj#JMTGT9Vp3>^ zN-2GkBHF@L1P78vxAgCe@p~vFR?5?Dn`!|q7hk*B;Vjk&3PyOe`EK;O7jR2o2TMt& zGjc5W@mWDFNym7-m^|5fx8`NAEsP>nrS{cJwR@z)^vh z0dSW~x2fIO?i*+Q_QwG@JPwZuJE-u&kEQURxU{qslk(_fWKi4E`+|cEG?tWT*aQu; zJtqv|;5m)KSYV`o`rF@nGUPoLaAgL^(gP?ixOh<3jE9DRFZNVX4niu3r(hrNGK5B2 z_hDrEvV8Gk@Qx6iMrQfS&X9Vv4WO(&SLb+o6@PL zGloGf>XAY^3J;M_PtpOY=Xxaw#t?2LG*Wk%8CTeV2&G+}&FWcfY@v-` z+;+EwVSXCzbBW$g!HwR$ILhLsw^Jap)b;s-erdBzmL<|Beqbp@92vwC9j-d>B)w%^ z!!RRE7}}+wK(`D`4{Xt6utj_PJx?pmF87|9PG$FKl@w67hsB2g2NbP%&&H0eh$0T? zoSF6xW{|b1w4SzRCZaeA+Jsc8o^cVP;>F_ezxAd84c33{>EvaM_yQm8l_?$}Mm-nq zIh{<^hcQCb7SkMh!84oM!-!ht8MBif#<##8W{4n@%PQmn$Uz+P>{ z?5r}t^mg#!Fvxb$29H~p%B9`mnm(8}8_QH2^_q}GJ3Od0U_w^%#2g68JYfI+L+D0bBR$b-REJh5f zd}%44Q6JN<%<)Lrgx%-<=+ppmdDv{Nn}#o96Bazzt3-Pz8fF=6#wUY?wV&mmGw#R0 z<nwlvwv%ExZFtfi zSv^|Ds{~gY@t}aRFy3E+S#tYAvx@+Pg}7oQ+e6ppGaV( z4bI$z1Igo#$^(e7EWzt^10kN7fQX~l3LMGDbX1Mca^5rV%-v2RXgW|G|oq_ttk%PLZ`Ea*AZ_$*Fj*Uqf80FgN4b{+6KP zY^Wu$&1O@lH8}HXRq3LkCR!r@GkTEt|B4>SYmL$sAsPEmsDbkk6Az&`^PTCkkJ$iD zgW5U(PT4wOWr0T}iPBX9b{1Yq#yuDCHirN4)`jUK#B>8Y4d)7o{m#E(%FlVSm52Ri z8qteq3QvA&{?Sin-{3tiY-N_GaaVu3^Wl-or4u<~61e(UTX3&VmXd10MOv+MT`bFL z1D!{^u-i43*A2Ja?Cf@IDUNvvTo8#~fH0Qcb{D_|dNdXts12!?P8mztL4(iut~U6V zWpr73FEMCW3LYN3=k5R+(9w(IO%pwD5;?xrz zWlYayb@`@EQpv3&fE;q-0H%b*+umw_j;^7c5acXh&)O2m zi?w#`@z-r>uT4u^M{MR11*J)8bq2<5@g$Wc8B)=F!UtO`)PvixGRZR*4!s5PwHsLNWV+VcEAb==i6jo8z(a1i7foqP0rCea%~ zM7xsOo~CE>Fs129#ERMz3W1zCHPu*Fh4+q>%c+a$=Fo7$k`*z#>1oJpJ3&P#QYoh| z*rXC{U6cq}8w*PL9UYhyi@^X5QyMWju!D9EEc+TCHH(Bfs!G^|2g+;s`U)dPp-QL# z{3efO(4@juD(0|UhjUqEx}||n$!`u9LHFR4L|EzV+RH{UFa9v4S)ssa0;w>X!I|Z@ zaxhos)e1}@CO@uEbYbrVB4I|cRK_%(5Un}s&_*7#n^I)G56>JT!dj&12g`9@EEDUg zwc=(X-^wHmlnrI5zMazy0L>n=){M1sTc5gEqw;q3uEI*;)|Jt5JV%vMrzq&-L5!%{ z^O3soV9VO6du!>i$m6FNCWNxl0&Ul)Z4}lL`Sy5f5$~yHq{F)i@26yIy41K7Wt>A) zw9U5^nX1-GjJzw(Qk+<=ew)wqg85dJX-%$abkkO@4320bg0rSaO*1L2<0o8`QOERQVde~JBLluN2{BLTe>h$no}S3G7Mf|bOkjT;IuQx) z4JuxgwMHSJYsSLUM_xjW(@GI8I{1{;;xe&LF(Y5HG~bJ@2Xs)}G=EKV|wSrw=f4imeZkA|?+2&V%pIg1B1{t5y4>yu4)WQoc^I0BgJ8 z8Ug?u9u_g(O1f3m!5N2$j?VNRNjb!Q%X`PqPYdshwI2wn=MMReQ#RohhglzW_n&-e z5TdBxVt{#p?Bl0?a2kHQc!jkEI0k~rh5S`C+NahH zk`v-5Ja^(tvSwDG(|AsU;MkP$O_6-L1C->s2TwjdE#4R^ujO%dmd zR1-+}LE*_8X1?6}?*T+os0_Ayf!)+ehzo*25ihaO)h!F*{kh0yrV#Whk{phIJzCMU zlf@3ntJxuIy_~=C>!LH^M!>mvJS&f|pI0%O- zWPNo!no9q~p@mtWAD^{R{~QN?XF?(QL=^cB8I(ee4_zx55@4HL6Y z=~U!Dlco55$c2lgY&>3)E|mw!jL(fl>hWrl7lT-tfYGhJPN~+6}swk-PS+3 zo>X0G)G0OUoEnu|qlVR}@il5jjk>Z%g=^IE8nwDct*KEPYt*(Hb#IN@Q=^`!Q7_i0 z*K5?fHR{tE<*!vI)~d5=)%mq*NUa)EtESbed9^B3tEy^MeXY8&RyEeD+iTU`wd$c- z^?0p%wpP7TtKO+qAJwX_YE`!t>Wmet&k8kgg&Mg+OeMN9>YO^2Tc?KAsquAcMxDB{PKE2# z@;bG;POYg^8|&1zI(2WI+Eb^Vs8cW2sn_e&yU4gQYfU%xZUYy|&s~(r>xZslEMExKum8Y5v3E6T#w|+pyCerM15S)8>MxxDzRy_{Ulc96 zYE*7szua->X-R3*eLS^a?0vti9 zy!URP6@FJbDXmTVQO<$hwYj9xQ4Y6TVR8u%af#FXW^5h9rtW%J3{6!iQ#oq~oLlOy zkBi$<&Z$=|dOFam99O!`^+9_aJpaVK>_Ii3usQ-V&Wr8w@X}7D9EGqRDR>9y2j0?` zuRfil4&6Us+j3kph2Q>gtOQ2Q zr|h0*{>!DO8g8L&TfV`kl}Aq4K|3k*U!>_Tt*NN64hAIsd++FXg=HsOr?ZP`yBx^- z+R*Q^Z2IBHVF8>?|8rwcsc_M+;Wpwn`47B@-ny^YmY<9A^r{ELmxsUTJHRDB7TjQN z6EFDEwd#t?X;V#o6HBo2tQ>3AVsTL?k)H!4WBz(J(%9{K5l*a5rRxy=y&m8B0|)&& z9%$(Aad+UeH2O8XJ^IP-yW)2^`0bLHPNO4%G=3A_KE2?}OFy0UltW$^?=BaiPk?CB z4S6{=clr;~`7I-E)#ufbJHO%5-?VgAjoOqav`0MuxJ`C@jBx93?cPbpx!Oa+EwpX(33zwE z`9QU8mq+ldm{dP9{T~aTa@l1Yu2E;Acim&hKCtC-1met#v5JIz2hSeZz5RPGxwPQz z@f-Bc{J7y%yMG#~?Tzpmc&Crwdc@T~8MwuWO!O9?`{;I?-jQxQkpJAFvt0CA@D9*> z_{$&v+JV=lpV`+gyCw~9hxK#8D;tGpij8{t|i(xX1JVbYQndqd~hIfG8m-cSF&7zm%U}wLu?zT^_ zcG7F$9in&LEB7q5^aA-@deQaB@lQMTA_do|+mtiNXv=lSx>@}K@;U82#aQfLcKr0` zo&7>ux(?AlIqRRR9sG95UGM#eE=}Wi$K($5@4NKQJ1oD5bZNP={T-oyS8k6B1E`B- zS3S~69opm*^!&iz4nN^&S6Bb@+)u9iG_74b*8c$S^sYaD+rbxo77Y20x=ncM=d#}4 zLAQn*j%T9xxnJeavgKunTV6_gww&vd7aQJ=ynx>9k*^il^bW!D3(of$dRyYZTHtC& z3*G^GTh`qFF9*FVa0CK;Zq0`tlfLz88odVIKD~#7OCPh_ZHU``Y##pH4_)o1;f7k2 zogtpE{~s^-EW4kd@3#BV4y92MxorAq14;4SUa84Rtfp-Y+ z_~06w5BY9B9FD(yzKahUZlTS{haRPu?6c(|-`!7bnfv8&E_txv26HApeEhdF4><6; zi#Ppm*h(K@0768|s#a`IG8jceR^= zTMTZK53m6h-JiYFiaX>FMLfeJ?lAum{|nBzgDag?2xTaPJnz1_b9Y-lhPvhB;YHW= zbjgQ?+nC$L3;GY-_~Uae|2qFd@9|i)$J(h*|Jt3-E~ahzQBK#AFJ7|wbfGt&wro7E z#Kk9Px>PZ3mqYva=NER_@^hhE-uqs9%GECUvET-Gn|uP^zVAIX(zbsWy6yGa^KKaG zvVS&QBhG|(%jjF)aM0_L_bt2c4y4iBF?k2SH-EC_I@@o!(A#fVx2O1Gm*3z{XLCFg z{S!V}DHC=aH$g63c3}VVTPLN_-(fq@chtm0%7yV1Oo8-6SNa|>QM*4=oc=0LJRXuOeI;5`FX2=gY zoc`Nc_h~yI<+}WwEz9q@K4k}NxLu<*+Yx-L{lnNn4!-F%kE!sr_-SJwY&n+3w^Ta2 zl+1j)>E?a44!+rbz3MfzZN8!2U27LqIr7`0Va%bI!TQ-=Tpjqx+?dL-5331@U9u{V0P5svV;RbE_rJkN6#=QNDH>E6pt8}&mX5!1??lV?$ zhN#>YT>QXA8u~8pzn3&{WR!y2Xxo-At6Yf}XilslT z(xr;=sWTkwDulSer5LoNjw<2zj$RqpH({}rs_KblDpci)(& z9;NU(62+p`h+{{SkR4?;G>OUguC(@u^ z)N$D)=-b?K-XYpJocC8*iY-I4b+dALlTD#5B(g|paD(9VB9aJuPOVx)V=S%Xkd7dp z$;CvkE}plTbCy;^Tj_#KVJ(H4f3T&Tq@@XkZeLcGn4(wwWDoi476zvfjL`IY@t~2K z`|$a1EjkMrxcO;GJQj`BBrZ>DEI@s}CM@@2yPIZPKh=MU#_XjojTF?p_t%e+kxZRT zhFPs2;j0cc#yy?0`gASupIsQPeSX-GY&=9W#8wo@@ytkXz5QZNO=%(-N#Z0!Wqo=a;T2`S50W{N#AK{*n~gQqLY!|3fj8avhx?=p ztV_m2SlJdqc#<>}sOWJ2CjTWyz`R&s30SAWZx9^T#j^~lJ_M|JQ-LZT?w{h|YUYhz zAn4ek+k1((nL;PzZfHM;mf3`lKr}xRUNMDIynDl^VfKEJL;=q13q{K>#i<&zqLo(fH>KIpmp~~pi-cM? z+^?EwJzM_E4;g4wn=J`wn(CjuUTlk3P348>y`J0`ru`LbiqB@CxzN9fn%N9A4QT&V zzt%1-tlhDn_PUpTfwhufRDV`YvbrXTr#RiKY5i0S>4uCeIh)l8)TgPViC;dcp~)fm z?ck;^j;&lfnw+VOB$AV=A~?#VT(qeH)o(KO2AWGY3J<+hvYFB{d|=fl?AcM{WD}(w<{Ma++qovW2DrhM`RMt5<6C)Ue({wGsSkr+bWczxH;jb9p4RIEp1b zc;Oa)k*JO$K^vZW<2lE%4Y9lpZx#z<8+`_3Q#D2GGk9Sox?EJIO%#Zg%g_CUM%zra zh6}1|X2)ZAJG9ZD2AEATHRQp%wJ)rrmcdsDRn5gru;B9f3T*0$@}=MIe1<47)FYm} zvg#W1RVP>P4fkjAy~L5Y`jVz5_Nx|MbIEr6hRiiIPH8G|$dW%(9o2~?TK`H1`PV~o zHdy@#2%UcL=DXJAB5WfiqSxb>EfTvc$2ZIfRg)*Unk|YqO=)mZ+>X5@&bY#cl>)TWS6tgukUM@W{h?DXP~hw;yk4|mNhrEFoM0=A7YWhc(!_8zRXSQ` zJ59b#>*=+9{P$bF&+(QKr#WHtOt2p5s;|A6uDz$9M=N6zYm)IBo(E*e5<8Kj_WG}^ z$2)`Vv;lMlrSsD4-&o+U)24-F@uIf!64R2E+fA$8^+Ww|QXq$0mh`=X{i;<1X;vC^ z$pqqz9XN-8s}N=RuKFT$q>w z(KKt=;dbh;HG0EQ5HCxFNIN@?{rivqkVA@aVkp7d2j>@c%nTr9DAHrFKgg$@2YBoQpX=<1GK_^x{Y<{r0)dBOP z3_ntSA&K*!Fe8W$8EdHGi~MT-MSg#^VFFzuURD=snY5Z5-Gpxsrd2CYGs*fw{;?Y& z#b>8d!9W%Gzhkg-y$&z-oxBA5IVvbW@Q3W~ar62U_}n_1RDd!rBJhhpb|V`@Rpt0= z+Co>VFqH3mp(~k}sK=`Sstk~PCfeR}C+=g;8fQ*v5v$>(5v+!jA66qsi&%V@7IAa8 zF!b^8@BcyDgz_-MQ9+dCBZ(|}ZNCNcY~Tn3q^OH@`K}mZ0l7sWi08fv1FUGetem`v z#$X4~#Ru`-_ats*w$40-@2S=$)PJ}C(vx?RU%n;$^S{^sa^QbC@IU52dwpUT-`l_L z#9q-1!_rJ2{F3#hEZpi;XgzpQt2e0gyK1XC;X?n$W_m}uKW%J(_y)7G`4`)=Xx$=F zdLh=Xyp&66oHu%i{@L1f^KwWw(h4;ny16$=gKy5W7JTb5R?hb~TmC-X8!_)1r+=!k z=U%lkv5$DoYb)3*aeS>~I;62Rl@2$;I3e1TusR^xMqXr?IMcO5Pdtt81&4w%1~lGl zL*x2_4O5Imf*NZyUNzpN&fnXRW#Uh8GnzlNVc)f&$UowQW%qMGwc6 ziU&x#;bPk5&=tJ?f=kvCg>eNPLl+&hna#_NB5HoB;t`8y-I6svf8TA$r}=m z5dDJZC*d5WTuin#}mxF?WeNr1+f9fp$jnYGASEq!qcBv34^!w@G?OFM)1Q zhjaZ+>$M?B8L;${+v|!@x6;vnI6}dW;KV783mxVIoY$H@ql+~ZZkpPFCdk-tlVLrE zcb1owVq2k{S~;ganhe$1krdy?<}`$t=oa+q*W>{4U9UX;E@d3Ew1xUy7YaE}b7)ov zAQd*Y%%o-AsE~o`pV9@b2D*1`M^x475+m|9$Ekn&@7(y3bmmyVT9&kXD0WfM^c4Pf zm03O13cp$~0>pk@JTLx-@ErLM;prAk=iC3RUg!UZ@Z91od)8E@ec1DY}< zo{it0PD3I;IQyp;Q(L03c~MMVmtPty3sq`6O0UP3Ud!OX)=bs5hPimLBj!aDDyMCn z`pn7GXaX&ivl-Y9!wia%@c9}MaNJq0X`HqyizEx>&FuzXYf3YjSw(gz@}#wMD+_x^OnQx0#8FR60H9;bXAJ^|nEPd`mC?nK7p9HFvhVMNDq z022DgvND`~_VpMKgs&F8_cz^=7Jp{pTs51>&A7x?MqnN(^F?5~QypXua{-6%#d%4Dj zxRr~3BOy!U*C$Bap3V(Zu)rbXSg}ccHUbk-sE1TRDkDUsDkCIl@4o|9ad-Xq=P)kJ z4VQ%&WLO446C~tlBk1KvHmFV`{f!x^$;eucK+fPuQ4g*C0o|ySF-h&{K=G|Z{r)Se#C{jHVC)3lAVJ_Er_|~|Q(!4_E)Vkdnx`OcSI;x?LgKNek%e#qID5EvVMY_9SFit`z~X zeSuc3n#q1rny<4&gqmp?1q607tQFj(ZZD?wPb&*~j0Ac4|1=|Lp-$y@uBHT))g+R! zDh$`lBbJS(R4phOUX@`{LxDAt%8Ud2jMmM2R)PYKxo6gxW8qCxn(QH2helRZl@FfZ z^+!5RJd!^i;qWe0iNUD7A{`Co81JG-O0`ydlghx}^`Au_acyW=Pws21Gs4Lb)~$u` zeAD_ytP&C?SW7g3HN*LA&1|0HIel+p^WYG*$#7$>6A&=#{poQQ8Zvep>>`5A54_DnnW*NlUX{UEpij0C}U>L5rnJPK)_$RPUj>8EwFxVp*7E+YBus z?U?}7zVNDtj?fhr+dn>D8o_duc>N?U?n%%Tp!S<|x0wv4>oNtKhLQ6vOvSvu>7MN~^I zAwge8Hd3St|J|+cX~>XR7O9@7Ul4j}q*O2Az+<>>GrC~7VW{V-t9E*75V3CZ>X{^> z499igs8C~RY#i+Dx_)z34()7BvH}yf5T=aA5*eB&Ufo2TRK5wbX80PLQxR;38XHrQ zA?n4_C(Ivq8j;~-XD&GOVZk&;o?-*-@vNkK_kwHbR#jHu4=#VeRNkwLV20YGAAXOp zvbfF?#CN&W2P6GKexpn7pQ;!4dU+`9s?aREp1N$V9)V8cfzfm0HBl_Nu%m^-QfIHw ziUpa(Ox%qF$LfAbFDn{Dd*gHc2CR0X!)T&S5ZNi2ohj3+wme+=8hgcPtPn4jsnkbG z;y0z$njc9CDs)~(X_@GgaNSGGyWGUifqS2-r*S7hRCWR&`erMDb3QNg2nU9?NflPI z_s1qK?(~|2b^Vt{AWeo&@s(3_wr^23a%uJ6oX>xBfdi@Zv9l^Lisdw4VHhX830*bB$lOS2wQHXB)pW|RZoj$ksZ^kbLNCY@v757A6wW4&!R(NXyf zXpM9odwo+D#Xv6x^PD(9SU))B9_Z~sj#bc$)= zou=K$eOIWwrANkkYE`1H@Zu1Rmy9bZk=I}J`K_|JjPt1`Iv6U&I}&s{--eB97emf$ za97*lnD0ytV@%(^*RS~%J6b(Fi?S13LWM0baTHBAX3X8L7HP-Y&J3#o7>Mg0{nxPq-`X_&*ZWy-E07@LXvcU<69#{P zs$uy9nyU7eoLBhn`4jbKS-3t{!D>er%d*pkMhg$O_GDUs0ViUnujCwhC)JfT*aDY; z3$~}T^csvWW%59%K**o}<0n=zkf8HxMt_uLx^=c@MrTW%QjE1I;*wLIbT$}7QLh}s zU83j3ASEve)8uvu@;eM$yf3E4p)JeweV?ZSy+;3 zU!1z`1S^Kow0=|j0}7auLr;GHWA-=*+f>Wqe$!!_2KQO`boGJLZW0m3GcOGyv)+-J zqepUH;?<;%91kyesy|a2O7^Y;zdp(~unwZ^NQ6)I94({7jPYtHuo~N6wly^P1G>lY z8ft9V*xcmL(QvPtY@loU#?JkZp9?CDr^k*08(_l|5a2K8ZgCwyU zi#AsiM*G!d+|x(<{eiY)z+@cO^!P8)NxZ_C8vtLAg>TIT@T4*bsZQe`p)s+kcsWDr z4m=rBQ5rc*^CzvAlMon(o)tGyC*WtAeU_aD}Q zF$;{wL-9?wM*EybAUB<214F8TwJ|ABwP>rAXsZllu%ksmTe?kJ%yyC;gLW~}0W~zT zEi<54PXr%-=R-OEnA1s4b=;t{!_?%*-y2K@^y@dMA3kLJWsf7hzGSiItwJEWBC;5x zhSe7A&>tRnoel_IQ&fD_qCvTf`r+T8ap$q>D}OUj4m>ShSln-1QNNsVV-^isSUjkx z-$MM%g?;?2ACA%}4DEiZX)mqHB_s=6A*J&dKK2TEI*7BGC3=b@F$gfE|9DgHdueA& zO7I7ldi>Tj1AO|7AOTc!wK-STtZlxyQIXKhs3SJ` z2M0eVk;U10{j<;0M6Ue)51!=$$ea*f(OX}I&DN+DF7?Xh%ma^4j|)md*d+M8`a5cr zHLQ#Njut>DLw|=DX7$l#%ZCbNh0S;PK#)J3CV=PHb=BW5LHXz0rmnm#W8_Tj>f_hx zeMnzuen_wg+&}cUUf-b_7lf)K2x%j7fgiYn{3Bf)678MUMZk2 zp5Qm1oo}9*!3iz08e*QYeQ{By)fn@Pb;QL6Q`5{d&TrtNHB|G=GscH-F$$+b<{1mb zMYO8QJR@>k)Dcy0o|zqGlpJ-Vc}DLV7yDM#Xr38!Ow!fu<{2~LVvwINPg0+5QC;p( zr`(~=xkKgNp@!X|#^0f4+@Y?#Lxt~9%kNOD?@(**P#ckEt2$?^%H67lZB^s9su^3= zm0MMKt6IKQt=_8EY*ib#s%=}cy?<^{wjNt?JXQ%D+vWxJ{k4O`X3@ z4cVr~Y*W*=sd?K}Xq&3qrs}t;8@H*(ZR+-I>h5jop>68%ZR*)=>XmKkoo(u)ZR)FS zs#}XXqeb;;Q3G4l$QCuJMP1gSids~pMb)&Z>sr(;E$Y@5wX;Rt-=ZFCQBSw1ms`|Z zE$T>%`n*MT*{)96uFl!6a<{8t+tv8(YQ}bT<#rX`u9k0CtGBB)+ttSHYTI^o?{>9k zyLw`~dU3mYeY<*hyZUsy^6yY5?oem#Q0MPZLw2Y!JJhrtYTgbN+M%j;sQMl1#vQ70 zhq`@-x_gIuXoq@yhkAC0dS!=tXNUS|hx%%V>b6szu~YTgsRr&;BX_DvJJn@7Rnblr z*{N!Fs_S;DTXw2jcdDH`)%`owV>{K;JJriO)muB&k)7)EovO<&b;>Sv&MuX^OAXtl z#_v)ycBw0O$x#uN>k=2BIr%!Ig_oRL6wt2SU4N38Fi=ued z5%WA(c*{vS$Rlq^>Odx%_e?fPgum2(@`}JQJe>D-meZKE?p5#;G@69@^3jnV&& zcdY#g%;j})mq`{w2a}CPjhnz8k-uKTQbJYLeC!MIPhZ>wE;#nD z5OapAW8xi0p@A5cXCBoxP@EOGI+lHIKbyctbjXBE@3*_@4|FqyH@YwTzJjR^WF{~5A zXArkWuI8gy0JD&raMzPVnqz_E_@+y=9Ar)NmXLisgscIHo)PJ zBheo^G03R-%cQWf)8A6L%z#rj@3ZECmhwmQTETa1}`RB6y#1eh7ND$wB z_u?jS!mev>CJ<-4@v$!AK71jtaCBfK%dZNRp~eRIV*~tP&zyIdv)q~UCkFV^udmog zZXpbpsIvHczXA4qr0zQ5R!wz29_cb4Fu-F^E?r}QLv7%L2Dq!!7r$klLuEDcl7}Eu z{1i8-?fKiY{ASjlEZ|Tp=RN~0{{6|Z|XOW=6yPvbmh5Z`^j#7*8_y?E{q1!y=Q#B<-{2AKcNHKSOS@nw;C z89sx!efx1U=k@Q!?~lIs+|gw-IW*qrMiAe9zsAj+6}MjTil8;;S_|+A z1MD^9_dgZjq{;{-SAYi$uzAAqewJBa+Dw2?8er4W6}J#L$yv{*4Dj&l^JWRqS)*`0Q;VDxC_gi<}CAh1Kj=GmSksqpEP$W{u&OwfG_0j*k5gYfMw2fw#thJ z*gIS_lfYR{YF{$Ik3X1jRDe!u|Jwj3OuOd=Qaj5@?Qacm->vU#lAO*mU$%hDANU2U zGRs+&Lk3v5?W@y?Hi>0T?16%~eZRxas=V~j!7Bu9qB4|NVt~Il!0ES5IEBD@&Z@j( zfSry%YpwvDRe99_^ZT#9nq|&&R^<-{_`tdUE+Mt^oYcN%fIYfb$@}ZBbfSIT02lo1 zxz$8Fcj~OdnfMIi_Pv3dRq1`scSWcZD;+}pM+4md=&$QZZOB=bHw|!laiUs)&NAOJ zzy$-t7qHBbvnp>J;0MPJ7E4ZNtNh6TZ+i2i+gZ<$vz~u8z|!vTNMnbb`uUClHh+8T z>r!UeQI*36IJtL8F3T))>gQh!aQL@|JVIdDSE~^rDR^@#IoO$n^yNEX8Ec0&$82|X(GX>}@^N0a<%8u+{nb;?hvV9*I zVBa~9Uc)jY&NBaQfCm=7Fq--qHs`_x@!j_!Zc4&}e{WdMGI8`u%4&XOfTd^tqk=&0 zB(^Q}#|D_c;X%FA)Jg3>3~=R_*OZakn3LL13~=&ipNZR6IkP{%I5~Sa`+>s3eZ`V zqXyWwXPGRsuW?r83j^%__h+smwKYy^zcj#^!{#&)ZM_pMy=79n|L`nD4kdBC8~hfn zpT`Wa_LJYgPPFw-wEs51t7nWnfjMV6d!$0YMqB*-pKKMzJJn&10nUH_2WOF>Ib|Un zlW58e8sO5m&T1qu;RJ>Zu<4(DUL-+RIti*Xz=MZwkS+Wb(O3o2=&Cds;0GtaBlgyC zPXiCYS0@t=e$w+XVS*Mu4SWqiRT}UIiL0@*PI+|6U2{ml1gr$*^C15;8DQS}4>uCc zln{=$!)Fk;Z@mFFR}Q$2z=`2X$tu7N23Rrm>}#_CoK&5NRDvY}+-QKG<=u88%Us|r zv)KTL57_uVzWOFzsX4K#ht|0&1W`NDz&wPo%nNHyC z1~}oS;SyTDBEl>t=N1E8xp4Vma%h%#yM{w|0H_XR>n7T2QocRyofq-dSGa^uu|c!V z0N)t?#uKa;wiuP08khL8HkbygLk|G9#zIFu?a}V?pMucIN!00hT_v>09{f zi#z4^aRcmh;SV;5Rixa)x&@KYx8DHs_v0<^_?>VH^{)&td)lFY;Hxj`6zX3aV2_bE zM@emsvpt_Mz?nDQFoHR&opO7?0AD(9bs>RRmWaQGpeGHm_noWAW*@MofKM4<@xfR2 zklL72sGl~#32*=Vo5uktl7Kf41eu@eOy2f-;I0dqlhnfT^#yVJUNXR&eBb*gzJj;4 ze5`?o4Dh*UF29E|goWR!&`YO)M*H^FzdtO%DhIH$0rty|$yroMEWac;2rRyC2ADs( z^lXv%715=99^{`B0!9)-gDybvzLhHkrhz9~z;AsxfxybRjFdI7hXKyd*+p&i#VZ}a zlMHaTFY7fjAzCipzs`A@0Ujzj)QP|;p2Tadxs-QC?K2?-Dg1QOh>ad&qO5Zv9}-QC?C&VBE0An#{Y=kKYLs>xit zPmjy?&Q3p*gF}Wy4kqyg<;7d?84UG{G-OgwaM$p&OBf9Dj}%Pq2@aXCX1i;&k%K8b z!TPU@6=%>daxkSQIOfiweB9&xeOtHY6EuBbFK=)6y8Y#q(e>S+Y~)~SPq6aWVHLSm z{P1HldhQ|9c!JrF-dn@bBE@Y`V8r%J?+G3XsU5~38kDr>wGSWIE2p=6n&F!)Q2Uac!DV###}G)x{pg8ey= z5C6sT1owr#U7+DLl7RAUcQB487?v?<9!|4;AQBuz1mk*wBXZt_`#HzXjhXS8`e!Bbi5x+rWa+1*95MhmuJIGy z74-E4Gnbv=`l=9-+^xVrh07-l? zhC~iV^8`cY^mLtQf;{`QYsgHV;HzIpTp!9v2>o|2$?OSMo^j>2hS3HE_r!nCG>ypc58#?Gi+V!8Zy8W zEIu^lPY#J28{7XGvK50F|9h)`-V25w!3+0qSPOIK$_x1vm7xp|`BGA>6Hya-T zku8L~t}BU!9+C~WIrhvZMA6#q!@K5O<%fZzdPn-}>Ko$Sq6G)5?HU*Sg~T>Y1~gjO5>eA{Xe7G(Fm3;i2qTl2quC*;r-uRI`h~0@G_gW#6>yxg4AWk zZhVfk|C6c3bthf;j<0cDH}d^$zw(cgW%Rfug`Tja5O+L3%i2s3 z|E9B(a7qFc{Cy9UGo1ZgeEbiC+FD5N&EUE zD-3Q2=KW=^+v)3cMlvtgn&Fh)TOn%Z+vT~5T?5o0D+Y#l6mKvDaU94Oho@7xn-O^CWyt4_uJ$W| zp^?W@&NW_t*SuimjTt|>BKL&A(0?7v-+~CcXFPj<-wUzvBDlk!F$S>ZjccjGKK5@K z)fIG)$yHsB7&3R6sPnmIvGV@ju*%?gJ;urjdP*EXq1S z^x{xybmh@iMpqqOZFKd~HAdGQogcbZ=-Q&|fGz}GR~*8%gy$HpiK+50qr2z6wE~Cd ztL)iaPx5Z#8|u!d!A40a<33(iosO9tFK}T0ZE#r5b$&vNrp~_jCWDU^PvH?Uro2k| zuZ7*sb0Y1!ER00&;gc8C&GfH@BNb~9*tty^K6VI65nFp^>F<<2-T%lb1)|NE|43JUa%JeYey!yhO0<$?x={$0+0D~Y&k-gD26SBLYC+>yZ@ zt1>5Gq?SnKI(0#g56(-$uPbJMslVST%HA*OZWdBQcXXS!lrLC=FD(HI)Jp{afMO@$YpkHgSX{@gqq zkh!-0-V5$392Oof-Rl&43?2~^{2xwaf5x2KZ_ZtByuzO5Y1D`0{xw?t*~=n^buCzK zGe>o9Qg%SJpZsJ;(a~_W6msd`ntMJd*>!4xapO2j?fv$U& z+_Qs)+t;9aQuo5SZ?Q*;w}`}XEaP4Sr($;ZbswFjLR#a48;@dhWvpZFH0)aL$)D@$ zl_(vC;L;=G0~?gP8$GgZ3{#E$bNSp0=(kAE~m^1&hQ+#9ZxvYy!(QuVa`n_QG2&8PTxmf(`379)s{D1IJaeC` zkR*Z$u>*p7{MmeUgWacm_a0iq&z0eti|d)vyV*}aa7yl@p(<(>rFmnTt6l7$gUGdy zV1dK3K6W+##kWJh;Gnv}kroI=Fh;#EK9dHb8-i{Gx-sY`pqqkj2D&-u7NGmN5~n$R z2^5Ad9Nju}o6v1Tw+r1qbcfI#Lw5?@Idp|m>X*>FhVB-+d*~jadxq{6x_9V4q5FpJ z7dlxA?cdNvMHdrYTy$|(=}A`UsaNTlR`F`fzMhlLShZ*zSrmC@IR zqumz$~$MHs;3hA68;r34V8Q z->i=ZL%GhBJpK_3FZFmybOZzNH7DY+ZXA;zig=osM7m{*1IdSx8Y z)zMvD{lD5|>*Lp#(HF0G)h<#lDi-AV7G8!;u=oW#B0m8~T1FqRezHD!{S>`VEWI&K zG=G}uGAd$Ml}cn>kNo<(zeE(#~FCtGKh_$IjOdo^+p@S z7Iz+j%$_mvi#Y%OoH_Gdxy?0)Y8P74c|IQTnk(*8A3cog#vSu+74i5a*0^H6Pwvl( zlq+)-9t-zx!=uc#LAzgYbwrf$Zt0rHpAqRB3ve>8K2^!j$+$nE|3qE0FeOy`%orM& zGTpqnH=J!hRcgvod$jh+!h{CXptD z`vfDV#39}5Y}c?SKhnYfww?dWBsskWQ|*?l%KeA!{=X*Y+8;x5PmRa@u_m78i{A+= zhw@%6{Bav^`xHMb_c`P$V=Q}H1pn-mEQ4*5zS8v${MK5AhpB%Z*L6}fL=5KeGhVGV zc}Tfq-tM3`st3V%6Mx(mvQ*e;R>uEsH&ksg0#^;aKKnww-JLKw18AK5u0ie~HV{a<=%Nj-rDWL+pSvC0I?2;MYM$V=7?SA z9)EB5MN`*|hkE1ts(0iSf_EYgfrzWPxP;h}Pe#QEkm)ACwJ!Iy$Q6&L4$raeJ~iOH z9&rM29d}O0jy&!nj(_*b-x~=T-D{oe>)5WzkGY>$4-W45zt$PoSBFJZqTazU5)pQl`VmTPECz%w{Th(C_M?_x8qH~e45|L63~ zb#Uof>Qfm|_RpJtPlo?-BEoLurbav+l3ytQly)631Lr@D&r#v} zwd?9GMu%6Zt{B^5FGws3Vuo_M-thbz=H-<&YSM$zbVrY7I$AW#kxF<;CyJ6nI#HD- zr4vo*nslNoLzhkrWg60nsVq}Ev6O8|C$@@WODB$s8bvyBRkWzmiKn7RlTLgUBf4}F zsF*RNlTgKqDV;6R&ioWCy9z1M>)(n+ooCXh}Fl_;Tf zQmVv>q?1Y|Nh}?2l{ATTQmbT1rISV_PbQtTDn)YXq*EzVNXJpBQc5Si@=hh43@Wv^ zbTX10t(I_YFp=^g20QyJ1rC%ej+K{`29ri{|bsWNAhPA-)t zvvhK+tXZU!M`g<@oxCc0HtFP3IkHP9zsi|IIt5g&oYE<%a_5pxA(bb$bPB7yd8AWR z<;yFbVk&<==@eH5@=K?LDp)`|B~_t<(kZ117m`kCRiv5EsjNzul1>#>rnGdbs3e{ zK{~ZmrHaz2ttwZNP90UHvUKXIs#T;@PgScbo%*VJHR&`^HL6Rep{iL!I*nAVn$l^k zYS)rZ6IG|SbegKVb)?fw)vGI==Bj=@>9kM{>PyE*HEbXqU)89gbo^A~M$&1inlzS< zziQe>2~UmxkTQ+~eE2~sWnq|;vcx0Fr?72q$O zVAU!>IvrK(R?_LD+O(EVhze{Yolw;_P&%DeySCElqJr8mQHWgsiSoIsE|(5>8nCRq|;Az4wX)S)upp^2B@xGq%%-;>nfc=s(Ux-3|2k5 zOGl}mJ)|>4_39~|p{jQ;=?qhSdP`@x>f1*;BUHb>(iy4x_mj>jHK4zAMyr7Xq%%eh z8YrEyYVaWGj8kf`bjGV8N;(tN&>_;9sD=%d&LlN_m~>{V5yPc3M~xgIoyBU@Na-w5 zqen?+sTwm{I?L49G16JC#*LLum>NG$IxEzK@zPnTCQgvfDm7`Mbi&o-Nzz%Zrc9R3 z8Z~u_bk?eAQ{`bbeVTMOs2S6xvqjCEA)T#i)=cT_QL|@BXRn$wTRQvH+&R+Oujb8_ z&H*)lo^%eX1@onING)6-ox^I;Lg^e)ix)}fs9LgEI>*$~CDOT}mMxXeL$!RFbe^cN z<n=a<@nW|)pH)Xp8~ z8$#{ciM}b+?p^3xLhad&zAe<=J?KXfYTsV;qYAZuANtXRIgpBrQww$ND*9=Jx_%A)v_jpuj($3!ZsIO>#}Vq*P4v?Xb^8|j8HBon+gqKC zLfyTCekP&r-97_@$^GiXd%UP5)W;9#R~G8iNA#-*_4yO}RfYQU8U1QPef@%db)mj}MZbnn-@l<> zQ>Y)fH`l2p)XyL2*B0v6PxR{u_4^n4b%mGm62I}a)2Rmv;U&QOpcH~^0BQmsOI}Vx zP#1XNm{24j+k=Tj zLb3yxSR^8Y!6YIv*%3@Cl8~LiWFo1Md%aY0k&JJJ;;j@SIf)h*Dy2w4_5f3flw>c^ zTcjd;gQkbv?2}J4@@W0lKnwPq$3A_>4ifM1T%>A3$V3hSvxv;(P%x{=LJkA7iLB&sFuTY`jsSCr?Bpmgr^rE$26Ks==Z_IU6h`N|JNH(xMbO7c3)6lk>o`xLyR#2g`}F z?D~R&sBCsMdaDj`#N_fI0xCE>$Dv?XUDxxyE46G`ukjuepqAD2%Ru|RC z6<`ffom>gl6g9|IU@cLT3#N9mcozR1Nw`W=p%5beq1V6f;wo&Y6 zf?Y&s@*LPzbRo}!-9%UN0@z)2BQJtIM0fHM*b~>}!OLJT(UZIa_7=U!t6(3|o4f}0 z6@AF-U_a59yaDzX{m7f(0MVbk1r8Jg$lKr`F_63i4ifkc5lr+HQVi{?IVR$|^Xn`w47-@qm#R@VCxJs-f zqk`dL6&Vd&EyBs@;2N=-i~+6{Ysi@3IGlS~Hg7Q4vg;2yD? zOabl{d&rdFKCzcf1@0I7NN@0f*iWVg4~he18t{-fNTvl3i$i2O@Q64}I^a=pgiH?} z6GzDm;Bj$`%m|(k$H`3KNpXVA44x7v$t>V$af-|eo)M?XY~WdOhRhD06KBaB;CXS5 z%n4o)=gC~)MR9@54PFu#$vog?af!?eUJ;kceBf1ah0G6L6IaOs;B|40#I*o*LtG~d zfj7ksvIuxf+$4*Fx5X{87^sJ+c(|K-?$GgAc_6vI6)> zJR~cEkHsUh68JcKzWIgbMcu&>`KZ*}z1MrjhNHzpNi%(=D@Qe6N z;@)WWReT|vfZxPdvMKmod?TBIKg4&kIrvljAX|XH#81)({4IWwzMz-H|7AH;ATXMYO11~1 z%V=Z=Foujy27@tW46-8_OU5KSfw5&QG6akxW0Rp^Tp5S#491gj$u3}g8ISA=CXn&T zZeT*0fb0$?k_pKkU}Bkw>4p$aLf|FumkC*clFHkm<=0U`CmN z90_KU8Oc##W|@f`4Q7#<$uVG7nS~q+W|LXTabR|tjT{f=klD!zU{0BXoCxNUImt<2 zZkdam4Cax!$thr7nTMPT=978JX<&YtkDLw`kon0OU_n`coCy|^1<6@pVOfZr4Hl7w z$vI$AS%jPm7L!HEd0=r_jGPaakj2RbU`bhmTnLtuCCNo#X<3R~43?3l$t7S}S%zE+ zmXl@4Wng((j$95_kmboRu%fI$t^g~^isVYLvaCd|0;|Z%WH?w=Rv}k|)nrw24Om@P zBiDj8WOZ^KSX0&@*MqfWO>zTRTh<~sf^}qVauZlr)*&~8^<-Ug3s_&)Be#MLWPNfQ z*ibegw}XvjLvjb$ST-Vef=y&&au?WCHX(O|&16$@57=BbBlm(WWOH&K=p$Q@`$1pn zLmmMAq%V08Y$^T7L!iHGNgf6Rq(6BIY$XH8(_m}aiaZ0hk*&$IV4!S6o&(#;K=M4; zPPQd4fI+eyc@b%kKk_L!K=vn}fdl0L@;Nw24kTZIgXJLdC8*?J@)bBlD)KcrR1P8E zfWzca@+~-A4kO=zBjj-MJvdU1AU}Ykz}ld-_1atRq5Tqc*2alqwr85tK0lgr6?;0hT=#s^o*6(kz`t5tF( znGg(@tH?y)Y8g%@2G_{dWD;<#Ttg-W*U7bHGH|_IMY02>)&ZZ(XJlRQg?vud17FG)WPR|Jd`UI{ zU&~iyL-37!O*R7G$~R;RgYK?Z}CW|AF2TeHYcU=+x-)-sWkz${v3ax$1z%RqwtuPr57S)Q7tHEMgQF0AfTq{Pd1xskf$#q~!tpvFqETxqsH-M$JQshRk zj8>Z51eVpxkek7BT3K=nSY9hfZUrl7<;iVeMXdt49jv5PBzJ(7wMyhpu!>fh+yz$E zs*t+;8)#{Lk!FpO< z@(5U8t4AIM8))^(V_-wA0eKv3q%|Z@fQ_|A}gQ}PVhTx&+2 z1zTv%$#bBO)`C0_`f5Jp1<+6PB`<<4H9zta=&!XTFM|P^KY0agr3H{z!PZ(U@*3Di zYfWAU1GP5f4X~{iNZth7X>G|{V35|1ybZS3g2+2y2dzDM7Yx=qkoUljS}=JZ?4)%h zAAliRC-NZ}s)dk`z|LAI`55e?bta#HU9~RcQ?Q%Xm3#(v*Se9p{K* zduct%S72|g7x^0OqxB}=fPJ+-&=mKTz|vd1NARfi|B^3@+3bkV(Kr+CnlZxL8|6CIgpfi%BH4QA@QYWD0PZ zwvF4vZkslYI8Iq40q(89>n;7V-;nFd^?tt8We;o2%P9k^NxCmnE&wwg>2uGQ9% z8NhYgS~4TJURy_I0yk*u$;{wJZ3CGF+@x(Jvx1woO(c?ms4d!NGCR0c+d}35w`p5R zJYhp^*S3+lz#ZClGB>zW+d<|5cWFDxyx?wa7nu*-qwOYvwpDYX>)DDnEz(d+WvM6|1J46-(k7$R<;^0y32w4I=rX3|qg2%OEWGV23 zcAP8?p43i|Wx!L~NwO?>T02FS1J7uu$@1V??F?A~Jg1!{(HdW!*Uph>m!d9c=Sid+ zP#3ieWEJp|c9Ew-768)QB3 zmUfe@58l>pkqy8*+HJBScvrhaHUjTycge=!eeE9E1bm>~C!2x~wFhJ~@R9bAYz{uw z9+54;C)#7u2YjkMA$`GT+EdaGe6BqsTY@jN=cGUQQhPxLfUmTdWGnEs_KIu`zR_Nj zZNRtM8!`}lr@bZHg739=WIOPK_MQv^KWZN|xeJwF?IYjnkGDQ+pU45=7wt1S5d5lr zAqRoqw6Ek~@VoYnRNxQoJ2?dWsr?{_g1@w%` zu16zhgE92z52{1HJWB7BRV0Jwlc^u54XD3gBIrSXm zDKM9wlRORP)^m|(z&v_x@+_EF&qJOA^XYlX^I(2GA9(>Rpywx%Pf``s3y_zoV*E^)Ju@Jz*2fi@-|pnFGbz~ z%jl)ayI@(p40#VMrjiR>YIYz(-&uy(0M-tgKfepMX{L%H&h9 zs$PYB23FInlFz~FdNuL|SVON)z65LPHONyq!m`g%R`1K2>XPksa&>J7+GU?aUD`5A1iHzL1)P4vd(SFowxg!~3J)0>jt!RC51 z@(0*LZ%+OMee@ROFVI)_A%BB@x-W^;IjW`ZM+(qiZ%HE6oC?tWNeyhJ2ar10T5m-f zU>m(PX@Y@z8`1*X>Vc#Uw$s~^QNSR*9T^R5uLqIHFsnM~?Mb9MR>67)GA7tj4<=)Q zo%D`mY%oOcM8*L_^$-%Nrc`G=l#B;<(L0k!udTZ3UC0DrH@z#F80@ZhBa?tV^zJ0m z5UHMe4>B3pOYcc02Yc(i$P{27y*HT>?5p=7Q-S^TzN9zUU++hz1_$W<$u!_VeE^vj z9Hb8<(}9EaL8JpJeK46G9HJ{S12|M4LL!Tg8m12=Gl9ePVPs}-gg%_i0*=&2kVprl zM(HDUnfNKHzWOM>mksZY(MOXxz_I!m5{a_ZIDIUMbc<@dK8`E^PSD4b1;L5>1hNn~ zNuNj}CBB-hPa=zeQ}oGXQE;k0g)9b6)2EV1`=_Ss)5wzG41GFT8l0)mAj^QW^qFK? zaJD{+EC+?vY1yu|5`6QB@Th)- zM7ut9Og~DF2aoH=$O+&H{Wv)hJgJ``CxNH*lO)nEtJC@^ate4xKTS>r&+2E$Y2Z2i zEIA!Kub(4lfEV=h*PZ4rhbE51m4nbl8eFH`Ymz^ct^iYE(P!EcgSVnJ^e1Z9K5gJBg4Q4`h9W* z_)vd9t^^u_)LFFt_7d#&&YM)3;j8{9(<|4AUA-o z^q1sD@U{Mm+yuVSUz3}`xB44$3;0fdOKt_<>+i^I;0OIZxgGqde;{{&pY)I9PVlq- ziQEN#(La;B!LRxkau4`T|4Qx!zw6(~ec%uMJGmeHssA7kfWP#gWdKtgT z!=Ny{4Dbjj4M83SH3K&`ptc0+240}H1R91;o&Zh5AWwppfz~OgIDxidk*C2ZhE1LU zqZ(1jvtTqMDtQi!ZbT!`gE5TgRp^<>R2_`ZUlDELbMk4Yyn8Zj--T{*uNyxikG9xK@4@_<( zBkzMLjO644Fr|@#dq@4;+FR`LUw-N;6M z1albK$xmQTBM12z%w^;xzks=oT;x|UkCB`F2Ie*Lkl(?4Mqcs~k zL8Acq8!TiLB=LwpRoEy*3b2S#n3P~qqX?;i#f+k)4i-0xkp@`8C{CJSNuva5fu)R+ zqz#rfN|8~(GDc}KDp=MiLq-G38D+`nV0oh)83U|flqX|?6^#lcl4Pq&Mny6tYy?B@jwYx+o(k% z^}niP)FzXHb&Wb?GO(UemrM@UH|miozy?NrG9}p1Xh5a{8yO8rZ?Lh^h)fMOF&dL; zz@|nMGA-E5XiBC7n;XqY2W(+9C-Dp*)<|Rz?7s4Qy?+BC~^SjMiihFwkg2<^SlB$@hA+{-RMS^0DBnS$&z4CqX$_E>}B*MOM|_QUSt`tkI|bf3-&eokmbOB zMqjc#*x%?!RsaVW{mF{pKw|(|2^?e$BrAi1jX`7;P#J^Cs^Abqk=4MV#t;%u>`}vv zp=1qkxG{`Grbsoy7*5s(M;ar@I^ZZ{Bv}_6ZHyx8fn$u(WPNa~F^0sG4b(VeEZGno zZ;T@wffJ1JWMgomF@bCXPBJEvO~J{=B(fPe#h6UuNi%AyF@MPz$$iLsdM04_C_kip;bMb){;HJ^~O4~7r4P#Pxb~k8XL$y z;3i`u*%#bwY$E%CTa3+Qe{id@g&Y8GGq#cg!R^L2auB$~*iH@xcN#lL1@1C-l0(4V z#x8OwxX0K{4g>cZd&uG7K4ULA0^D!xBS(SSnI89Ci&l+dQ z>EJozEI9)_Z=54%f)|YQ*Nyfrg4K@3f?kqlFPu`#w~I=c*nR+hJkmDJLC%Ro^h943EnsEk*mN5 z#(gpzd}ur%SA&m?hvXXYvGIsp3qCO(lk32z#uIWq_{?}pZUCPf&&ZA73*$Mt1AJ+` zAa{bVjF;pt@U`)Z+zq}lUXy#kx5gWCFZj-QOYQ^T8}G>d;0NP9c>w%qd@y9<2dMoT zAA$R*`x>8sd#L#upMkrm_ZnY-JE-*sCXKY{D0 z>l(j+YpCg(zk#c$=bB!oOnk*lNmBrqQO7lL$8O?FsNtGm;)|%?nmTX+wOi8w&ZBN? znkHuU5N6gk(fk^9W-y9rlSja)W)$)$7|o1I9s{GB(a7Uq3^O`;0*qxMm#kA{ft%OI`xwoAJoYU;;Bfc?C>pCLphZiOhuLbuh7+h`a?R zF%y%w!K7vq@(!5HOiJDblbgw~yr)pJHIwtbFL*DdnS%TVrZQ8K-$8FP75M{9ZF-YG z!8B%S@)wxaOhf(#)0t^WJi%ExW;#-U=}k73a5ON3nV!_ajAjPR!Ew}U&5V4{!h4y` zOr#BFF*B1rI~fDaY33kfgSpI{WIQmpnTt#S<}q`V ziNL&O9x?@(&&*4v1oNBu$W&kfGe7AK7BmZxslh^KK{5?k*epb*1&f%4$#h^*vk2*c z#mu5)da$@zjLZO*FpHBJ!IEYPG80(JEJ{$*f4`1}mGD$UI;bvoe_vtZG&v^MlpQs$>DMx>=1Z1lBOC zlZC;WW(^V#cu}>?nq*P1wpoiT2G%ialf}WhW*xEwSkJ6WmIUjY^~h3S1G7F^8f<7b zAj^P_%!XuHu(8>QEC)6*8&7$o6X2dU<{t;srIpxK733$`@_$$DTr zvn^R43^Lo1jllM15ZM^)V74clfWc-5vMJcn3?`d_oy?A8b1=m0M797!%@EQD>}-aT zzF-%#GwBC*HM@{4!ER<((jV+@b|VA89%gs471-14LAC~anLWuiU~jV*83^_{f91YGj=a6H-dFEVlEI8ksM~(v*nDfc; z;6if&IRRW`E+i*{i_JykByfqjn4AnQHJ6Z6z-8uAaw@poTt-d10;0|*;xf0xI?jTozyUd+r zIJn!~MXm<-n7hd};9heNxfa}K?j_fO`^|midhmd`pWFZ*G!Kv)!9(UjauayiJVb5= zkC=zaE#Oh}2)PwJW*#NCfyd2b+B`+>2G5wM$vxm% z^9;EcJZGLI_krilbL4*Tf_a`i0A4gNkO#p_=0)-lc-g!}9tN+Nm&qgGRr3mY6uf3$ zC69sE&1>Xw@P>JvJOSP`Z;+?JTjovjG8COY%1O+I&Uc1>cyjQ7bu$TCMqp@7=?D@65O4eek{cj(h-qFyE68!H?z# z@)7vS{760qKbxP(r{EX!Gx-esYJMS~gWt@r$&xiiPY$sQ!UbttjMAFq##W`~^n0qLIJB7*=!=*#=ciD+VdRSXN9@g0Zbwqz1;Z zVv{-;*NQ_LU_2`>X@c>sc%%g;u;P<8n9xcg+VUpTfN8AMWLhw- zm4;UJ(&s2Xk{QXgPE+1WEL>9m5Iy>X0bAp*}$w; z7BV}S&B{vV0JB@!$edsfD?6DB%xUEybA!38oMc`wx0Q>`2j;PIlZC*%Rvxl2n9s^f z76J2H`N*PR0V_XQ3@m6BAd7>Atb$|-u&`B#ED08|3X`S4qE->IG+4|kN|phOTgAw- zU~|l^`pErL2-R<({JXr&* zXjLF)}W8of@}l&T0UeT=x6zoZNZk7AK4D{ zw_1`xV1VUMwg+2T0b~cTwbhCY2HRMz$&O&4)rRZ@wzUGu5U`!qmJ9`htafB)u)P&T zb^$wB?a8iSu+@R=26nW9$?jk$t0UP146!B=1*$3=ubs_tL z-K?%;Kd`&ijqDHhu)322z@AnQav<2t>PZd)dt1H8!C)V&H>tqBRv&T**w5-q4h8#L z{m5b90INSa92{s3AV+|Mtbyc6aIiIq90e+CFgY3=VkvSAIMfraBCPj z0UTisCntg=qwkDBtz$w;baxOU4nnKP4r&&|U`QUVG8o2rdDYaST}F0kg4E5U`<0&*3&$XZB-gNv<2 z+R{pS71f2ky7_k>|k!)_(E=c+fgPUIY(W2gys|Ve1fi89ZVgCa-`;ts~@B@R)U! zyapb(j*-{F6V`F^26)msLEZ#UStrR`;A!g=c^f=qohI*qXRR~jUGSWBmb?d^x6YCG z!3)-T@&S0!x00}+4@Ar0l!$E z$++NG>kAnV{APV6!Hwkcq%w)=x4q_}ltLCIP+d-(*ry*j_f6 z43xGYQ-GR{Obob?2kJInpi&1Kwoax2P1_*Tf|hNP4rtpJnI4Q{+hhhXs*UuUsL6rR z?5JcWFuEO$%nZh`qmx;{n05>@D;Ud;NoEIQ+p)+TU>rL(nG=j_$02ip@$9%{ZZN(b zkIVxmu;Y_?!Gv}KG9Q@8PDmC26WfW%f?yImFj)7YuWGGJOe4OtdUXQw60fsUPyEDxr)nGDCN z0A{e$lNG^?b_TK%n90saRt7WMnaC<&7CSRp70ha9A*+Ge?5t#UFuR?NtO4e*vy(N! zoOTYf7MRP-X+Sr^P}=OOEX`Ru%8eK5bBk8A)Iu=A4*!Gd-HvJqIw zE=V>83)_XrCSVb}FxeC=Y8N4!fyL~iWOJ~%U5so2mavPHK43|^1nCQwvP+VFU}?J) z*%B;cmnQwevUV9V04!&hC0l{z?Q&#mu!3ElYy(!bE0BR;CA%Wo7OZSnBHMvg?8;;i zSkL%{XrkQ1vvopwSCBepr7qa4gy=+e&k@#-)>0`0RwD*awyo!4j_kvt?gFi z2(XRanj8rR+HJ^DU|Tzo91XU!+md6zAiEto7Hn?^k>kJ)c6)L>7;JYSCx9L8U~(eZ z$?ix_0z>Rh5D zgPaTYvU`&A!QOT+ask-K?oBQP``UfTMPNU>FS!`(Z}%gYfCKFQgj@vxaf)nit|dm6b1oMBHV_kuI+8RR~2mOYc)56-q{kq5vz_H6PXIM<#- z9s=jtbIHTte0v^w1YBUxCy#;)?FHm9aFM-`JPt0l7m+8xCH7+SB)HUGLY@Mb*-OdO z;BtEzc?Jx#my>6~6?PbT4qR!kAkTxV?3LsNFx*~6UIbU$;p8Q7jlG(@46e1;kXOKU z_FD2PxZYkzUIRDS>&ffjMtcK!1Keb9ByWP7?M>t@aErZ}ybW%(w~%+hZT433F1X#^ zM&1K=*xSkb;7)r7`2gHy?<60ByX{@%BXEzsn|uuJwfB%uzrQSvQ#+&)IW15en;$@kz%`vmy` zJY}CGKZ2+2Q{*S`jD4E?44$>mkYB)a_F3{Pc-}roegiMq=gIHjMf(Ez1H5EkB!7aJ z?Mvh@@QQtz{0&~UuaI6*QTw&8k^;PLUn3=W!@f>x;7$7mse`xdo1_8Wwr`Opc*nj? zTHsy#4rzn;?7L(X@Vt#nb|(){{C!+8IHSQNh2*uk|arz zBxxZDNs^?w4M~!a``jlZukYz{Js!WmT<7h1uFvP3 z&&~OK&aQL4uf$vqgg$W3YV{V7S$_Veo6ob2k(Q5thc>3x(AYnvBv=SOZ~%VKNZbL|Dd% z2!*u}MjB{7hVKx~CQn%-7-jNA8NqVHu{}(Q<%|eZ?B+h3i%pTi7*k@j5rv1Q3OcA&!!L_du zWt55C?4N2vkiarMnDID4J$UcN^YNG~JZ$F?^wDhAG`^ z_(Rc5Q@YOxP5cm?Wg6o*%5QcC%Q#~<;=pS($Mg5 ze9!&yrolb91UI}K-+O<&S#U3|#|=;CA8#8(O;R(XBF7KkA8!{#(b5w}C5|7tKi)ows;1^f z0>_WuAMX%E+0&CoWsV=aKi)Bj0;v{8BFB&4AMX@I1=UkV6^@^{Ki)Zr604R*RgRy$ zKi(yX8my;{Y8=nIKi)N%hg%B+-A%DIPu(9+3!XBKc*f{vsuetK8qwP5Zn6f?;2y)M zPI>OW>=8Wo-)?G+&9>lFlpWDCc-}OkjnNCwz8JWG%on+fydAux9`y-cHVu8wcn7D$ zn}M`oA#PBN8lmjnH}?yc21-q{`cE-Ta z;gSA=F$f13c7x=s()PxOrr1r8nWp5-($F(;GVr`;#BNC2n7QGj*o|qMvNnEf!iBRf z!h|0JSNR2RgUdF!>__q`O zEfZl%f5rF&!}v5s+}Rk7a{<0dh%k+K)ff{xT1~c#k+GX>5m-Y{7bCPjuNkqK-EM9w zJ2rF}cQwYDOtwoxUMEQllkp+)hA{!;ijZz3-NR&Ji1aWffm{{xCP~jQnH(Z-8B;;7 z3F$@hc9={Hk>17(kbEJ1Ncx7!tPpv}m;-V{NI#Ny!(?uV^f%^%6bN~bWI&iK43YPZ z#UM9@3?%s=OqPVmAY&;=p^y(r28YSA5c$Yh4suJ#5R#9>WJQRi8!JJIgbXDa7AC7h zWVo>gc07$UQb!ysja%qE!=CPzc$8{-&Al#sb3^TOmrh|D+g zK*|YOK(a7QPKU@M<19$Dki{h5hROL5Sz=rOF$?*QWNDaO3X$)PDszgbI@%uTyU}Cw$g1=&O^7|@WG)&LHH!NgYFOI0(3i#IJkImf70zzE}m|; zQ4y|!xIJ`xl}n(@F%sb_irYt*t6WvOzlE7zLthS3JD zmbe1Co65DND>T}{)fRV)u1LA|bhnKTaCO8L)7??7BVCEn39hcVf9dWj*O~5~(FLxa zxKcWk`TlX)l`g`Z2A3?Zj5+k-AE{h7y0YdTaE-)8(UnuKCtb9;7u=)bV(1LzdefQB zec_skv(QqloZ_lJ8zoSn|0+yFYKc_3VKaV|Qya)anR=D~1Jiu2O>lp8|lH>bn3 z5Er0}Rc;tvoOuM?Q{u|g#Va?8u7Y_qTuX5k=_)BVmM+0O4(@4jmFW_d8&6loJOQqi zxTRJj#&P0TCdUJ&;fT~p;&(>-op1J_<$GrA{~TT9p6ybkU~aZl2R1QSKmJXY*mWF5+IL>!RFIy4TFd z;9e8gmF{)rPSB;9^WeIQdxNfuJ6KmnQBlx?aj%qI=tX z1+JU8-gJGGyGGa7oDbJs+&gsrlq;Zn*IWqKLtKBl_mnH58(=PmdsE!|bOV(uq5Hsm z7p|wcL3AG~S4ub798tz(drRC$bVHOw7c5-=eQd_BvTVJ?rPB>n4u4;V8-_2>Ot!bh z4W}ESoP}cWx*wHmNw>n>3hrZZ zKhdpJt~K2%a~rsHajWTmR<14G8go0iQR04~TdQ1qx?jy5;64$zj_x<*I?}B-cY^y= z-0yT5%5|pOVD17pPFyD4M&-KFWtr39J`=ZzE?c>7beqjR;KqyFLbp}9o^;#Hz2H6< zx1DZ>a=q#PF!zO%c^uqS zaVP2Wlp9ZX$~*z?YjLOP&L}sL?yPwd+%$3L=*}xQneHF+RJiHlF3??6ZW`Ss^9;Bd z;x5x&QEnF9Rr4IUnc}X|T~}@{UA}of+$?c7=n9luNO#k`7;d(>Lb_YZEukwiFNK>U z?lxVqa?9xMn3uzSBd&z*U*%TN-8HX-n=9@fU8!=b=}eY2aP!1PSVH$vWi0pax7N}{ zTGqkM7gv@pO1brPp2-(#2Z#!+j?%j;_3N2kGK1hvAlr zt3X##xubNIEXUx!7neX+S-BH*iIzOLW#X#PRaNdZT{X*DxF5s?>8dMtp00-F0^D+O zHR)<8cZsgH`$=4Vx(AgjqHAC&hFc}> zA-WXhO6VF|?!v7W*NE<6ak^&8S?Hdy*x=TQYfks1at^u{78l%a;+~>wsho%IX^RhTy|`9%&nOq5Yi)^x z`(50#bZwN2r+dy)5iUbqTe|0!OQ36KNrc-V?ghH`%2lO%(GrBq6xV_7CFN?+b+pui z+bHg3x=zX^(Y<1+3%5yJXS!FFOQ!2$sSmeB+-r1Qm1{uvx+Mi}tGG0}H} zFSuNBpVEy}t~cFhmcDR*i5pM%xpMvJCRqBz?HBh2-9+UE(0yqc2zNl-B)YGZ8$>tR zG8pclxG8i~l^a6$wIvt5Qfn@^R-{Kb1EmCeG-D1lmxMSkJrCXxhWV-JxQ{j$_TT1u6a?|LR zS!TeU5cdP!a^+^x{b-p3cT(I6x}TJrOSjT8A1+VaD!SFmEu{O|vKa1^xHWXYD7S=e ztz{|PX>q^Oty69p-EWrVaA(A=r~6&G6?7Svm2hXpZJ^6kZZ+LT%Nn?I;m?^$YH!BCPlCqbzh~tTwp2;v(tFD(9e!vbx~zi7Q7Jt(=E0#_EGB6=%?y zl?%{WtZ{IrsQ>;>Y^Ad)7f)xmR)mWX=b&>cmq6#TCc>2w=ce;0SC!6d4Z=l=^U?X0 zt3emA)`BZ5E|xA%xg@&s*1B*};^OHlD3?rE(OMs_oVZGK3CcB~t87hyix!tiS4Fu- zbXBd5;bO#9qYEmRN>|<51kMmwgRZ7>P3dY`o57jI)uu~Qt~p&DYYRAwxVm)plxs4pN3gVj5J+53=x@OiixQgPQplhyNH@YXSJ>V*fYeDyvay{u< zT6@7IihG)_m2$o5p0W0Yt0Jy7-LuN|qibXB4_8&(b98N$8$kEGbs$_daqZ|{P;L-i zd+T7hptu+5Iw&`U?j>tFTy=3B>0VZD7+ojp2)G*JUZLx(+$g$Nt)t;;it9r6nsQ_5 zx?0D<)e`qQU7B*^>E5tTfU7O88(nwhCern=PJ&Ak_awLImaqrO$P;MdJ`_{#94~QE`_knUt z=muGr!qpe|A>Cl*meGA=T@Lr4xFK{OE4P9!-MSL4fw-Y`!<1W1H{7}g?jdm_=te5H zmTr`F9bAgIPv}M~x1Mf{H3P1pxUqDfDwjz&&YA_+NZe<130T7a1zz zT}ih}xgxsN)?&DplJ94_HOiIH{bIcf_q4dRbiXQBO1I7$5rsMwalg^6S1vLtbp7|c zH45$-aT#=Qr#qos z1GM;?B^WRW6n8oV5wu%OS_2^z(H8DA$zkg0&f3C&_n_ z?virN=`LGaz;%^;SLm)P*OKm9V%g^F1IUP1YQH` zLBy4@vHnlFZggdl3-ue~qUg#g*OM+92K5`_V(1LzdefP0ec^hGv(Qqlp^^@r;t z&Q9l0ZUCLrHW03_I2WB;xj}Ru+hDkN#Chp_$_=6O+tT6Q6&Ik3Rc;tvoNWYL|B&Nx zRGuzgxlwc#Y@^}c6IYS0l5%6|5^Uq(28gRnm#Ex$x+=B_aPNz&N>@#}iF85RB)Ea% zs?*g_ZZcg>+f=v@#MPp!t=u%aB-;$QLE`Gr)m3g5T|L_zxDUl8(>j*;8rhb@4H5S+U1Q~z(LG{Y4)?LRRJuo%TS3>vwh}H~ z++%c2m0L~sxNQyGP;t%Zo=|QrU31$yxMAX+q-&wvdb+1<8F0hJwWNDmxlFoNwk)_2 z;+~;vtz0(Uv$id8BgM6$drrA+bZu=r;6{mip01s8JLz7q?SlJ6Tzk3~mD@wt!IlFz zTHH%?9hJ+ad)c-hZj882bgw9Pkgl`sFx*&iuhMlDpx}Hf$c8bm*NJ|eW+Y1-C$cp zIn>jL`-pCca*^di*MA?|qTs#~mrgfSxoEm!HUn<5xZ!jol(W!{wAtXMh#N)siE<9Q z(KZ*{RB>bI#wzEb`_$%x`&!&My3dpg(2cjn!A%P}UYC4MH$l00x-V=M;iik5NcW|3 z33QWeiEuN-eML7}xvF$iY(co0;-=Dlty~SdX|`H$v&2oOo1t71-Ar3uxY^=n(alya znQo4)KHMB}-_Xrft^wUVTMFDa;^xyWP_7Z(LR(|Fx#AYlEmkg-?ps?ExOw81(0!*| zQ@W+LW^nVxeNVScx#n~~*jm6X5VxG}N99`5t+2I%TPW@)x|Pbcrdwre1NWV{)pS2A z*OqRLtsUIbkmL2iFLY~_Yfty9tpnWm;?~jqrd&t5^|nrM%VfE~(`6{vnQnuv3*2&X znRFYK>q?hpON0AS+$Oqg<+{;rw)KEpA#MxZR^@uqZL{@)`$^n(x*f{(ru)Oz7j9+9 z@qO7z_os6G=yuuq!>tmxn{JPC1L*eJ2EwftmqWKtxj}Tfw!v^ei~Eaizj8z94%pJ+ z)`&YucSyNmbcbyt;MRs5&j&~7jw&~b?r+;@xL?H`qdTtLSh^Foac~>NoutcCZam#7 z+XT2wai{6dC^wPrtZfq9Mserp&MP;W?jPG!xGZrO=q@TZjqZ|d2HYlbm+7u3H;eA7 zZ4O*^$Z`8#qr0x$T)KSQe7McxZqOAdw~+3pZ86*yS#BZSE#;Qb71@@;Z54N$u2{Kc zba!ma;kJn@q5D_46?AuPE8(`w`rV@|Rc;LTc@3+>{McUWF{UNR_ zU6gX`>B`wN;C70Ori)Q7lg_Yb!TlL>-2cpU7Ui<(toAK%yTsY(?8x zb1Aoz&TZcXw?~|Z&a2!WI-flUZm&2$T|l{9x>);uxEyhDbmf&hNEdHE47X2Q1-gpL z9i^*eKL(d8E`hGHawq5#?Rjv2iK{|aRk_o2)$C{C_KOSBRafpjT@CvMxC7#9($!M# z5?yWk6}W@qlIZFvca5&DJs<9nxO#NS$`#N(U@wF_EUrG?gUS`rHLw@M9TE2sU5aug zbPery;f{)HME9_ArF4z$=ml>3Tihdbsmeu0v;NN>1$Rtb6S~Kgi>7O8M_U-%adD5+ zHB-()_k`UBcS2lqx+j%$(6z9;;7*Erims({9=fOPKDaz_t>~UnEte4D_m8;O=(;M`fbMmB3fu*8X>@NW*NCp0y)oQH zaoy>9D3?n2ro9Q=C2>9J-cqh9T`zkxxXa?+rt7U-bGknE7I0U@^`(19xt4VO?5*Ig zihGx?zjCeV-m|xXyC!Y`-TTV5r5k8(2X|fE2Xup!Yftx~y#rjnxWRNEDc6y1h`kfs z4RIgSr7PE&Zm7KrT!FY@bil(be}8Nk8Xm!Kiq9`U(iidZUEhv_JMH4;wI63rQ9I8$@al; zcf?Jho2uLpy07i&a3$iV(M?xw7~Kr}2)KX6&7_;9+$g%)_R(;6#m%AnM!B(cbM52c z?una6H($B&bPMbg;7Y|Uq+6uiM7qWHNpPle|D89!rCXxhWV-L{Q{f`SEv5TjxoLFE z>@(oXi2H$VxpK4Uezeblixjtl?kDBu(yg@5hbtR$WkP?qT1B@S&K|qL9-HApCoMLT zh&C@ftqi7nOJk-CFxnxN_otrCX=mGP>XF%i*HMt*854xfOI7_LXok z;;?ckOu|~q7wW6&Hrm&~8RD|&HYvB3F5A8i&Ma;--4^B6(`~h9z)g?&Zv$?l+pb(D z-41&e+zfGl(Ct(%o9<8h7Py(>cG2yI!yE2G12ewcSzgLFsjhvDXm`F40|9?i$?{dp_L1vfQh5*OV)uyKXOpyDKiA?uK$jbOrWexO?Jm z(iJLKLU+r47p_!X5#4R&O6iL2Xqas?p>y5;Zcy&fl_(b(!vaBj6kLS3yL9)Ii>52J z8*pXBnH($-bll$$EOccYHn>P}k#uF1bI?UOTySN@m7|MR&O;aD@WDliGw9691?Vh} zIJk1+taLWz;_2*;ig3~59CS|Q66joxM7S7nZaR;0Rq4DAv{$ql;(T;|P3dYmn!!26)uu~Qt~p&DM+-QYxVm)plxs}Un&7WV*M zedSuyJ?Lly=MmR{?jhyc(xo`s!Fk0sq-&&Hd%A}m9pHT88q+Uu;>ES5dsexAbZs2{;VOuGj;^h81L&T241}vBt{vSA$_=7x?-&f1 zAnrxF4$2Lod&!XwS6N&~x|fw3M%T$P0xnV9D|DTe8%6i3V>Db9ab4(MQ*JC>SI0QG zs^VU!OH*z<-5ZVxaMi?hqwB8RM7kc1NpL}NZ_@QtZZh3lj;U}p#Py{vejP4`Ha=5zUhR}Vi+zPsM$4a<*;)c=zS`6!$gVH05&XraSh-H4-<2Zl-bv>1H_& z!#yHyHr*WMj?#VOI0lz0ZZ6$C zG29d4ex_TaTnXJTj=ONp#jU0LRk>2Sbq=(Qwmm8CH@fx8MH(y+bVR|m5SKx>LAhwU zOb5C}+ny4)kuFO)3*9D%4X&lQY`V?LIq0@HTyRf|+e)`hIS<`-hYzlmxE*wVC>NmH z>4<}SM%^6q`RbCbGplp7H}_%yFzzWxt4U- z9IfCwiMvjhuUu=o8;&+`uZSz4yQy4Tx6LADW_adx@c!FxHNGwbcS-h>CDc)aBqmS&{>u1M`v^PhwCQJPUlc=0G-o05U#s8 z7oA(VL3AGHV7MOQymUV0hS2$)>2Pm~3(&Sllyot(D8Bd)B!H?jvz+ z=$=z<8(mxH4!9xWo~LW4+)la|oV(yY7T2EcMdkL;b#Ugu4HfqiT}S0|>0Wm3hZ`oY z6WuGy9i;2*JPbEn+^ck5lsiiIn)4Xk2ytEMURUk}U79lwZlt(3=(;I)ny$O^EZit@ zJ?P$4?mS&j=LNV=#Jxq=OSwyQZ#%ERjTYCNu8(rp==wVI;l_x2hpwM;1$6H^3*p9! z>reNdaz%6loW*dTihG}KpmHU2A2{#AjT1MB?nC8D=>|J7t&8n5aUanQQ4SNlgbDelXMecw#qFlsquc4$>V`ZW!HR=LonT#T}tLs@y2Lzn!DueiC<#?znPe=}tJu!L1Z`k}glV z@pPx06W~^hJ56^+xruaVos-~x7I%*BymFK2{&7x)TPyAY-9_c5(Oq)RfcsV4Wx6ZM z&7!;NoCCK`+%>xE%FU(Acg~0VP23H-0_7Ic-E=O7TQ9DV?v`>(=!%?6;eHo)o32>7 zWpsC(%i%J_mC*gG+zPt8&XsT*#NDGSRc8!3TaM|K)bav&o(K%c@;5LhM(z%q| zN#}O$g4-g_L+4d)51r4I1GiP2pDv(WE?ul^KioEPadhRCJ4hGrIt;g6Tm`y{${nSv z8iQT!tE3nq^qvndAb^|3vhpmt4UW&xl44l zU02|CiA$oZque#Rx~_b<-Qw!eB`a4z_kgPqZjZS7bPp<5MAyJo47XR@Lv$(1mC!YG z-G$2$*NE<6$!3D(9eU;c~$p5cd>aOXWOtPrH0@2gS9bdq%kcU29hy+#zw# z(zQ`8p6)qUMYtp4+R{C*TmoG?S0da|aWBxdSFS4Ei>@Ht-{LyZy`)?Xx{j_|aL2{H zOxH=dB)V5zb>U8k>rD5ma>;aET=n5jihGT&t8xwKUU#Ly<%vt9dqcTKblqHy;ZBL` zPS-=ZRJu1^P2f(8>q+;Pa!u)axthV95%)G-Z{?cP^>MX;J1ed)-8;&)r0eHu1$R!| zyLA1PYfbl_s}0;g;s(&YuUuQYfv$FN7sP!)H%PhmbRW7pz+DtKnC>IxI?@esb%MJj z?qj-i9p zaZ~7~DmR4gYganlEpgN6rYkp$ZiZ_FT#>k$bhDHjMK{|u8t%5ZIdtDBHExj<|(%i}xt7C4id#?jyK*b&GF&U+%8Ek`U8pWlxz%(VU2EW?#AVTKQf@6> zwrd?+IdPlmwkWrrZmTN;E?V3+y6wtk((Q0%!NrLCgKnpC*>r!pw!oRi?V{VQ+%~#B zt{rd|aeL`Fy~PO;_qN z;3|tVxmo|`zJDKOp)2FI!6k}|q${hOgD%SLf~z8~99^_>9=aH}53Z^>gU+m6fX?EM zgR3UaN@r6pp3d&B2p1IRpmQphK<9EN!c`aNrt>IQmCox9!qpb%qw_0QgD&8%1(zf) zmM%`YB)anMx^Q*G#nV+#E}5>PyFOe!ah2#2lxsj&*_{HHEH06*igJzUs=6D)Js_?c zT~N7Hy6WyGaP`I2psT4|Q@UF2W^fORt4)`rTywfQ?iO$j#MPy%r(8?AWOpmLhr~TV zS6{i-bPu}Qz@><5K=+VxZRt|n?cf@UYe?5fx%PAqyF0)&64#jS5#>74rMf%8JuL1~ zx+cnXrhClY1+KBUrgV=h*OjiBI}Pp;aZk`SSFRh~lkOgHsp4ADJ*8Yvx|Z%JDAiDPM!EnvQ zy-3$Vxgm5fxzpjE5Z96JW#xv^b#jk@YcB2;y3WdtqI=an8tzGPUFcp@ZY*6__c*u~ z;$EjqQ*J!n8}12kPl@YB*Il`ZbUoaY;981%ldh+7lj+`aPlbD0Traw}m77M_+dTuW zmAF21eU+O<_l|oG+%w|((Y>qOT)O`5`Eaepy+=1dxrKD^yBEVfD{dg&2g)s>8{}RJ zhw0D4-wS+5H(0r4bRW5w!#yW%2;IlZt)NSHuY_wWZYbR_^i?|7NUnsYY zZlZe!+-u^#q?@GNPP(t$yWqNtn@l%Fxjl4K-8pcti~E{xnsT{x)7|^w(!|Z6o2lGE zx>@eSaBqm4O*comqjcZ6kHK{lHi(5dqP`T4|i`-}7dWc(0_pNg0 z>6W-Jz`ZH%JG!OHU84KmeFd(kxMg%dD0hu+xjP^3Epb26tx&Fj?k9I4TrY7e=~gLM zM7P>q4EMIUpXt^pS3>uT`z~B>ack**Rj!n7ojbyY`Vw)!(XCf5(iXb@``sM{*H>Hy z-3H~N=`!60+&khn(q$=Uq1)uP!SxfDO}AM&2i+F83+`QUTj{nb=b_u~_QCZRw}b8v z+!?yF%B9ksb2oweSloHKf0S!Vcfs8ZE?wM3x=YG6r@QQK0XJ0K6}qd+ zwWPb|ZUr|?+;zHqTJooRTy3$2>(%{C3E8_{>Z$&ECjjpVx2i#b3 zQFP_tT(KMe_Y=J+Q%}-pPcP6eIoyRj6?sIWoI-hbw==`2^xC!C{bg{|}ql@#5fcrvRdAfMzM$uL9 zjE0*it|DC}<;Kz_c*enfDXubIqH^Qus(2>AO%hj?u9|Wa>4Kg~a9@e5PFF*@$#gY6 zQ{g6yt3_8^xoLDso*8gc#MPmztK2NQdY(COQ^h6IJ)qoNy852^a9@jikgkDp3+W#6 zEQXsVE`_e4a!cqMd6vRW7xyq-W9637J>ppoH$z-1-J{B_pljk;2{%*RV{}cGTTS=4 zXARsean0zSP;M<1vg(@d%72u+e6pElLNOv+)H#FmCL1j z*|Q&Rp}03VuDz(D1-7t><_mjBcbR(3r(2exi;8uzoMfZtv4!Y4E7u>3l z9RK4shAx*3tc@Tt~X~o=$L^#r;l~p@l4+;+Mh%JruE!_yaThq#?|e=66HZkMM& z+#lk0)9q1i0Nq~CK)9Xaa_IIcH;69RGZ^kqaevY6S8fR10Z%&IE^!Cx4krch)lr zZlAbwbmx_uO!tpxDqOC(3v?Hin?`rZGXw4~ahK_?C^w7ls%H+|esS06t}8c}F5fdB z?tr)(bOp*Sq`T=^40ljmA>A$Ime3V>mcktpcbl$Qxn*>BJj>w@iz}h~SGg5*cRefN zj)=QQSE}4j*%<%?@h_oQ+Tx)xp+ z+zoM0(X~|0L-(}T2Uj4j72Pw+1?XCP-G|-|aAm~}ru#^_j&wu3o#3LxeN30GTxYtW-Y#(E#0{exu3T5T z5#BVoXmKOyMk&{g?h|hhIG4E5bYqn3NjKKp3(hU>Q@U}=^``sG+ZWCwZam%R%Jrk0 z;O!6R754?*MCAt1ed!$t=My)H?knX6(M|RahVzS?LN`^pA#`7R)8PW*rqNATZW!GR z?+Cb9aWm;=DL0C4ws$mKd2w^-zEN&0-CXZDxOj2%=;kXoo^FA60$c@g3+WaqH<50! zcM@Dhao^G{QEoEbciyRRmBcNj`(C+ebj!Rm;3|vzfo{2Sv*>>G&Vfr5w}S2`<>u0@ z^v;K?B5oDkYULKv{p?)~S5@2^x?hxALbukt6t0@MU+LB*=<7GvI28+eWutxlFnp z-YmG<;{Kr9sa!VQpWZESN#b_V?N)9Z-5&1_xH{tY(&Z?(lWw1P7hGL&xpaRiw})=O zHwUhsxC3+tmCL0&YRL%AZl0&g+g!{Tnz6)IOkcguShuCcfxy4%W?(iM9n zoG2j@cZaS-xkx8V0KHLgsp9U^-BT`_uGDM5Ju1%RV+o+|{(Y2%u8hwH*F;<-U0LNE zbWuJR++*U((M2ogp^NeP;F^jv=*-Fm=q$cCxW~m=>1@iy)7gC$;hKqa&^eV$pmX^W z;n12WT(aV(^C(x9&g%=pp+QmD`RM%0)u0RbYQZfM7fTnXToPS*UtKt~Fbe03r>me` zGF?SqeK<5S3cE^l3CcB~tL#gG`(9ikT@~dT(N*;|hC{2PaK37ELFH2Es{5M2q2W>3 z)u5}XTvNJQzGiSgiK|VQq+D~lI=&WgtHsr&tEXH`x@2D~I5b8IFZThu`pUJYd(hVg z4y}{It^wUc%C)6S@wJ2dMO;I=M#{CPd)U_j4vm(=`5MzbqFhJ1R9`1Jv|0NwMxfpD4P+R?qB+#tI4 zzQJ&4EEUf8B3%dNhS0s_ONT@2sj%xv_p)-s=sNjEz-RpESH z=w4H9EL~UMI5@Pj3cJ_o(v%xd_l9o*+!k@&=(;O6k*=IA1@yca@t<*WWiE4z0Gr?mfBz z$}OaO-?tcUr?`Q1A1JqkZjf&&92#?l^Lkx=8E`q`#?pPNTqfN( zUltr1hlTTfMmJu$Y`V{VTj0=IEbJ!GeWBbox{1CWaDR#Wl5Uc6JL$gi?SexivT(l1 zbW@buLpRlz1BX^+VfQuNH05&Xru+889TYc%Zl-bv>1O#3!=bTRINxl#Im#WS`^I++ z4z16^ZZ6$Cn6`7WvM?p;1~m-(tFNl{-(j#CHJ>t<=KqJG!OH zU84KmcLna4xMg%dD0hu+xi23Mjn~5YexzHWTmjurzCt*(W(&KObgPsrqFe1NhC33&tNly03b!iDd5#r;ONUb#pYO8|XQaHqv(&}~pInl964 zz@af*c)1(tvXryXZSvXR&^j*cvgtM}=b+o-bHSYxx0P<2avr+vJ|7$!&4u&rp!-9) z0NqYs92{EFh25WYyOfKk+wH3ecR}19y1mLJ(B=3N;n27)oNpgpu5wlB{_+Lk(AqBS z_R}3ut_IyfUoE)H;ttUrRxXL|h_5aj8sUZW9i{tQxn#OyzWQ)zl^1r$=}suufbOI( z1rDw9!Y+^QlyZ&ePWu|e-4J(%?yPdDbmx3c;LunvobNo{Kgub*8)L>jD=cu9VK?zkeUql`g`c z23JO089z$^mFq@V*53mzQd|^WIpuoNMf-cfl@%95XDHX3&g}0C7bVU@XH~8roz34L zuADeKokO_+bWZ<3xM*=MI=6Cz=sfALjipQQkAt&{t4x=u+<3Yw{t0k4aaHN6DL0WW=$`~<7gwFG zhH{hXYWkaE>2uax~G-Pq-*8Rf-5iX8M@ZW zWz#+D-vSpet_|ID%59@->)!!aLEQ6n?UdU|_kw>HTt#v1>0VTB4_ya;4qPR1FVS^W zE|>0Q|9-dxah>R1QSKmJXa8Zi%Hm$7>!RFIy4U>2;84XCKA(1_dtJE`bZP!PxJlyP zpzEgGX}a$Ivv8=+3g_!V_oi~^>3aGvz)cbN7F{ppF44X1zXFFks&KyEbbXY&M%UM$ z4>wQTJ9Pb&E1-MVUkHb)t8l*lbnhuwL^r@+47XI=`*Z`9E1~_ZH$lq8p-Iq?`4B{wTQh;?n7cDi=*R%x}PATj6g7 zw@chlbSsr>O}EP51`gF|;e4yUUBQ_ep9X^ z-Fkl~I8?WV^ZibjpmLkvSlnN9`;{9)cfg+xhiblXzJqj!lp98O*gpae^?qS@gzl(v zqv-zjkA_3lU)UX^JFeVVx)c6!aHtCmyOVTz%8jQx<(~j|LfmP(Gs;b*JL{hWhbqEw zzH@Zvm77fWkAEs0>I}o~0^LRBrqNyU&wx86?lRpK24{vgs#ZH6b@C6;e5C0ij`YNcgMdR4t0=WS3>u% zax3WW`d7mJBkmqusdB67Oo26UsG1DtiwLm(FL3{UYb{-5U>zLlD#NZUU6gX`>B>gw<+AClfh};T!VKrL(b<*TM&}6ZfJ2>T*g5H3%I&0c z2X?{bi}TQVmD@w-3*^9|sxw@l=%))PmrEBL*bj%g&+u~N=*la1kS;!O81AOH3Un2f zJ4#n6a10JrqTzfAbd{AmL6;cFgF_u@*j1scs@!S1YJszG?*{&R9YMP4%AKdH5x4-? zUtCSPTFPCbs~xxk_nx>Ux;n~TqpKUphkIXKJ-TG&3g{jP6v7P@SD)@d<%;MU1d8E4 z5cd#WigG1%4Fh-K28nA#_poxMbd3WM9{fa9+#__U%0+ru{}+gY8!WB~-DAo{(=`nk za36_#oUWO27P=<_Hn<_;n$tb0oP(}Kzy}bgu;J!hIsHGu^AoCDU~Y)Q1}@?lrou$~B;SJ&*$Tskk({Hs8!|@`nGu=mlE^t%C;YfkQaUiZM-N%76xT)fB1i)ec7T1k# zXrKq&*W$2e!(qP_*OP8|pcmXUaoA(wMk?2vZe*Y@+;nl+W8ttrO1^${qXYfnW{4X@ zhy7990J={D1L0AMz=691MXXK$c%h= zp14_b-v;KuEs^CSg8NRnxpYed^WiWaUcc|@mMOQ8?uWo)IQ(zeEvNfYxg~Tf0!!iW z{P6buiEgEG%ji}Gmc#uZ`Bu~YtlSE^HG!3ISXy|wztF8!ZZ+N7z#2F#Exg=c>DDQ? zmhQK}IykIPINy4@-<4ZWml4Q-TP1D-U8ZuGbQ=R%a9FEwzAU;;%4O4K2e!ary~A!Z z-4^Ax(QOUvfWz8{-8Q=I%I&1v5!eNX*B5qw(Ct)i58a=E9Jt@a?V{VQTrS<7z2j1iNVhL=7!L1O*yYmwrQA`v{efd}8R8Dm9aQcF-Jw7p9NyhPT~FK6y0g%uF;(dM;KXezAE26s?D2Bs!54%frmz67_yArqyw^iI#x@*dn(p?Wkc=7#^ zxO}=B%0+rZ*M9|pC^+m#;pN_>D^xC;?pDBn+aa!q?zVCky5fKh4trfV-yOOV;3DkzsqS24B; z9FE#>zDjfn$~C2{9NP@;khny;D#|sds~Xz^4o7!5Up2a*axLkq$F_pQ@gH_I=xQq0 znyyxC8@Qw5YSSet*OsnMY&$rdEyDTg($!P0Jza8a2RNKh!tMdO`pR{rdoZ>W+;MRY z=pIt8GhIq-7dV`K!ucA~HBzoC-NUhIa5z7OU1PdOl}BNfVJKA|OqAC-e>hA_4-^dlQk~L`0;9BGNmEh)5F= z5fKp)G4Gi(vv;mM&%^us!;kl#`OcZi>}F>6-UaH>)fa9E-RrJla5y{K+igJihHxY3 z8oEZo;rwa4H|ZJ)H-@gUD-rG&<(kkn6>dCTGglHE&bD^nTXfBZn@IPzYZ4sJ$F^%h z_l|H==vum_!aY!~6m=MFy3Vd8aJUZGeO>6f3b%}|n`;H!W7F||ygOYF;a1Z1bghEJRm1M`f1%8!X&5x*@LZ zaJW?2ZYbR_;damsckP6Grgm`z-ALhf(T#HLfy0H({!vELje!$C%0AMuu4GVL{p?LA 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

q++_+#7DRa{tk#2-la+p(VhLQ7%ez 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.0.0", - "react-native": ">=0.72.0", - "expo": ">=49.0.0" - }, - "devDependencies": { - "@types/react": "^19.0.0", - "typescript": "^5.7.0", - "vitest": "^3.0.0" - }, - "keywords": [ - "react-native", - "bytelyst", - "platform", - "expo", - "mobile" - ], - "license": "MIT", - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/react-native-platform-sdk/src/auth.ts b/vendor/bytelyst/react-native-platform-sdk/src/auth.ts deleted file mode 100644 index 9ec3a88..0000000 --- a/vendor/bytelyst/react-native-platform-sdk/src/auth.ts +++ /dev/null @@ -1 +0,0 @@ -export { useAuth, AuthProvider, type AuthState, type AuthContextType } from './auth/index.js'; diff --git a/vendor/bytelyst/react-native-platform-sdk/src/auth/index.ts b/vendor/bytelyst/react-native-platform-sdk/src/auth/index.ts deleted file mode 100644 index a5af012..0000000 --- a/vendor/bytelyst/react-native-platform-sdk/src/auth/index.ts +++ /dev/null @@ -1,208 +0,0 @@ -/** - * Auth module — React context + hook for authentication in React Native apps. - */ - -import React, { createContext, useContext, useState, useCallback, useEffect } from 'react'; -import type { PlatformSDK } from '../core.js'; - -export interface AuthState { - isAuthenticated: boolean; - isLoading: boolean; - userId: string | null; - email: string | null; - error: string | null; -} - -export interface AuthContextType extends AuthState { - login: (email: string, password: string) => Promise; - register: (email: string, password: string, displayName: string) => Promise; - loginWithGoogle: (idToken: string) => Promise; - loginWithApple: (idToken: string) => Promise; - logout: () => Promise; - refreshSession: () => Promise; -} - -const AuthContext = createContext(null); - -export function useAuth(): AuthContextType { - const ctx = useContext(AuthContext); - if (!ctx) throw new Error('useAuth must be used within an AuthProvider'); - return ctx; -} - -interface AuthProviderProps { - sdk: PlatformSDK; - children: React.ReactNode; - /** Called when tokens are received — persist to secure storage */ - onTokens?: (access: string, refresh: string) => void; - /** Called on logout — clear secure storage */ - onLogout?: () => void; -} - -export function AuthProvider({ - sdk, - children, - onTokens, - onLogout, -}: AuthProviderProps): React.JSX.Element { - const [state, setState] = useState({ - isAuthenticated: false, - isLoading: true, - userId: null, - email: null, - error: null, - }); - - const handleTokenResponse = useCallback( - async (res: Response) => { - if (!res.ok) { - const body = (await res.json().catch(() => ({ message: 'Login failed' }))) as { - message?: string; - }; - throw new Error(body.message ?? `HTTP ${res.status}`); - } - const data = (await res.json()) as { - accessToken?: string; - refreshToken?: string; - user?: { id?: string; email?: string }; - }; - if (data.accessToken && data.refreshToken) { - onTokens?.(data.accessToken, data.refreshToken); - } - setState({ - isAuthenticated: true, - isLoading: false, - userId: data.user?.id ?? null, - email: data.user?.email ?? null, - error: null, - }); - }, - [onTokens] - ); - - const login = useCallback( - async (email: string, password: string) => { - setState(s => ({ ...s, isLoading: true, error: null })); - try { - const res = await sdk.fetch('/auth/login', { - method: 'POST', - body: JSON.stringify({ email, password, productId: sdk.config.productId }), - }); - await handleTokenResponse(res); - } catch (e: unknown) { - const msg = e instanceof Error ? e.message : 'Login failed'; - setState(s => ({ ...s, isLoading: false, error: msg })); - } - }, - [sdk, handleTokenResponse] - ); - - const register = useCallback( - async (email: string, password: string, displayName: string) => { - setState(s => ({ ...s, isLoading: true, error: null })); - try { - const res = await sdk.fetch('/auth/register', { - method: 'POST', - body: JSON.stringify({ - email, - password, - displayName, - productId: sdk.config.productId, - }), - }); - await handleTokenResponse(res); - } catch (e: unknown) { - const msg = e instanceof Error ? e.message : 'Registration failed'; - setState(s => ({ ...s, isLoading: false, error: msg })); - } - }, - [sdk, handleTokenResponse] - ); - - const loginWithGoogle = useCallback( - async (idToken: string) => { - setState(s => ({ ...s, isLoading: true, error: null })); - try { - const res = await sdk.fetch('/auth/oauth/google', { - method: 'POST', - body: JSON.stringify({ idToken }), - }); - await handleTokenResponse(res); - } catch (e: unknown) { - const msg = e instanceof Error ? e.message : 'Google login failed'; - setState(s => ({ ...s, isLoading: false, error: msg })); - } - }, - [sdk, handleTokenResponse] - ); - - const loginWithApple = useCallback( - async (idToken: string) => { - setState(s => ({ ...s, isLoading: true, error: null })); - try { - const res = await sdk.fetch('/auth/oauth/apple', { - method: 'POST', - body: JSON.stringify({ idToken }), - }); - await handleTokenResponse(res); - } catch (e: unknown) { - const msg = e instanceof Error ? e.message : 'Apple login failed'; - setState(s => ({ ...s, isLoading: false, error: msg })); - } - }, - [sdk, handleTokenResponse] - ); - - const logout = useCallback(async () => { - try { - await sdk.fetch('/auth/logout', { method: 'POST' }); - } catch { - /* best-effort */ - } - onLogout?.(); - setState({ - isAuthenticated: false, - isLoading: false, - userId: null, - email: null, - error: null, - }); - }, [sdk, onLogout]); - - const refreshSession = useCallback(async () => { - setState(s => ({ ...s, isLoading: true })); - try { - const res = await sdk.fetch('/auth/me'); - if (res.ok) { - const data = (await res.json()) as { id?: string; email?: string }; - setState({ - isAuthenticated: true, - isLoading: false, - userId: data.id ?? null, - email: data.email ?? null, - error: null, - }); - } else { - setState(s => ({ ...s, isAuthenticated: false, isLoading: false })); - } - } catch { - setState(s => ({ ...s, isLoading: false })); - } - }, [sdk]); - - useEffect(() => { - refreshSession(); - }, [refreshSession]); - - const value: AuthContextType = { - ...state, - login, - register, - loginWithGoogle, - loginWithApple, - logout, - refreshSession, - }; - - return React.createElement(AuthContext.Provider, { value }, children); -} diff --git a/vendor/bytelyst/react-native-platform-sdk/src/broadcasts.ts b/vendor/bytelyst/react-native-platform-sdk/src/broadcasts.ts deleted file mode 100644 index 2dfce29..0000000 --- a/vendor/bytelyst/react-native-platform-sdk/src/broadcasts.ts +++ /dev/null @@ -1,7 +0,0 @@ -export { - useBroadcasts, - BroadcastProvider, - InAppMessageBanner, - BroadcastModal, - type InAppMessage, -} from './broadcasts/index.js'; diff --git a/vendor/bytelyst/react-native-platform-sdk/src/broadcasts/index.ts b/vendor/bytelyst/react-native-platform-sdk/src/broadcasts/index.ts deleted file mode 100644 index 3d29d96..0000000 --- a/vendor/bytelyst/react-native-platform-sdk/src/broadcasts/index.ts +++ /dev/null @@ -1,125 +0,0 @@ -/** - * Broadcasts module — React context + hook for in-app messages in React Native apps. - */ - -import React, { createContext, useContext, useState, useCallback, useEffect } from 'react'; -import type { PlatformSDK } from '../core.js'; - -export interface InAppMessage { - id: string; - title: string; - body: string; - type: 'info' | 'warning' | 'critical'; - action?: { label: string; url: string }; - dismissible: boolean; - expiresAt?: string; -} - -interface BroadcastContextType { - messages: InAppMessage[]; - dismiss: (id: string) => void; - refresh: () => Promise; -} - -const BroadcastContext = createContext(null); - -export function useBroadcasts(): BroadcastContextType { - const ctx = useContext(BroadcastContext); - if (!ctx) throw new Error('useBroadcasts must be used within a BroadcastProvider'); - return ctx; -} - -interface BroadcastProviderProps { - sdk: PlatformSDK; - /** Poll interval in ms (default: 300000 = 5 min) */ - pollInterval?: number; - children: React.ReactNode; -} - -export function BroadcastProvider({ - sdk, - pollInterval = 300_000, - children, -}: BroadcastProviderProps): React.JSX.Element { - const [messages, setMessages] = useState([]); - - const refresh = useCallback(async () => { - try { - const res = await sdk.fetch('/broadcasts'); - if (!res.ok) return; - const data = (await res.json()) as { - messages?: Array<{ - id: string; - title: string; - body: string; - ctaText?: string; - ctaUrl?: string; - priority?: 'low' | 'normal' | 'high' | 'urgent'; - dismissible?: boolean; - expiresAt?: string; - }>; - }; - const raw = data.messages ?? []; - const mapped: InAppMessage[] = raw.map(m => ({ - id: m.id, - title: m.title, - body: m.body, - type: - m.priority === 'urgent' ? 'critical' : m.priority === 'high' ? 'warning' : 'info', - action: - m.ctaText && m.ctaUrl ? { label: m.ctaText, url: m.ctaUrl } : undefined, - dismissible: m.dismissible !== false, - expiresAt: m.expiresAt, - })); - setMessages(mapped); - } catch { - /* silent */ - } - }, [sdk]); - - const dismiss = useCallback( - (id: string) => { - setMessages(prev => prev.filter(m => m.id !== id)); - sdk.fetch(`/broadcasts/${id}/dismiss`, { method: 'POST' }).catch(() => {}); - }, - [sdk] - ); - - useEffect(() => { - refresh(); - const id = setInterval(refresh, pollInterval); - return () => clearInterval(id); - }, [refresh, pollInterval]); - - const value: BroadcastContextType = { messages, dismiss, refresh }; - return React.createElement(BroadcastContext.Provider, { value }, children); -} - -// MARK: - UI Components - -interface InAppMessageBannerProps { - message: InAppMessage; - onDismiss: () => void; -} - -/** - * Placeholder banner component — product apps should implement their own - * styled version using this as a reference. Returns null (render-only hook). - */ -export function InAppMessageBanner(_props: InAppMessageBannerProps): React.JSX.Element | null { - // Product apps implement their own styled component - return null; -} - -interface BroadcastModalProps { - message: InAppMessage | null; - onDismiss: () => void; -} - -/** - * Placeholder modal component — product apps should implement their own - * styled version. Returns null. - */ -export function BroadcastModal(_props: BroadcastModalProps): React.JSX.Element | null { - return null; -} diff --git a/vendor/bytelyst/react-native-platform-sdk/src/core.ts b/vendor/bytelyst/react-native-platform-sdk/src/core.ts deleted file mode 100644 index b1e3c4f..0000000 --- a/vendor/bytelyst/react-native-platform-sdk/src/core.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Core SDK factory — creates and configures platform clients for React Native. - */ - -export interface PlatformSDKConfig { - /** Platform-service base URL (e.g. https://api.bytelyst.com) */ - baseURL: string; - /** Product ID (e.g. 'nomgap', 'flowmonk') */ - productId: string; - /** Function that returns the current access token */ - getAccessToken: () => string | null; -} - -export interface PlatformSDK { - config: PlatformSDKConfig; - /** Generic authenticated fetch against platform-service */ - fetch: (path: string, init?: RequestInit) => Promise; -} - -/** - * Create a configured platform SDK instance. - * Pass to providers to wire up auth, telemetry, flags, etc. - */ -export function createRNPlatformSDK(config: PlatformSDKConfig): PlatformSDK { - const platformFetch = async (path: string, init?: RequestInit): Promise => { - const url = `${config.baseURL}${path}`; - const token = config.getAccessToken(); - const headers: Record = { - 'Content-Type': 'application/json', - 'x-product-id': config.productId, - ...((init?.headers as Record) ?? {}), - }; - if (token) { - headers['Authorization'] = `Bearer ${token}`; - } - return globalThis.fetch(url, { ...init, headers }); - }; - - return { config, fetch: platformFetch }; -} diff --git a/vendor/bytelyst/react-native-platform-sdk/src/feature-flags.ts b/vendor/bytelyst/react-native-platform-sdk/src/feature-flags.ts deleted file mode 100644 index 15f9fc3..0000000 --- a/vendor/bytelyst/react-native-platform-sdk/src/feature-flags.ts +++ /dev/null @@ -1 +0,0 @@ -export { useFeatureFlags, FeatureFlagProvider, type FeatureFlag } from './feature-flags/index.js'; diff --git a/vendor/bytelyst/react-native-platform-sdk/src/feature-flags/index.ts b/vendor/bytelyst/react-native-platform-sdk/src/feature-flags/index.ts deleted file mode 100644 index 77825ac..0000000 --- a/vendor/bytelyst/react-native-platform-sdk/src/feature-flags/index.ts +++ /dev/null @@ -1,89 +0,0 @@ -/** - * Feature Flags module — React context + hook for feature flags in React Native apps. - */ - -import React, { createContext, useContext, useState, useCallback, useEffect } from 'react'; -import type { PlatformSDK } from '../core.js'; - -export interface FeatureFlag { - key: string; - enabled: boolean; - value?: unknown; -} - -interface FeatureFlagContextType { - flags: Map; - isEnabled: (key: string) => boolean; - getValue: (key: string, fallback: T) => T; - refresh: () => Promise; -} - -const FeatureFlagContext = createContext(null); - -export function useFeatureFlags(): FeatureFlagContextType { - const ctx = useContext(FeatureFlagContext); - if (!ctx) throw new Error('useFeatureFlags must be used within a FeatureFlagProvider'); - return ctx; -} - -interface FeatureFlagProviderProps { - sdk: PlatformSDK; - /** Poll interval in ms (default: 60000) */ - pollInterval?: number; - /** Optional user id for targeted flag evaluation (GET /flags/poll). */ - userId?: string | null; - children: React.ReactNode; -} - -export function FeatureFlagProvider({ - sdk, - pollInterval = 60_000, - userId, - children, -}: FeatureFlagProviderProps): React.JSX.Element { - const [flags, setFlags] = useState>(new Map()); - - const refresh = useCallback(async () => { - try { - const qs = new URLSearchParams({ platform: 'mobile' }); - if (userId) qs.set('userId', userId); - const res = await sdk.fetch(`/flags/poll?${qs.toString()}`); - if (res.ok) { - const data = (await res.json()) as { flags?: Record }; - const map = new Map(); - const raw = data.flags ?? {}; - for (const [key, enabled] of Object.entries(raw)) { - map.set(key, { key, enabled, value: enabled }); - } - setFlags(map); - } - } catch { - /* fail-open: keep existing flags */ - } - }, [sdk, userId]); - - const isEnabled = useCallback( - (key: string): boolean => { - return flags.get(key)?.enabled ?? false; - }, - [flags] - ); - - const getValue = useCallback( - (key: string, fallback: T): T => { - const flag = flags.get(key); - if (!flag?.enabled) return fallback; - return (flag.value as T) ?? fallback; - }, - [flags] - ); - - useEffect(() => { - refresh(); - const id = setInterval(refresh, pollInterval); - return () => clearInterval(id); - }, [refresh, pollInterval]); - - const value: FeatureFlagContextType = { flags, isEnabled, getValue, refresh }; - return React.createElement(FeatureFlagContext.Provider, { value }, children); -} diff --git a/vendor/bytelyst/react-native-platform-sdk/src/index.ts b/vendor/bytelyst/react-native-platform-sdk/src/index.ts deleted file mode 100644 index 5a5e533..0000000 --- a/vendor/bytelyst/react-native-platform-sdk/src/index.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * ByteLyst React Native Platform SDK - * - * Provides platform services for React Native/Expo apps: - * - Authentication - * - Telemetry - * - Feature Flags - * - Kill Switch - * - Broadcasts & Surveys - */ - -export { createRNPlatformSDK } from './core.js'; - -// Re-exports from sub-modules -export { - useAuth, - AuthProvider, - type AuthState, - type AuthContextType, -} from './auth/index.js'; - -export { - useTelemetry, - TelemetryProvider, - type TelemetryEvent, - type TelemetryConfig, -} from './telemetry/index.js'; - -export { - useFeatureFlags, - FeatureFlagProvider, - type FeatureFlag, -} from './feature-flags/index.js'; - -export { - useKillSwitch, - KillSwitchProvider, - type KillSwitchState, -} from './kill-switch/index.js'; - -export { - useBroadcasts, - BroadcastProvider, - InAppMessageBanner, - BroadcastModal, - type InAppMessage, -} from './broadcasts/index.js'; - -export { - useSurveys, - SurveyProvider, - SurveyModal, - type ActiveSurvey, - type Question, -} from './surveys/index.js'; diff --git a/vendor/bytelyst/react-native-platform-sdk/src/kill-switch.ts b/vendor/bytelyst/react-native-platform-sdk/src/kill-switch.ts deleted file mode 100644 index ad836e4..0000000 --- a/vendor/bytelyst/react-native-platform-sdk/src/kill-switch.ts +++ /dev/null @@ -1 +0,0 @@ -export { useKillSwitch, KillSwitchProvider, type KillSwitchState } from './kill-switch/index.js'; diff --git a/vendor/bytelyst/react-native-platform-sdk/src/kill-switch/index.ts b/vendor/bytelyst/react-native-platform-sdk/src/kill-switch/index.ts deleted file mode 100644 index b9f5f16..0000000 --- a/vendor/bytelyst/react-native-platform-sdk/src/kill-switch/index.ts +++ /dev/null @@ -1,76 +0,0 @@ -/** - * Kill Switch module — React context + hook for kill switch in React Native apps. - * Fail-open: if the check fails, the app is assumed to be enabled. - */ - -import React, { createContext, useContext, useState, useCallback, useEffect } from 'react'; -import type { PlatformSDK } from '../core.js'; - -export interface KillSwitchState { - disabled: boolean; - reason?: string; - isLoading: boolean; -} - -interface KillSwitchContextType extends KillSwitchState { - check: () => Promise; -} - -const KillSwitchContext = createContext(null); - -export function useKillSwitch(): KillSwitchContextType { - const ctx = useContext(KillSwitchContext); - if (!ctx) throw new Error('useKillSwitch must be used within a KillSwitchProvider'); - return ctx; -} - -interface KillSwitchProviderProps { - sdk: PlatformSDK; - /** Poll interval in ms (default: 300000 = 5 min) */ - pollInterval?: number; - children: React.ReactNode; -} - -export function KillSwitchProvider({ - sdk, - pollInterval = 300_000, - children, -}: KillSwitchProviderProps): React.JSX.Element { - const [state, setState] = useState({ - disabled: false, - isLoading: true, - }); - - const check = useCallback(async () => { - try { - const res = await sdk.fetch('/settings/kill-switch'); - if (res.ok) { - const data = (await res.json()) as { - disabled?: boolean; - reason?: string; - message?: string; - }; - setState({ - disabled: data.disabled ?? false, - reason: data.reason ?? data.message, - isLoading: false, - }); - } else { - // Fail-open - setState(s => ({ ...s, disabled: false, isLoading: false })); - } - } catch { - // Fail-open on network error - setState(s => ({ ...s, disabled: false, isLoading: false })); - } - }, [sdk]); - - useEffect(() => { - check(); - const id = setInterval(check, pollInterval); - return () => clearInterval(id); - }, [check, pollInterval]); - - const value: KillSwitchContextType = { ...state, check }; - return React.createElement(KillSwitchContext.Provider, { value }, children); -} diff --git a/vendor/bytelyst/react-native-platform-sdk/src/surveys.ts b/vendor/bytelyst/react-native-platform-sdk/src/surveys.ts deleted file mode 100644 index 3d2b7f0..0000000 --- a/vendor/bytelyst/react-native-platform-sdk/src/surveys.ts +++ /dev/null @@ -1,7 +0,0 @@ -export { - useSurveys, - SurveyProvider, - SurveyModal, - type ActiveSurvey, - type Question, -} from './surveys/index.js'; diff --git a/vendor/bytelyst/react-native-platform-sdk/src/surveys/index.ts b/vendor/bytelyst/react-native-platform-sdk/src/surveys/index.ts deleted file mode 100644 index f62654b..0000000 --- a/vendor/bytelyst/react-native-platform-sdk/src/surveys/index.ts +++ /dev/null @@ -1,111 +0,0 @@ -/** - * Surveys module — React context + hook for in-app surveys in React Native apps. - */ - -import React, { createContext, useContext, useState, useCallback, useEffect } from 'react'; -import type { PlatformSDK } from '../core.js'; - -export interface Question { - id: string; - text: string; - type: 'rating' | 'text' | 'choice'; - options?: string[]; - required: boolean; -} - -export interface ActiveSurvey { - id: string; - title: string; - description?: string; - questions: Question[]; - expiresAt?: string; -} - -interface SurveyContextType { - activeSurvey: ActiveSurvey | null; - submit: (surveyId: string, answers: Record) => Promise; - dismiss: (surveyId: string) => void; - refresh: () => Promise; -} - -const SurveyContext = createContext(null); - -export function useSurveys(): SurveyContextType { - const ctx = useContext(SurveyContext); - if (!ctx) throw new Error('useSurveys must be used within a SurveyProvider'); - return ctx; -} - -interface SurveyProviderProps { - sdk: PlatformSDK; - /** Poll interval in ms (default: 600000 = 10 min) */ - pollInterval?: number; - children: React.ReactNode; -} - -export function SurveyProvider({ - sdk, - pollInterval = 600_000, - children, -}: SurveyProviderProps): React.JSX.Element { - const [activeSurvey, setActiveSurvey] = useState(null); - - const refresh = useCallback(async () => { - try { - const res = await sdk.fetch('/surveys/active'); - if (!res.ok) return; - const data = (await res.json()) as { survey?: ActiveSurvey | null }; - setActiveSurvey(data.survey ?? null); - } catch { - /* silent */ - } - }, [sdk]); - - const submit = useCallback( - async (surveyId: string, answers: Record) => { - await sdk.fetch(`/surveys/${surveyId}/start`, { method: 'POST' }); - for (const [questionId, answer] of Object.entries(answers)) { - await sdk.fetch(`/surveys/${surveyId}/response`, { - method: 'POST', - body: JSON.stringify({ questionId, answer }), - }); - } - await sdk.fetch(`/surveys/${surveyId}/complete`, { method: 'POST', body: '{}' }); - setActiveSurvey(null); - }, - [sdk] - ); - - const dismiss = useCallback( - (surveyId: string) => { - setActiveSurvey(null); - sdk.fetch(`/surveys/${surveyId}/dismiss`, { method: 'POST' }).catch(() => {}); - }, - [sdk] - ); - - useEffect(() => { - refresh(); - const id = setInterval(refresh, pollInterval); - return () => clearInterval(id); - }, [refresh, pollInterval]); - - const value: SurveyContextType = { activeSurvey, submit, dismiss, refresh }; - return React.createElement(SurveyContext.Provider, { value }, children); -} - -// MARK: - UI Components - -interface SurveyModalProps { - survey: ActiveSurvey | null; - onSubmit: (answers: Record) => void; - onDismiss: () => void; -} - -/** - * Placeholder survey modal — product apps should implement their own - * styled version. Returns null. - */ -export function SurveyModal(_props: SurveyModalProps): React.JSX.Element | null { - return null; -} diff --git a/vendor/bytelyst/react-native-platform-sdk/src/telemetry.ts b/vendor/bytelyst/react-native-platform-sdk/src/telemetry.ts deleted file mode 100644 index 82a6420..0000000 --- a/vendor/bytelyst/react-native-platform-sdk/src/telemetry.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { - useTelemetry, - TelemetryProvider, - type TelemetryEvent, - type TelemetryConfig, -} from './telemetry/index.js'; diff --git a/vendor/bytelyst/react-native-platform-sdk/src/telemetry/index.ts b/vendor/bytelyst/react-native-platform-sdk/src/telemetry/index.ts deleted file mode 100644 index 3cced99..0000000 --- a/vendor/bytelyst/react-native-platform-sdk/src/telemetry/index.ts +++ /dev/null @@ -1,134 +0,0 @@ -/** - * Telemetry module — React context + hook for event tracking in React Native apps. - * Maps queued events to platform-service TelemetryEventSchema for POST /telemetry/events. - */ - -import React, { createContext, useContext, useCallback, useRef, useEffect } from 'react'; -import { Platform } from 'react-native'; -import type { PlatformSDK } from '../core.js'; - -export interface TelemetryEvent { - name: string; - properties?: Record; - timestamp?: string; -} - -export interface TelemetryConfig { - /** Flush interval in ms (default: 30000) */ - flushInterval?: number; - /** Max batch size before auto-flush (default: 20) */ - maxBatchSize?: number; - appVersion?: string; - buildNumber?: string; - /** Default: beta */ - releaseChannel?: 'dev' | 'beta' | 'prod'; - /** Stable anonymous id (e.g. from MMKV). If omitted, generated per app session. */ - getInstallId?: () => string; -} - -function randomUuid(): string { - if (typeof globalThis.crypto?.randomUUID === 'function') { - return globalThis.crypto.randomUUID(); - } - return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { - const r = (Math.random() * 16) | 0; - return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16); - }); -} - -interface TelemetryContextType { - track: (name: string, properties?: Record) => void; - flush: () => Promise; -} - -const TelemetryContext = createContext(null); - -export function useTelemetry(): TelemetryContextType { - const ctx = useContext(TelemetryContext); - if (!ctx) throw new Error('useTelemetry must be used within a TelemetryProvider'); - return ctx; -} - -interface TelemetryProviderProps { - sdk: PlatformSDK; - config?: TelemetryConfig; - children: React.ReactNode; -} - -export function TelemetryProvider({ - sdk, - config, - children, -}: TelemetryProviderProps): React.JSX.Element { - const queue = useRef([]); - const sessionIdRef = useRef(randomUuid()); - const installIdRef = useRef(null); - const flushInterval = config?.flushInterval ?? 30_000; - const maxBatchSize = config?.maxBatchSize ?? 20; - - const resolveInstallId = useCallback((): string => { - if (!installIdRef.current) { - installIdRef.current = config?.getInstallId?.() ?? randomUuid(); - } - return installIdRef.current; - }, [config?.getInstallId]); - - const flush = useCallback(async () => { - if (queue.current.length === 0) return; - const batch = queue.current.splice(0); - const os = Platform.OS; - const platform = os === 'ios' ? 'ios' : os === 'android' ? 'android' : 'web'; - const osFamily = platform === 'ios' ? 'ios' : platform === 'android' ? 'android' : 'other'; - const events = batch.map(e => ({ - id: randomUuid(), - productId: sdk.config.productId, - anonymousInstallId: resolveInstallId(), - sessionId: sessionIdRef.current, - platform, - channel: 'mobile_app' as const, - osFamily, - osVersion: String(Platform.Version ?? ''), - appVersion: config?.appVersion ?? '0.0.0', - buildNumber: config?.buildNumber ?? '0', - releaseChannel: config?.releaseChannel ?? ('beta' as const), - eventType: 'info' as const, - module: 'app', - eventName: e.name, - occurredAt: e.timestamp ?? new Date().toISOString(), - context: e.properties, - })); - try { - await sdk.fetch('/telemetry/events', { - method: 'POST', - body: JSON.stringify({ productId: sdk.config.productId, events }), - }); - } catch { - queue.current.unshift(...batch); - } - }, [sdk, config?.appVersion, config?.buildNumber, config?.releaseChannel, resolveInstallId]); - - const track = useCallback( - (name: string, properties?: Record) => { - queue.current.push({ - name, - properties, - timestamp: new Date().toISOString(), - }); - if (queue.current.length >= maxBatchSize) { - flush(); - } - }, - [maxBatchSize, flush] - ); - - useEffect(() => { - const id = setInterval(flush, flushInterval); - return () => { - clearInterval(id); - flush(); - }; - }, [flush, flushInterval]); - - const value: TelemetryContextType = { track, flush }; - return React.createElement(TelemetryContext.Provider, { value }, children); -} diff --git a/vendor/bytelyst/react-native-platform-sdk/tsconfig.json b/vendor/bytelyst/react-native-platform-sdk/tsconfig.json deleted file mode 100644 index 055a6d0..0000000 --- a/vendor/bytelyst/react-native-platform-sdk/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src", - "lib": ["ES2022"], - "jsx": "react-jsx", - "skipLibCheck": true - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/vendor/bytelyst/referral-client/package.json b/vendor/bytelyst/referral-client/package.json deleted file mode 100644 index b922a34..0000000 --- a/vendor/bytelyst/referral-client/package.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "@bytelyst/referral-client", - "version": "0.1.5", - "type": "module", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "scripts": { - "build": "tsc" - }, - "devDependencies": { - "typescript": "^5.7.3" - } -} diff --git a/vendor/bytelyst/referral-client/src/client.test.ts b/vendor/bytelyst/referral-client/src/client.test.ts deleted file mode 100644 index 24eeafe..0000000 --- a/vendor/bytelyst/referral-client/src/client.test.ts +++ /dev/null @@ -1,224 +0,0 @@ -import { describe, it, expect, vi, afterEach } from 'vitest'; -import { createReferralClient } from './client.js'; -import type { ReferralDoc } from './types.js'; - -const baseConfig = { - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - getAccessToken: () => 'test-token', -}; - -function mockReferral(overrides?: Partial): ReferralDoc { - return { - id: 'ref-1', - productId: 'testapp', - referrerId: 'user-1', - referrerEmail: 'alice@test.com', - referredUserId: null, - referredEmail: 'bob@test.com', - status: 'pending', - referrerRewardTokens: 1000, - referredRewardTokens: 500, - referrerRewarded: false, - referredRewarded: false, - createdAt: '2026-01-01T00:00:00Z', - completedAt: null, - ...overrides, - }; -} - -describe('createReferralClient', () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('should create a referral', async () => { - const ref = mockReferral(); - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve(ref), - }) - ); - - const client = createReferralClient(baseConfig); - const result = await client.createReferral({ - referrerId: 'user-1', - referrerEmail: 'alice@test.com', - referredEmail: 'bob@test.com', - }); - - expect(result).toEqual(ref); - const fetchMock = globalThis.fetch as ReturnType; - expect(fetchMock).toHaveBeenCalledWith( - 'http://localhost:4003/api/referrals', - expect.objectContaining({ method: 'POST' }) - ); - }); - - it('should list referrals by referrerId', async () => { - const data = { referrals: [mockReferral()], count: 1 }; - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve(data), - }) - ); - - const client = createReferralClient(baseConfig); - const result = await client.listMyReferrals('user-1'); - - expect(result.count).toBe(1); - expect(result.referrals).toHaveLength(1); - const fetchMock = globalThis.fetch as ReturnType; - expect(fetchMock).toHaveBeenCalledWith( - 'http://localhost:4003/api/referrals/by-referrer/user-1', - expect.any(Object) - ); - }); - - it('should get referral stats', async () => { - const stats = { total: 10, completed: 5, rewarded: 3 }; - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve(stats), - }) - ); - - const client = createReferralClient(baseConfig); - const result = await client.getReferralStats(); - expect(result).toEqual(stats); - }); - - it('should update referral status via PUT', async () => { - const updated = mockReferral({ status: 'signed_up' }); - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve(updated), - }) - ); - - const client = createReferralClient(baseConfig); - const result = await client.updateReferralStatus('ref-1', 'user-1', 'signed_up'); - - expect(result.status).toBe('signed_up'); - const fetchMock = globalThis.fetch as ReturnType; - expect(fetchMock).toHaveBeenCalledWith( - 'http://localhost:4003/api/referrals/ref-1', - expect.objectContaining({ method: 'PUT' }) - ); - }); - - it('should get referral by email', async () => { - const ref = mockReferral(); - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve(ref), - }) - ); - - const client = createReferralClient(baseConfig); - const result = await client.getByEmail('bob@test.com'); - expect(result).toEqual(ref); - }); - - it('should return null for 404 on getByEmail', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: false, - status: 404, - }) - ); - - const client = createReferralClient(baseConfig); - const result = await client.getByEmail('unknown@test.com'); - expect(result).toBeNull(); - }); - - it('should send correct headers', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ total: 0, completed: 0, rewarded: 0 }), - }) - ); - - const client = createReferralClient(baseConfig); - await client.getReferralStats(); - - const fetchMock = globalThis.fetch as ReturnType; - const callHeaders = fetchMock.mock.calls[0][1].headers as Record; - expect(callHeaders['x-product-id']).toBe('testapp'); - expect(callHeaders['Authorization']).toBe('Bearer test-token'); - expect(callHeaders['x-request-id']).toBeDefined(); - }); - - it('should throw on non-ok response', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: false, - status: 500, - }) - ); - - const client = createReferralClient(baseConfig); - await expect(client.getReferralStats()).rejects.toThrow('getReferralStats failed: 500'); - }); - - it('should build share link', () => { - const client = createReferralClient(baseConfig); - const link = client.buildShareLink('ABC123'); - expect(link).toContain('refer/ABC123'); - expect(link).toContain('product=testapp'); - }); - - it('should build share message', () => { - const client = createReferralClient(baseConfig); - const msg = client.buildShareMessage('ABC123', 'NomGap'); - expect(msg).toContain('NomGap'); - expect(msg).toContain('refer/ABC123'); - }); - - it('should calculate earned days', () => { - const client = createReferralClient(baseConfig); - expect(client.calculateEarnedDays(3)).toBe(21); - expect(client.calculateEarnedDays(0)).toBe(0); - expect(client.calculateEarnedDays(2, 14)).toBe(28); - }); - - it('should use custom default reward tokens', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockReferral()), - }) - ); - - const client = createReferralClient({ - ...baseConfig, - defaultRewardTokens: { referrer: 2000, referred: 1000 }, - }); - await client.createReferral({ - referrerId: 'user-1', - referrerEmail: 'alice@test.com', - referredEmail: 'bob@test.com', - }); - - const fetchMock = globalThis.fetch as ReturnType; - const body = JSON.parse(fetchMock.mock.calls[0][1].body as string); - expect(body.referrerRewardTokens).toBe(2000); - expect(body.referredRewardTokens).toBe(1000); - }); -}); diff --git a/vendor/bytelyst/referral-client/src/client.ts b/vendor/bytelyst/referral-client/src/client.ts deleted file mode 100644 index 3dac261..0000000 --- a/vendor/bytelyst/referral-client/src/client.ts +++ /dev/null @@ -1,122 +0,0 @@ -/** - * Browser/React Native-safe referral client for platform-service. - * - * Wraps platform-service /referrals/* endpoints. - * No Node.js dependencies — uses globalThis.fetch. - */ - -import type { ReferralClient, ReferralClientConfig, ReferralDoc } from './types.js'; - -function generateRequestId(): string { - return typeof globalThis.crypto?.randomUUID === 'function' - ? globalThis.crypto.randomUUID() - : `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; -} - -export function createReferralClient(config: ReferralClientConfig): ReferralClient { - const { baseUrl, productId, getAccessToken, defaultRewardTokens } = config; - - const defaultReferrerTokens = defaultRewardTokens?.referrer ?? 1000; - const defaultReferredTokens = defaultRewardTokens?.referred ?? 500; - - function headers(): Record { - const h: Record = { - 'Content-Type': 'application/json', - 'x-product-id': productId, - 'x-request-id': generateRequestId(), - }; - const token = getAccessToken(); - if (token) h['Authorization'] = `Bearer ${token}`; - return h; - } - - async function listMyReferrals( - referrerId: string - ): Promise<{ referrals: ReferralDoc[]; count: number }> { - const res = await globalThis.fetch( - `${baseUrl}/referrals/by-referrer/${encodeURIComponent(referrerId)}`, - { headers: headers() } - ); - if (!res.ok) throw new Error(`listMyReferrals failed: ${res.status}`); - const data = (await res.json()) as { referrals: ReferralDoc[]; count: number }; - return data; - } - - async function getReferralStats(): Promise<{ - total: number; - completed: number; - rewarded: number; - }> { - const res = await globalThis.fetch(`${baseUrl}/referrals/stats`, { headers: headers() }); - if (!res.ok) throw new Error(`getReferralStats failed: ${res.status}`); - return (await res.json()) as { total: number; completed: number; rewarded: number }; - } - - async function createReferral(input: { - referrerId: string; - referrerEmail: string; - referredEmail: string; - }): Promise { - const body = { - ...input, - productId, - referrerRewardTokens: defaultReferrerTokens, - referredRewardTokens: defaultReferredTokens, - }; - const res = await globalThis.fetch(`${baseUrl}/referrals`, { - method: 'POST', - headers: headers(), - body: JSON.stringify(body), - }); - if (!res.ok) throw new Error(`createReferral failed: ${res.status}`); - return (await res.json()) as ReferralDoc; - } - - async function updateReferralStatus( - id: string, - referrerId: string, - status: ReferralDoc['status'] - ): Promise { - const res = await globalThis.fetch(`${baseUrl}/referrals/${encodeURIComponent(id)}`, { - method: 'PUT', - headers: headers(), - body: JSON.stringify({ referrerId, status }), - }); - if (!res.ok) throw new Error(`updateReferralStatus failed: ${res.status}`); - return (await res.json()) as ReferralDoc; - } - - async function getByEmail(email: string): Promise { - const res = await globalThis.fetch( - `${baseUrl}/referrals/by-email/${encodeURIComponent(email)}`, - { headers: headers() } - ); - if (res.status === 404) return null; - if (!res.ok) throw new Error(`getByEmail failed: ${res.status}`); - return (await res.json()) as ReferralDoc; - } - - function buildShareLink(code: string): string { - return `https://bytelyst.com/refer/${encodeURIComponent(code)}?product=${encodeURIComponent(productId)}`; - } - - function buildShareMessage(code: string, productName: string): string { - const link = buildShareLink(code); - return `Try ${productName}! Use my referral link to get started: ${link}`; - } - - function calculateEarnedDays(conversions: number, daysPerReferral = 7): number { - return Math.max(0, conversions * daysPerReferral); - } - - return { - listMyReferrals, - getReferralStats, - createReferral, - updateReferralStatus, - getByEmail, - buildShareLink, - buildShareMessage, - calculateEarnedDays, - }; -} diff --git a/vendor/bytelyst/referral-client/src/index.ts b/vendor/bytelyst/referral-client/src/index.ts deleted file mode 100644 index d021d6e..0000000 --- a/vendor/bytelyst/referral-client/src/index.ts +++ /dev/null @@ -1,72 +0,0 @@ -export interface ReferralClientOptions { - baseUrl: string; - productId: string; - getAccessToken: () => string; -} - -export interface ReferralStats { - totalReferrals: number; - successfulReferrals: number; - pendingReferrals: number; - rewardsEarned: number; - referralCode: string; -} - -export interface ReferralInfo { - code: string; - referrerEmail: string; - status: string; -} - -function joinUrl(base: string, path: string): string { - const b = base.replace(/\/$/, ""); - const p = path.startsWith("/") ? path : `/${path}`; - return `${b}${p}`; -} - -function headers(opts: ReferralClientOptions): HeadersInit { - return { - Authorization: `Bearer ${opts.getAccessToken()}`, - "X-Product-Id": opts.productId, - Accept: "application/json", - }; -} - -async function parseJson(res: Response): Promise { - if (!res.ok) { - const text = await res.text(); - throw new Error(`HTTP ${res.status}: ${text || res.statusText}`); - } - return res.json() as Promise; -} - -export function createReferralClient(opts: ReferralClientOptions) { - const { baseUrl } = opts; - - return { - async getReferralStats(): Promise { - const res = await fetch(joinUrl(baseUrl, "/referrals/stats"), { - method: "GET", - headers: headers(opts), - }); - return parseJson(res); - }, - - buildShareLink(code: string): string { - const b = baseUrl.replace(/\/$/, ""); - return `${b}/r/${code}`; - }, - - buildShareMessage(code: string, productName: string): string { - return `Try ${productName}! Use my referral code: ${code}`; - }, - - async getByEmail(code: string): Promise { - const res = await fetch( - joinUrl(baseUrl, `/referrals/${encodeURIComponent(code)}`), - { method: "GET", headers: headers(opts) } - ); - return parseJson(res); - }, - }; -} diff --git a/vendor/bytelyst/referral-client/src/types.ts b/vendor/bytelyst/referral-client/src/types.ts deleted file mode 100644 index 5067449..0000000 --- a/vendor/bytelyst/referral-client/src/types.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * Types for @bytelyst/referral-client. - * Browser/React Native-safe — no Node.js dependencies. - */ - -export interface ReferralDoc { - id: string; - productId: string; - referrerId: string; - referrerEmail: string; - referredUserId: string | null; - referredEmail: string; - status: 'pending' | 'signed_up' | 'subscribed' | 'rewarded'; - referrerRewardTokens: number; - referredRewardTokens: number; - referrerRewarded: boolean; - referredRewarded: boolean; - createdAt: string; - completedAt: string | null; -} - -export interface ReferralClientConfig { - /** Platform-service base URL (e.g. "http://localhost:4003/api"). */ - baseUrl: string; - - /** Product identifier sent as x-product-id header on every request. */ - productId: string; - - /** Returns a JWT access token, or null if not authenticated. */ - getAccessToken: () => string | null; - - /** Default reward tokens applied when creating referrals. */ - defaultRewardTokens?: { referrer: number; referred: number }; -} - -export interface ReferralClient { - listMyReferrals(referrerId: string): Promise<{ referrals: ReferralDoc[]; count: number }>; - getReferralStats(): Promise<{ total: number; completed: number; rewarded: number }>; - createReferral(input: { - referrerId: string; - referrerEmail: string; - referredEmail: string; - }): Promise; - updateReferralStatus( - id: string, - referrerId: string, - status: ReferralDoc['status'] - ): Promise; - getByEmail(email: string): Promise; - - // Client-side helpers (pure TS, no network) - buildShareLink(code: string): string; - buildShareMessage(code: string, productName: string): string; - calculateEarnedDays(conversions: number, daysPerReferral?: number): number; -} diff --git a/vendor/bytelyst/referral-client/tsconfig.json b/vendor/bytelyst/referral-client/tsconfig.json deleted file mode 100644 index 8c5e8c2..0000000 --- a/vendor/bytelyst/referral-client/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "outDir": "dist", - "rootDir": "src", - "declaration": true, - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true - }, - "include": ["src"] -} diff --git a/vendor/bytelyst/secure-storage-web/package.json b/vendor/bytelyst/secure-storage-web/package.json deleted file mode 100644 index 39a42bd..0000000 --- a/vendor/bytelyst/secure-storage-web/package.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "name": "@bytelyst/secure-storage-web", - "version": "0.1.5", - "type": "module", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks" - }, - "devDependencies": { - "vitest": "^3.0.0", - "fake-indexeddb": "^6.0.0" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/secure-storage-web/src/index.ts b/vendor/bytelyst/secure-storage-web/src/index.ts deleted file mode 100644 index 15919c3..0000000 --- a/vendor/bytelyst/secure-storage-web/src/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * @bytelyst/secure-storage-web - * - * Encrypted key-value storage for web applications. - * Uses IndexedDB + Web Crypto (non-extractable AES-256-GCM key). - * Falls back to localStorage when SubtleCrypto is unavailable. - * - * @example - * ```typescript - * import { SecureStorage } from '@bytelyst/secure-storage-web'; - * - * const storage = new SecureStorage('com.myapp'); - * await storage.set('auth_token', 'eyJhbGci...'); - * const token = await storage.get('auth_token'); - * await storage.delete('auth_token'); - * ``` - */ - -export { SecureStorage } from './secure-storage.js'; diff --git a/vendor/bytelyst/secure-storage-web/src/secure-storage.test.ts b/vendor/bytelyst/secure-storage-web/src/secure-storage.test.ts deleted file mode 100644 index 99e2c80..0000000 --- a/vendor/bytelyst/secure-storage-web/src/secure-storage.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import 'fake-indexeddb/auto'; -import { SecureStorage } from './secure-storage.js'; - -describe('SecureStorage', () => { - let storage: SecureStorage; - - beforeEach(async () => { - storage = new SecureStorage('com.test.app'); - await storage.clear(); - }); - - // ── Basic CRUD ────────────────────────────────── - - it('set and get', async () => { - await storage.set('token', 'abc123'); - const val = await storage.get('token'); - expect(val).toBe('abc123'); - }); - - it('get returns null for missing key', async () => { - const val = await storage.get('nonexistent'); - expect(val).toBeNull(); - }); - - it('overwrite existing key', async () => { - await storage.set('token', 'first'); - await storage.set('token', 'second'); - const val = await storage.get('token'); - expect(val).toBe('second'); - }); - - it('delete removes value', async () => { - await storage.set('token', 'abc'); - await storage.delete('token'); - const val = await storage.get('token'); - expect(val).toBeNull(); - }); - - it('delete non-existent key does not throw', async () => { - await expect(storage.delete('nope')).resolves.toBeUndefined(); - }); - - // ── Clear ─────────────────────────────────────── - - it('clear removes all values', async () => { - await storage.set('a', '1'); - await storage.set('b', '2'); - await storage.clear(); - expect(await storage.get('a')).toBeNull(); - expect(await storage.get('b')).toBeNull(); - }); - - // ── Has ───────────────────────────────────────── - - it('has returns true for existing key', async () => { - await storage.set('x', 'val'); - expect(await storage.has('x')).toBe(true); - }); - - it('has returns false for missing key', async () => { - expect(await storage.has('missing')).toBe(false); - }); - - // ── Keys ──────────────────────────────────────── - - it('keys lists stored keys', async () => { - await storage.set('alpha', '1'); - await storage.set('beta', '2'); - const keys = await storage.keys(); - expect(keys.sort()).toEqual(['alpha', 'beta']); - }); - - it('keys returns empty for fresh storage', async () => { - const keys = await storage.keys(); - expect(keys).toEqual([]); - }); - - // ── Namespace isolation ───────────────────────── - - it('different namespaces are isolated', async () => { - const s1 = new SecureStorage('ns1'); - const s2 = new SecureStorage('ns2'); - await s1.set('key', 'from-s1'); - await s2.set('key', 'from-s2'); - expect(await s1.get('key')).toBe('from-s1'); - expect(await s2.get('key')).toBe('from-s2'); - }); - - // ── Data types ────────────────────────────────── - - it('empty string', async () => { - await storage.set('empty', ''); - const val = await storage.get('empty'); - expect(val).toBe(''); - }); - - it('unicode values', async () => { - const text = 'こんにちは世界 🌍 مرحبا Ñoño'; - await storage.set('unicode', text); - expect(await storage.get('unicode')).toBe(text); - }); - - it('large value', async () => { - const big = 'X'.repeat(100_000); - await storage.set('big', big); - expect(await storage.get('big')).toBe(big); - }); - - it('JSON string value', async () => { - const json = JSON.stringify({ token: 'abc', user: { id: 1 } }); - await storage.set('session', json); - const parsed = JSON.parse((await storage.get('session'))!); - expect(parsed.token).toBe('abc'); - expect(parsed.user.id).toBe(1); - }); - - // ── isSupported ───────────────────────────────── - - it('isSupported returns true in test environment', () => { - expect(SecureStorage.isSupported()).toBe(true); - }); -}); diff --git a/vendor/bytelyst/secure-storage-web/src/secure-storage.ts b/vendor/bytelyst/secure-storage-web/src/secure-storage.ts deleted file mode 100644 index 99832ac..0000000 --- a/vendor/bytelyst/secure-storage-web/src/secure-storage.ts +++ /dev/null @@ -1,259 +0,0 @@ -/** - * @bytelyst/secure-storage-web — Encrypted web storage - * - * IndexedDB-backed key-value storage with a non-extractable AES-256-GCM - * CryptoKey managed by Web Crypto. The encryption key never leaves the - * browser's crypto subsystem. - * - * Falls back to localStorage if IndexedDB or SubtleCrypto is unavailable. - */ - -const DB_NAME = 'bytelyst_secure_storage'; -const DB_VERSION = 1; -const KEY_STORE = 'crypto_keys'; -const DATA_STORE = 'encrypted_data'; -const MASTER_KEY_ID = '__master_key__'; -const ALGORITHM = 'AES-GCM'; -const KEY_SIZE = 256; -const IV_BYTES = 12; -const TAG_BITS = 128; - -// ── IndexedDB helpers ─────────────────────────────── - -function openDb(): Promise { - return new Promise((resolve, reject) => { - const request = indexedDB.open(DB_NAME, DB_VERSION); - request.onupgradeneeded = () => { - const db = request.result; - if (!db.objectStoreNames.contains(KEY_STORE)) { - db.createObjectStore(KEY_STORE); - } - if (!db.objectStoreNames.contains(DATA_STORE)) { - db.createObjectStore(DATA_STORE); - } - }; - request.onsuccess = () => resolve(request.result); - request.onerror = () => reject(request.error); - }); -} - -function idbGet(db: IDBDatabase, store: string, key: string): Promise { - return new Promise((resolve, reject) => { - const tx = db.transaction(store, 'readonly'); - const req = tx.objectStore(store).get(key); - req.onsuccess = () => resolve(req.result as T | undefined); - req.onerror = () => reject(req.error); - }); -} - -function idbPut(db: IDBDatabase, store: string, key: string, value: unknown): Promise { - return new Promise((resolve, reject) => { - const tx = db.transaction(store, 'readwrite'); - const req = tx.objectStore(store).put(value, key); - req.onsuccess = () => resolve(); - req.onerror = () => reject(req.error); - }); -} - -function idbDelete(db: IDBDatabase, store: string, key: string): Promise { - return new Promise((resolve, reject) => { - const tx = db.transaction(store, 'readwrite'); - const req = tx.objectStore(store).delete(key); - req.onsuccess = () => resolve(); - req.onerror = () => reject(req.error); - }); -} - -function idbClear(db: IDBDatabase, store: string): Promise { - return new Promise((resolve, reject) => { - const tx = db.transaction(store, 'readwrite'); - const req = tx.objectStore(store).clear(); - req.onsuccess = () => resolve(); - req.onerror = () => reject(req.error); - }); -} - -function idbGetAllKeys(db: IDBDatabase, store: string): Promise { - return new Promise((resolve, reject) => { - const tx = db.transaction(store, 'readonly'); - const req = tx.objectStore(store).getAllKeys(); - req.onsuccess = () => resolve(req.result as string[]); - req.onerror = () => reject(req.error); - }); -} - -// ── Stored encrypted value format ─────────────────── - -interface StoredValue { - /** Ciphertext bytes. */ - ct: ArrayBuffer; - /** Initialization vector. */ - iv: ArrayBuffer; -} - -// ── SecureStorage class ───────────────────────────── - -/** - * Encrypted key-value storage backed by IndexedDB + Web Crypto. - * - * The AES-256-GCM master key is generated once and stored as a - * **non-extractable** CryptoKey in IndexedDB. Values are encrypted - * before storage and decrypted on retrieval. The raw key material - * never leaves the browser's crypto subsystem. - * - * Falls back to plaintext localStorage if IndexedDB or SubtleCrypto - * is not available (e.g., legacy browsers, server-side rendering). - * - * @example - * ```typescript - * const storage = new SecureStorage('com.myapp'); - * await storage.set('auth_token', 'eyJhbGci...'); - * const token = await storage.get('auth_token'); - * await storage.delete('auth_token'); - * await storage.clear(); - * ``` - */ -export class SecureStorage { - private readonly namespace: string; - private db: IDBDatabase | null = null; - private masterKey: CryptoKey | null = null; - private readonly fallback: boolean; - - constructor(namespace: string) { - this.namespace = namespace; - this.fallback = !SecureStorage.isSupported(); - } - - /** Check if IndexedDB + SubtleCrypto are available. */ - static isSupported(): boolean { - return ( - typeof indexedDB !== 'undefined' && - typeof globalThis !== 'undefined' && - !!globalThis.crypto?.subtle - ); - } - - // ── Public API ────────────────────────────────── - - /** Store an encrypted value. */ - async set(key: string, value: string): Promise { - if (this.fallback) { - localStorage.setItem(this.ns(key), value); - return; - } - const { db, masterKey } = await this.ensureReady(); - const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES)); - const encoded = new TextEncoder().encode(value); - - const ciphertext = await crypto.subtle.encrypt( - { name: ALGORITHM, iv: iv.buffer as ArrayBuffer, tagLength: TAG_BITS }, - masterKey, - encoded.buffer as ArrayBuffer - ); - - const stored: StoredValue = { ct: ciphertext, iv: iv.buffer as ArrayBuffer }; - await idbPut(db, DATA_STORE, this.ns(key), stored); - } - - /** Retrieve and decrypt a value. Returns `null` if not found. */ - async get(key: string): Promise { - if (this.fallback) { - return localStorage.getItem(this.ns(key)); - } - const { db, masterKey } = await this.ensureReady(); - const stored = await idbGet(db, DATA_STORE, this.ns(key)); - if (!stored) return null; - - const plaintext = await crypto.subtle.decrypt( - { name: ALGORITHM, iv: stored.iv, tagLength: TAG_BITS }, - masterKey, - stored.ct - ); - - return new TextDecoder().decode(plaintext); - } - - /** Delete a stored value. */ - async delete(key: string): Promise { - if (this.fallback) { - localStorage.removeItem(this.ns(key)); - return; - } - const { db } = await this.ensureReady(); - await idbDelete(db, DATA_STORE, this.ns(key)); - } - - /** Clear all stored values (keeps the master key). */ - async clear(): Promise { - if (this.fallback) { - const prefix = this.ns(''); - const toRemove: string[] = []; - for (let i = 0; i < localStorage.length; i++) { - const k = localStorage.key(i); - if (k && k.startsWith(prefix)) toRemove.push(k); - } - toRemove.forEach(k => localStorage.removeItem(k)); - return; - } - const { db } = await this.ensureReady(); - await idbClear(db, DATA_STORE); - } - - /** Check if a key exists. */ - async has(key: string): Promise { - if (this.fallback) { - return localStorage.getItem(this.ns(key)) !== null; - } - const { db } = await this.ensureReady(); - const stored = await idbGet(db, DATA_STORE, this.ns(key)); - return stored !== undefined; - } - - /** List all stored keys (without namespace prefix). */ - async keys(): Promise { - if (this.fallback) { - const prefix = this.ns(''); - const result: string[] = []; - for (let i = 0; i < localStorage.length; i++) { - const k = localStorage.key(i); - if (k && k.startsWith(prefix)) result.push(k.slice(prefix.length)); - } - return result; - } - const { db } = await this.ensureReady(); - const allKeys = await idbGetAllKeys(db, DATA_STORE); - const prefix = this.ns(''); - return allKeys.filter(k => k.startsWith(prefix)).map(k => k.slice(prefix.length)); - } - - // ── Internal ──────────────────────────────────── - - private ns(key: string): string { - return `${this.namespace}:${key}`; - } - - private async ensureReady(): Promise<{ db: IDBDatabase; masterKey: CryptoKey }> { - if (this.db && this.masterKey) { - return { db: this.db, masterKey: this.masterKey }; - } - - const db = await openDb(); - this.db = db; - - // Try to load existing master key - let masterKey = await idbGet(db, KEY_STORE, MASTER_KEY_ID); - - if (!masterKey) { - // Generate a new non-extractable master key - masterKey = await crypto.subtle.generateKey( - { name: ALGORITHM, length: KEY_SIZE }, - false, // non-extractable — key material never leaves crypto subsystem - ['encrypt', 'decrypt'] - ); - await idbPut(db, KEY_STORE, MASTER_KEY_ID, masterKey); - } - - this.masterKey = masterKey; - return { db, masterKey }; - } -} diff --git a/vendor/bytelyst/secure-storage-web/tsconfig.json b/vendor/bytelyst/secure-storage-web/tsconfig.json deleted file mode 100644 index 318c075..0000000 --- a/vendor/bytelyst/secure-storage-web/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src", - "lib": ["ES2022", "DOM"] - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/vendor/bytelyst/speech/package.json b/vendor/bytelyst/speech/package.json deleted file mode 100644 index f4e405e..0000000 --- a/vendor/bytelyst/speech/package.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "name": "@bytelyst/speech", - "version": "0.1.5", - "description": "Cloud-agnostic speech-to-text abstraction for the ByteLyst ecosystem", - "type": "module", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" - } - }, - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks" - }, - "peerDependencies": {}, - "devDependencies": { - "typescript": "^5.7.0", - "vitest": "^3.0.0" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/speech/src/__tests__/speech.test.ts b/vendor/bytelyst/speech/src/__tests__/speech.test.ts deleted file mode 100644 index b3b8569..0000000 --- a/vendor/bytelyst/speech/src/__tests__/speech.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { createSpeechTranscriber, MockSpeechTranscriber } from '../index.js'; -import type { SpeechTranscriber } from '../index.js'; - -describe('@bytelyst/speech', () => { - describe('MockSpeechTranscriber', () => { - it('implements SpeechTranscriber interface', () => { - const transcriber: SpeechTranscriber = new MockSpeechTranscriber(); - expect(transcriber.isActive).toBe(false); - }); - - it('start/stop lifecycle works', () => { - const transcriber = new MockSpeechTranscriber(); - expect(transcriber.isActive).toBe(false); - - transcriber.start(); - expect(transcriber.isActive).toBe(true); - - const result = transcriber.stop(); - expect(transcriber.isActive).toBe(false); - expect(result).toBe('Hello, this is a mock transcript.'); - }); - - it('returns configurable mock transcript', () => { - const transcriber = new MockSpeechTranscriber(); - transcriber.mockTranscript = 'Custom transcript'; - transcriber.mockConfidence = 0.8; - - transcriber.start(); - const result = transcriber.stop(); - expect(result).toBe('Custom transcript'); - }); - - it('calls onFinal callback on stop', () => { - const transcriber = new MockSpeechTranscriber(); - let receivedText = ''; - let receivedConfidence = 0; - - transcriber.onFinal((text, confidence) => { - receivedText = text; - receivedConfidence = confidence; - }); - - transcriber.start(); - transcriber.stop(); - - expect(receivedText).toBe('Hello, this is a mock transcript.'); - expect(receivedConfidence).toBe(0.95); - }); - - it('calls onPartial callback on pushAudio', () => { - const transcriber = new MockSpeechTranscriber(); - let partialText = ''; - - transcriber.onPartial(text => { - partialText = text; - }); - - transcriber.start(); - transcriber.pushAudio(new Uint8Array(100)); - expect(partialText).toBe('[Recording...]'); - }); - - it('ignores pushAudio when not active', () => { - const transcriber = new MockSpeechTranscriber(); - let called = false; - transcriber.onPartial(() => { - called = true; - }); - - transcriber.pushAudio(new Uint8Array(100)); - expect(called).toBe(false); - }); - - it('setVocabulary stores phrases', () => { - const transcriber = new MockSpeechTranscriber(); - transcriber.setVocabulary(['hello', 'world']); - expect(transcriber.getVocabulary()).toEqual(['hello', 'world']); - }); - - it('simulateError calls onError callback', () => { - const transcriber = new MockSpeechTranscriber(); - let errorMsg = ''; - transcriber.onError(err => { - errorMsg = err.message; - }); - - transcriber.simulateError('connection lost'); - expect(errorMsg).toBe('connection lost'); - }); - }); - - describe('createSpeechTranscriber', () => { - it('creates mock transcriber by default', () => { - const transcriber = createSpeechTranscriber({ provider: 'mock' }); - expect(transcriber).toBeInstanceOf(MockSpeechTranscriber); - }); - - it('throws for azure provider (requires native implementation)', () => { - expect(() => createSpeechTranscriber({ provider: 'azure' })).toThrow( - 'platform-specific implementation' - ); - }); - - it('throws for whisper provider (requires native implementation)', () => { - expect(() => createSpeechTranscriber({ provider: 'whisper' })).toThrow( - 'platform-specific implementation' - ); - }); - - it('throws for unknown provider', () => { - expect(() => createSpeechTranscriber({ provider: 'nonexistent' as 'azure' })).toThrow( - 'Unknown speech provider' - ); - }); - }); -}); diff --git a/vendor/bytelyst/speech/src/factory.ts b/vendor/bytelyst/speech/src/factory.ts deleted file mode 100644 index 04dcbe4..0000000 --- a/vendor/bytelyst/speech/src/factory.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * Factory function for creating speech transcribers. - * - * Auto-detects provider from SPEECH_PROVIDER env var, or falls back - * to 'azure' if AZURE_SPEECH_KEY is set, else 'mock'. - */ - -import type { SpeechConfig, SpeechTranscriber } from './types.js'; -import { MockSpeechTranscriber } from './providers/mock.js'; - -/** - * Create a speech transcriber based on config or env vars. - * - * For Azure and Whisper providers, consumers must provide their own - * platform-specific implementations (Python azure_stt.py / whisper_stt.py, - * Swift AzureSpeechTranscriber, etc.). This factory handles the mock - * provider for testing and serves as the registry point for future - * TS-native providers. - */ -export function createSpeechTranscriber(config?: Partial): SpeechTranscriber { - const provider = config?.provider ?? detectProvider(); - - switch (provider) { - case 'mock': - return new MockSpeechTranscriber(); - case 'azure': - case 'whisper': - case 'google': - case 'deepgram': - throw new Error( - `Speech provider '${provider}' requires a platform-specific implementation. ` + - `Use the Python SpeechTranscriber ABC (src/audio/speech_types.py) or ` + - `Swift SpeechTranscriberProtocol for native providers.` - ); - default: - throw new Error(`Unknown speech provider: ${provider}`); - } -} - -function detectProvider(): SpeechConfig['provider'] { - const explicit = (process.env.SPEECH_PROVIDER || '').toLowerCase(); - if ( - explicit === 'azure' || - explicit === 'whisper' || - explicit === 'google' || - explicit === 'deepgram' || - explicit === 'mock' - ) { - return explicit; - } - return 'mock'; -} - -export { MockSpeechTranscriber } from './providers/mock.js'; diff --git a/vendor/bytelyst/speech/src/index.ts b/vendor/bytelyst/speech/src/index.ts deleted file mode 100644 index dd38008..0000000 --- a/vendor/bytelyst/speech/src/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -export type { - SpeechTranscriber, - SpeechConfig, - TranscriptionResult, - PartialCallback, - FinalCallback, - ErrorCallback, -} from './types.js'; - -export { createSpeechTranscriber, MockSpeechTranscriber } from './factory.js'; diff --git a/vendor/bytelyst/speech/src/providers/mock.ts b/vendor/bytelyst/speech/src/providers/mock.ts deleted file mode 100644 index a730f88..0000000 --- a/vendor/bytelyst/speech/src/providers/mock.ts +++ /dev/null @@ -1,70 +0,0 @@ -/** - * Mock speech transcriber for testing. - * - * Returns configurable responses without requiring any speech SDK. - */ - -import type { ErrorCallback, FinalCallback, PartialCallback, SpeechTranscriber } from '../types.js'; - -export class MockSpeechTranscriber implements SpeechTranscriber { - private _isActive = false; - private _partialCb: PartialCallback | null = null; - private _finalCb: FinalCallback | null = null; - private _errorCb: ErrorCallback | null = null; - private _vocabulary: string[] = []; - - /** Configurable mock response. */ - public mockTranscript = 'Hello, this is a mock transcript.'; - public mockConfidence = 0.95; - - get isActive(): boolean { - return this._isActive; - } - - start(): void { - this._isActive = true; - } - - stop(): string { - this._isActive = false; - if (this._finalCb) { - this._finalCb(this.mockTranscript, this.mockConfidence); - } - return this.mockTranscript; - } - - pushAudio(_data: ArrayBuffer | Uint8Array): void { - if (!this._isActive) return; - if (this._partialCb) { - this._partialCb('[Recording...]'); - } - } - - onPartial(callback: PartialCallback): void { - this._partialCb = callback; - } - - onFinal(callback: FinalCallback): void { - this._finalCb = callback; - } - - onError(callback: ErrorCallback): void { - this._errorCb = callback; - } - - setVocabulary(phrases: string[]): void { - this._vocabulary = phrases; - } - - /** Simulate an error (for testing). */ - simulateError(message: string): void { - if (this._errorCb) { - this._errorCb(new Error(message)); - } - } - - /** Get the configured vocabulary (for test assertions). */ - getVocabulary(): string[] { - return this._vocabulary; - } -} diff --git a/vendor/bytelyst/speech/src/types.ts b/vendor/bytelyst/speech/src/types.ts deleted file mode 100644 index 4dd72b6..0000000 --- a/vendor/bytelyst/speech/src/types.ts +++ /dev/null @@ -1,84 +0,0 @@ -/** - * Cloud-agnostic speech-to-text abstraction. - * - * Providers implement SpeechTranscriber to wrap platform-specific SDKs - * (Azure Speech, Google Cloud Speech, Deepgram, local Whisper, etc.) - * behind a unified push-audio + callback interface. - */ - -/** Callback for partial (interim) transcription results. */ -export type PartialCallback = (text: string) => void; - -/** Callback for final (committed) transcription results. */ -export type FinalCallback = (text: string, confidence: number) => void; - -/** Callback for transcription errors. */ -export type ErrorCallback = (error: Error) => void; - -/** - * Cloud-agnostic streaming speech-to-text interface. - * - * Audio is pushed in chunks (PCM 16-bit, 16kHz, mono by convention). - * Results are delivered asynchronously via callbacks. - */ -export interface SpeechTranscriber { - /** Start continuous recognition for the given language. */ - start(language?: string): Promise | void; - - /** Stop recognition and finalize any pending results. */ - stop(): Promise | string; - - /** Push raw audio data (PCM 16-bit, 16kHz, mono). */ - pushAudio(data: ArrayBuffer | Uint8Array): void; - - /** Register callback for partial (interim) results. */ - onPartial(callback: PartialCallback): void; - - /** Register callback for final (committed) results. */ - onFinal(callback: FinalCallback): void; - - /** Register callback for errors. */ - onError(callback: ErrorCallback): void; - - /** Set custom vocabulary / phrase hints for better accuracy. */ - setVocabulary?(phrases: string[]): void; - - /** Whether the transcriber is currently active. */ - readonly isActive: boolean; -} - -/** Configuration for creating a speech transcriber. */ -export interface SpeechConfig { - /** Provider type. */ - provider: 'azure' | 'whisper' | 'google' | 'deepgram' | 'mock'; - - /** Azure-specific: speech service key. */ - speechKey?: string; - - /** Azure-specific: speech service region. */ - speechRegion?: string; - - /** Whisper-specific: model size (tiny, base, small, medium, large). */ - whisperModelSize?: string; - - /** Default language (BCP-47 code, e.g. 'en-US'). */ - language?: string; - - /** Custom vocabulary phrases for better accuracy. */ - vocabulary?: string[]; -} - -/** Result of a completed transcription session. */ -export interface TranscriptionResult { - /** Full transcribed text. */ - text: string; - - /** Confidence score (0-1), if available. */ - confidence?: number; - - /** Duration of audio in seconds. */ - durationSeconds?: number; - - /** Which provider produced this result. */ - provider: string; -} diff --git a/vendor/bytelyst/speech/tsconfig.json b/vendor/bytelyst/speech/tsconfig.json deleted file mode 100644 index 81f2cd1..0000000 --- a/vendor/bytelyst/speech/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src"], - "exclude": ["dist", "src/**/*.test.ts"] -} diff --git a/vendor/bytelyst/storage/package.json b/vendor/bytelyst/storage/package.json deleted file mode 100644 index 4e46626..0000000 --- a/vendor/bytelyst/storage/package.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "name": "@bytelyst/storage", - "version": "0.1.5", - "type": "module", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - }, - "./testing": { - "import": "./dist/testing.js", - "types": "./dist/testing.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks" - }, - "dependencies": { - "@azure/storage-blob": ">=12.0.0" - }, - "devDependencies": { - "vitest": "^3.0.0" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/storage/src/__tests__/memory-storage.test.ts b/vendor/bytelyst/storage/src/__tests__/memory-storage.test.ts deleted file mode 100644 index 56f0c45..0000000 --- a/vendor/bytelyst/storage/src/__tests__/memory-storage.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -/** - * Tests for MemoryStorageProvider. - */ - -import { describe, it, expect, beforeEach } from 'vitest'; -import { MemoryStorageProvider } from '../providers/memory.js'; -import type { StorageBucket } from '../types.js'; - -describe('MemoryStorageProvider', () => { - let provider: MemoryStorageProvider; - let bucket: StorageBucket; - - beforeEach(() => { - provider = new MemoryStorageProvider(); - bucket = provider.getBucket('test-bucket'); - }); - - it('isHealthy returns true', async () => { - expect(await provider.isHealthy()).toBe(true); - }); - - it('returns same bucket instance', () => { - const b1 = provider.getBucket('x'); - const b2 = provider.getBucket('x'); - expect(b1).toBe(b2); - }); - - describe('bucket operations', () => { - it('upload + download', async () => { - await bucket.upload('file.txt', 'hello world', { contentType: 'text/plain' }); - const data = await bucket.download('file.txt'); - expect(data.toString()).toBe('hello world'); - }); - - it('upload returns metadata', async () => { - const meta = await bucket.upload('file.txt', Buffer.from('test'), { - contentType: 'text/plain', - metadata: { foo: 'bar' }, - }); - expect(meta.key).toBe('file.txt'); - expect(meta.size).toBe(4); - expect(meta.contentType).toBe('text/plain'); - expect(meta.metadata?.foo).toBe('bar'); - }); - - it('download throws for missing blob', async () => { - await expect(bucket.download('missing')).rejects.toThrow('not found'); - }); - - it('exists returns true/false', async () => { - expect(await bucket.exists('file.txt')).toBe(false); - await bucket.upload('file.txt', 'data'); - expect(await bucket.exists('file.txt')).toBe(true); - }); - - it('delete removes blob', async () => { - await bucket.upload('file.txt', 'data'); - await bucket.delete('file.txt'); - expect(await bucket.exists('file.txt')).toBe(false); - }); - - it('list returns all blobs', async () => { - await bucket.upload('a/1.txt', 'data'); - await bucket.upload('a/2.txt', 'data'); - await bucket.upload('b/1.txt', 'data'); - - const all = await bucket.list(); - expect(all).toHaveLength(3); - - const prefixed = await bucket.list('a/'); - expect(prefixed).toHaveLength(2); - }); - - it('getSignedUrl returns memory URL', async () => { - const url = await bucket.getSignedUrl('file.txt'); - expect(url).toContain('memory://'); - expect(url).toContain('file.txt'); - }); - }); -}); diff --git a/vendor/bytelyst/storage/src/factory.ts b/vendor/bytelyst/storage/src/factory.ts deleted file mode 100644 index c37c2e4..0000000 --- a/vendor/bytelyst/storage/src/factory.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * Storage provider factory. - * - * Creates a StorageProvider based on STORAGE_PROVIDER env var or explicit type. - * Defaults to 'azure' for backward compatibility. - */ - -import { MemoryStorageProvider } from './providers/memory.js'; -import type { StorageProvider, StorageProviderType } from './types.js'; - -let _provider: StorageProvider | null = null; - -/** - * Get the singleton storage provider. - */ -export async function getStorage(): Promise { - if (!_provider) { - const providerType = (process.env.STORAGE_PROVIDER || 'azure') as StorageProviderType; - _provider = await createStorageProvider(providerType); - } - return _provider; -} - -/** - * Create a storage provider by type. - */ -export async function createStorageProvider(type: StorageProviderType): Promise { - switch (type) { - case 'azure': { - const { AzureBlobStorageProvider } = await import('./providers/azure-blob.js'); - return new AzureBlobStorageProvider(); - } - case 'memory': - return new MemoryStorageProvider(); - default: - throw new Error(`Unknown STORAGE_PROVIDER: '${type}'. Valid: azure, memory`); - } -} - -/** - * Set the singleton storage provider (for testing). - */ -export function setStorage(provider: StorageProvider): void { - _provider = provider; -} - -/** - * @internal - */ -export function _resetStorage(): void { - _provider = null; -} diff --git a/vendor/bytelyst/storage/src/index.ts b/vendor/bytelyst/storage/src/index.ts deleted file mode 100644 index e704073..0000000 --- a/vendor/bytelyst/storage/src/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -export type { - StorageProvider, - StorageBucket, - UploadOptions, - SignedUrlOptions, - BlobMeta, - StorageProviderType, -} from './types.js'; - -export { getStorage, createStorageProvider, setStorage, _resetStorage } from './factory.js'; -export { AzureBlobStorageProvider, type AzureBlobProviderConfig } from './providers/azure-blob.js'; -export { MemoryStorageProvider } from './providers/memory.js'; diff --git a/vendor/bytelyst/storage/src/providers/azure-blob.ts b/vendor/bytelyst/storage/src/providers/azure-blob.ts deleted file mode 100644 index 86d3a9b..0000000 --- a/vendor/bytelyst/storage/src/providers/azure-blob.ts +++ /dev/null @@ -1,207 +0,0 @@ -/** - * Azure Blob Storage provider. - * - * Wraps @azure/storage-blob behind the cloud-agnostic StorageProvider interface. - */ - -import type { - BlobMeta, - SignedUrlOptions, - StorageBucket, - StorageProvider, - UploadOptions, -} from '../types.js'; - -export interface AzureBlobProviderConfig { - connectionString?: string; - accountName?: string; - accountKey?: string; - blobEndpoint?: string; - publicBlobEndpoint?: string; -} - -function parseConnectionString(connectionString: string): Partial { - const parts = new Map(); - - for (const segment of connectionString.split(';')) { - const [key, ...rest] = segment.split('='); - if (!key || rest.length === 0) continue; - parts.set(key, rest.join('=')); - } - - return { - accountName: parts.get('AccountName'), - accountKey: parts.get('AccountKey'), - blobEndpoint: parts.get('BlobEndpoint'), - }; -} - -export class AzureBlobStorageProvider implements StorageProvider { - private client: unknown = null; - private config: AzureBlobProviderConfig; - private buckets = new Map(); - - constructor(config?: AzureBlobProviderConfig) { - const envConfig = config ?? { - connectionString: process.env.AZURE_BLOB_CONNECTION_STRING, - accountName: process.env.AZURE_BLOB_ACCOUNT_NAME, - accountKey: process.env.AZURE_BLOB_ACCOUNT_KEY, - publicBlobEndpoint: process.env.AZURE_BLOB_PUBLIC_ENDPOINT, - }; - - const parsed = envConfig.connectionString - ? parseConnectionString(envConfig.connectionString) - : undefined; - - this.config = { - ...parsed, - ...envConfig, - accountName: envConfig.accountName ?? parsed?.accountName, - accountKey: envConfig.accountKey ?? parsed?.accountKey, - blobEndpoint: envConfig.blobEndpoint ?? parsed?.blobEndpoint, - }; - } - - private async getClient() { - if (!this.client) { - const { BlobServiceClient } = await import('@azure/storage-blob'); - if (this.config.connectionString) { - this.client = BlobServiceClient.fromConnectionString(this.config.connectionString); - } else if (this.config.accountName && this.config.accountKey) { - const { StorageSharedKeyCredential } = await import('@azure/storage-blob'); - const cred = new StorageSharedKeyCredential( - this.config.accountName, - this.config.accountKey - ); - const endpoint = - this.config.blobEndpoint ?? `https://${this.config.accountName}.blob.core.windows.net`; - this.client = new BlobServiceClient(endpoint, cred); - } else { - throw new Error( - 'AzureBlobStorageProvider requires AZURE_BLOB_CONNECTION_STRING or AZURE_BLOB_ACCOUNT_NAME + AZURE_BLOB_ACCOUNT_KEY' - ); - } - } - return this.client as import('@azure/storage-blob').BlobServiceClient; - } - - getBucket(name: string): StorageBucket { - let bucket = this.buckets.get(name); - if (!bucket) { - bucket = new AzureBlobBucket(name, () => this.getClient(), this.config); - this.buckets.set(name, bucket); - } - return bucket; - } - - async isHealthy(): Promise { - try { - const client = await this.getClient(); - // List one container to verify connectivity - const iter = client.listContainers(); - await iter.next(); - return true; - } catch { - return false; - } - } -} - -class AzureBlobBucket implements StorageBucket { - constructor( - private containerName: string, - private getClient: () => Promise, - private config: AzureBlobProviderConfig - ) {} - - private async containerClient() { - const client = await this.getClient(); - const container = client.getContainerClient(this.containerName); - await container.createIfNotExists(); - return container; - } - - async upload( - key: string, - data: Buffer | Uint8Array | string, - options?: UploadOptions - ): Promise { - const container = await this.containerClient(); - const blockBlob = container.getBlockBlobClient(key); - const buf = typeof data === 'string' ? Buffer.from(data) : Buffer.from(data); - await blockBlob.upload(buf, buf.length, { - blobHTTPHeaders: { blobContentType: options?.contentType }, - metadata: options?.metadata, - }); - return { - key, - size: buf.length, - contentType: options?.contentType, - lastModified: new Date(), - metadata: options?.metadata, - }; - } - - async download(key: string): Promise { - const container = await this.containerClient(); - const blob = container.getBlobClient(key); - const response = await blob.downloadToBuffer(); - return response; - } - - async delete(key: string): Promise { - const container = await this.containerClient(); - const blob = container.getBlobClient(key); - await blob.deleteIfExists(); - } - - async exists(key: string): Promise { - const container = await this.containerClient(); - const blob = container.getBlobClient(key); - return blob.exists(); - } - - async list(prefix?: string): Promise { - const container = await this.containerClient(); - const results: BlobMeta[] = []; - for await (const blob of container.listBlobsFlat({ prefix: prefix ?? undefined })) { - results.push({ - key: blob.name, - size: blob.properties.contentLength ?? undefined, - contentType: blob.properties.contentType ?? undefined, - lastModified: blob.properties.lastModified, - }); - } - return results; - } - - async getSignedUrl(key: string, options?: SignedUrlOptions): Promise { - const { generateBlobSASQueryParameters, BlobSASPermissions, StorageSharedKeyCredential } = - await import('@azure/storage-blob'); - - if (!this.config.accountName || !this.config.accountKey) { - throw new Error('Signed URLs require accountName + accountKey'); - } - - const cred = new StorageSharedKeyCredential(this.config.accountName, this.config.accountKey); - const expiresOn = new Date(Date.now() + (options?.expiresIn ?? 3600) * 1000); - const permissions = BlobSASPermissions.parse(options?.permissions === 'write' ? 'w' : 'r'); - - const sas = generateBlobSASQueryParameters( - { - containerName: this.containerName, - blobName: key, - permissions, - expiresOn, - }, - cred - ); - - const baseUrl = - this.config.publicBlobEndpoint ?? - this.config.blobEndpoint ?? - `https://${this.config.accountName}.blob.core.windows.net`; - - return `${baseUrl.replace(/\/$/, '')}/${this.containerName}/${key}?${sas.toString()}`; - } -} diff --git a/vendor/bytelyst/storage/src/providers/memory.ts b/vendor/bytelyst/storage/src/providers/memory.ts deleted file mode 100644 index 4bb2710..0000000 --- a/vendor/bytelyst/storage/src/providers/memory.ts +++ /dev/null @@ -1,83 +0,0 @@ -/** - * In-memory storage provider — for testing and local dev. - */ - -import type { - BlobMeta, - SignedUrlOptions, - StorageBucket, - StorageProvider, - UploadOptions, -} from '../types.js'; - -export class MemoryStorageProvider implements StorageProvider { - private buckets = new Map(); - - getBucket(name: string): StorageBucket { - let bucket = this.buckets.get(name); - if (!bucket) { - bucket = new MemoryBucket(name); - this.buckets.set(name, bucket); - } - return bucket; - } - - async isHealthy(): Promise { - return true; - } - - clear(): void { - this.buckets.clear(); - } -} - -class MemoryBucket implements StorageBucket { - private blobs = new Map(); - - constructor(private name: string) {} - - async upload( - key: string, - data: Buffer | Uint8Array | string, - options?: UploadOptions - ): Promise { - const buf = typeof data === 'string' ? Buffer.from(data) : Buffer.from(data); - const meta: BlobMeta = { - key, - size: buf.length, - contentType: options?.contentType ?? 'application/octet-stream', - lastModified: new Date(), - metadata: options?.metadata, - }; - this.blobs.set(key, { data: buf, meta }); - return meta; - } - - async download(key: string): Promise { - const entry = this.blobs.get(key); - if (!entry) throw new Error(`Blob '${key}' not found in bucket '${this.name}'`); - return entry.data; - } - - async delete(key: string): Promise { - this.blobs.delete(key); - } - - async exists(key: string): Promise { - return this.blobs.has(key); - } - - async list(prefix?: string): Promise { - const results: BlobMeta[] = []; - for (const [key, entry] of this.blobs) { - if (!prefix || key.startsWith(prefix)) { - results.push(entry.meta); - } - } - return results; - } - - async getSignedUrl(key: string, _options?: SignedUrlOptions): Promise { - return `memory://${this.name}/${key}?signed=true`; - } -} diff --git a/vendor/bytelyst/storage/src/testing.ts b/vendor/bytelyst/storage/src/testing.ts deleted file mode 100644 index c3cecff..0000000 --- a/vendor/bytelyst/storage/src/testing.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Test helpers for @bytelyst/storage. - */ - -import { setStorage, _resetStorage } from './factory.js'; -import { MemoryStorageProvider } from './providers/memory.js'; - -let _testProvider: MemoryStorageProvider | null = null; - -export function setTestStorageProvider(): MemoryStorageProvider { - _testProvider = new MemoryStorageProvider(); - setStorage(_testProvider); - return _testProvider; -} - -export function clearTestStorage(): void { - _testProvider?.clear(); -} - -export function resetTestStorage(): void { - _testProvider?.clear(); - _testProvider = null; - _resetStorage(); -} diff --git a/vendor/bytelyst/storage/src/types.ts b/vendor/bytelyst/storage/src/types.ts deleted file mode 100644 index b1f7211..0000000 --- a/vendor/bytelyst/storage/src/types.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * Cloud-agnostic storage interfaces. - * - * Provides StorageProvider and StorageBucket abstractions - * that work with Azure Blob, S3, R2, or in-memory storage. - */ - -export interface StorageProvider { - /** Get or create a bucket/container. */ - getBucket(name: string): StorageBucket; - - /** Check if storage is configured and reachable. */ - isHealthy(): Promise; -} - -export interface StorageBucket { - /** Upload a blob. */ - upload( - key: string, - data: Buffer | Uint8Array | string, - options?: UploadOptions - ): Promise; - - /** Download a blob as a Buffer. */ - download(key: string): Promise; - - /** Delete a blob. */ - delete(key: string): Promise; - - /** Check if a blob exists. */ - exists(key: string): Promise; - - /** List blobs with optional prefix. */ - list(prefix?: string): Promise; - - /** Get a signed URL for temporary access. */ - getSignedUrl(key: string, options?: SignedUrlOptions): Promise; -} - -export interface UploadOptions { - contentType?: string; - metadata?: Record; -} - -export interface SignedUrlOptions { - /** Expiry in seconds (default 3600). */ - expiresIn?: number; - permissions?: 'read' | 'write'; -} - -export interface BlobMeta { - key: string; - size?: number; - contentType?: string; - lastModified?: Date; - metadata?: Record; -} - -export type StorageProviderType = 'azure' | 'memory'; diff --git a/vendor/bytelyst/storage/tsconfig.json b/vendor/bytelyst/storage/tsconfig.json deleted file mode 100644 index 5edad81..0000000 --- a/vendor/bytelyst/storage/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/vendor/bytelyst/subscription-client/package.json b/vendor/bytelyst/subscription-client/package.json deleted file mode 100644 index b5dce4a..0000000 --- a/vendor/bytelyst/subscription-client/package.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "@bytelyst/subscription-client", - "version": "0.1.5", - "type": "module", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "scripts": { - "build": "tsc" - }, - "devDependencies": { - "typescript": "^5.7.3" - } -} diff --git a/vendor/bytelyst/subscription-client/src/client.test.ts b/vendor/bytelyst/subscription-client/src/client.test.ts deleted file mode 100644 index 33b8b8e..0000000 --- a/vendor/bytelyst/subscription-client/src/client.test.ts +++ /dev/null @@ -1,283 +0,0 @@ -import { describe, it, expect, vi, afterEach } from 'vitest'; -import { createSubscriptionClient } from './client.js'; -import type { SubscriptionDoc, PlanConfig } from './types.js'; - -const baseConfig = { - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - userId: 'user-1', - getAccessToken: () => 'test-token', -}; - -function mockSub(overrides?: Partial): SubscriptionDoc { - return { - id: 'sub-1', - productId: 'testapp', - userId: 'user-1', - plan: 'pro', - status: 'active', - currentPeriodStart: '2026-01-01T00:00:00Z', - currentPeriodEnd: '2026-12-31T23:59:59Z', - cancelAtPeriodEnd: false, - monthlyPrice: 999, - tokensIncluded: 10000, - tokensUsed: 500, - createdAt: '2026-01-01T00:00:00Z', - updatedAt: '2026-01-01T00:00:00Z', - ...overrides, - }; -} - -function mockPlan(overrides?: Partial): PlanConfig { - return { - id: 'plan-1', - productId: 'testapp', - name: 'pro', - displayName: 'Pro', - price: 999, - tokens: 10000, - words: 0, - dictations: 0, - features: ['ai_coaching', 'advanced_stats'], - active: true, - createdAt: '2026-01-01T00:00:00Z', - updatedAt: '2026-01-01T00:00:00Z', - ...overrides, - }; -} - -describe('createSubscriptionClient', () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('should get subscription by userId', async () => { - const sub = mockSub(); - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve(sub), - }) - ); - - const client = createSubscriptionClient(baseConfig); - const result = await client.getMySubscription(); - - expect(result).toEqual(sub); - const fetchMock = globalThis.fetch as ReturnType; - expect(fetchMock).toHaveBeenCalledWith( - 'http://localhost:4003/api/subscriptions/user-1', - expect.any(Object) - ); - }); - - it('should return null for 404 on getMySubscription', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: false, - status: 404, - }) - ); - - const client = createSubscriptionClient(baseConfig); - const result = await client.getMySubscription(); - expect(result).toBeNull(); - }); - - it('should unwrap .plans from getPlans response', async () => { - const plans = [mockPlan(), mockPlan({ name: 'free', displayName: 'Free', features: [] })]; - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ plans }), - }) - ); - - const client = createSubscriptionClient(baseConfig); - const result = await client.getPlans(); - expect(result).toHaveLength(2); - expect(result[0].name).toBe('pro'); - }); - - it('should start a trial', async () => { - const sub = mockSub({ status: 'trialing' }); - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve(sub), - }) - ); - - const client = createSubscriptionClient(baseConfig); - const result = await client.startTrial(); - expect(result.status).toBe('trialing'); - }); - - it('should cancel subscription', async () => { - const sub = mockSub({ cancelAtPeriodEnd: true }); - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve(sub), - }) - ); - - const client = createSubscriptionClient(baseConfig); - const result = await client.cancelSubscription(); - expect(result.cancelAtPeriodEnd).toBe(true); - }); - - it('should report isPro correctly from cache', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockSub({ plan: 'pro', status: 'active' })), - }) - ); - - const client = createSubscriptionClient(baseConfig); - expect(client.isPro()).toBe(false); // no cache yet - - await client.getMySubscription(); - expect(client.isPro()).toBe(true); - }); - - it('should report isPro false for free plan', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockSub({ plan: 'free', status: 'active' })), - }) - ); - - const client = createSubscriptionClient(baseConfig); - await client.getMySubscription(); - expect(client.isPro()).toBe(false); - }); - - it('should check hasFeature from cached plans', async () => { - let callIndex = 0; - vi.stubGlobal( - 'fetch', - vi.fn().mockImplementation(() => { - callIndex++; - if (callIndex === 1) { - return Promise.resolve({ - ok: true, - json: () => Promise.resolve(mockSub({ plan: 'pro' })), - }); - } - return Promise.resolve({ - ok: true, - json: () => - Promise.resolve({ - plans: [mockPlan({ name: 'pro', features: ['ai_coaching', 'advanced_stats'] })], - }), - }); - }) - ); - - const client = createSubscriptionClient(baseConfig); - await client.refresh(); - - expect(client.hasFeature('ai_coaching')).toBe(true); - expect(client.hasFeature('nonexistent')).toBe(false); - }); - - it('should report isTrialing correctly', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockSub({ status: 'trialing' })), - }) - ); - - const client = createSubscriptionClient(baseConfig); - await client.getMySubscription(); - expect(client.isTrialing()).toBe(true); - }); - - it('should calculate daysRemaining', async () => { - const futureDate = new Date(Date.now() + 10 * 24 * 60 * 60 * 1000).toISOString(); - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockSub({ currentPeriodEnd: futureDate })), - }) - ); - - const client = createSubscriptionClient(baseConfig); - await client.getMySubscription(); - const days = client.daysRemaining(); - expect(days).toBeGreaterThanOrEqual(10); - expect(days).toBeLessThanOrEqual(11); - }); - - it('should persist and restore from storage', async () => { - const store: Record = {}; - const storage = { - getItem: (k: string) => store[k] ?? null, - setItem: (k: string, v: string) => { - store[k] = v; - }, - }; - - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockSub()), - }) - ); - - const client1 = createSubscriptionClient({ ...baseConfig, storage }); - await client1.getMySubscription(); - - expect(store['testapp-subscription']).toBeDefined(); - - // Create new client — should restore from storage - const client2 = createSubscriptionClient({ ...baseConfig, storage }); - expect(client2.getCachedSubscription()).not.toBeNull(); - expect(client2.isPro()).toBe(true); - }); - - it('should send correct headers', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ plans: [] }), - }) - ); - - const client = createSubscriptionClient(baseConfig); - await client.getPlans(); - - const fetchMock = globalThis.fetch as ReturnType; - const callHeaders = fetchMock.mock.calls[0][1].headers as Record; - expect(callHeaders['x-product-id']).toBe('testapp'); - expect(callHeaders['Authorization']).toBe('Bearer test-token'); - }); - - it('should throw on non-ok response', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: false, - status: 500, - }) - ); - - const client = createSubscriptionClient(baseConfig); - await expect(client.getPlans()).rejects.toThrow('getPlans failed: 500'); - }); -}); diff --git a/vendor/bytelyst/subscription-client/src/client.ts b/vendor/bytelyst/subscription-client/src/client.ts deleted file mode 100644 index 0922773..0000000 --- a/vendor/bytelyst/subscription-client/src/client.ts +++ /dev/null @@ -1,193 +0,0 @@ -/** - * Browser/React Native-safe subscription client for platform-service. - * - * Wraps platform-service /subscriptions/* + /plans/* endpoints. - * Caches subscription and plans for offline reads. - * No Node.js dependencies — uses globalThis.fetch. - */ - -import type { - PlanConfig, - SubscriptionClient, - SubscriptionClientConfig, - SubscriptionDoc, -} from './types.js'; - -function generateRequestId(): string { - return typeof globalThis.crypto?.randomUUID === 'function' - ? globalThis.crypto.randomUUID() - : `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; -} - -export function createSubscriptionClient(config: SubscriptionClientConfig): SubscriptionClient { - const { baseUrl, productId, userId, getAccessToken, storage } = config; - - const SUB_KEY = `${productId}-subscription`; - const PLANS_KEY = `${productId}-plans`; - - let cachedSub: SubscriptionDoc | null = null; - let cachedPlans: PlanConfig[] = []; - - // Restore from storage on creation - if (storage) { - try { - const raw = storage.getItem(SUB_KEY); - if (raw) cachedSub = JSON.parse(raw) as SubscriptionDoc; - } catch { - /* ignore */ - } - try { - const raw = storage.getItem(PLANS_KEY); - if (raw) cachedPlans = JSON.parse(raw) as PlanConfig[]; - } catch { - /* ignore */ - } - } - - function headers(): Record { - const h: Record = { - 'Content-Type': 'application/json', - 'x-product-id': productId, - 'x-request-id': generateRequestId(), - }; - const token = getAccessToken(); - if (token) h['Authorization'] = `Bearer ${token}`; - return h; - } - - function persistSub(sub: SubscriptionDoc | null): void { - cachedSub = sub; - if (storage) { - try { - storage.setItem(SUB_KEY, JSON.stringify(sub)); - } catch { - /* ignore */ - } - } - } - - function persistPlans(plans: PlanConfig[]): void { - cachedPlans = plans; - if (storage) { - try { - storage.setItem(PLANS_KEY, JSON.stringify(plans)); - } catch { - /* ignore */ - } - } - } - - async function getMySubscription(): Promise { - const res = await globalThis.fetch(`${baseUrl}/subscriptions/${encodeURIComponent(userId)}`, { - headers: headers(), - }); - if (res.status === 404) { - persistSub(null); - return null; - } - if (!res.ok) throw new Error(`getMySubscription failed: ${res.status}`); - const sub = (await res.json()) as SubscriptionDoc; - persistSub(sub); - return sub; - } - - async function getPlans(): Promise { - const res = await globalThis.fetch(`${baseUrl}/plans`, { headers: headers() }); - if (!res.ok) throw new Error(`getPlans failed: ${res.status}`); - const data = (await res.json()) as { plans: PlanConfig[] }; - const plans = data.plans; - persistPlans(plans); - return plans; - } - - async function startTrial(planName = 'pro'): Promise { - const res = await globalThis.fetch(`${baseUrl}/subscriptions`, { - method: 'POST', - headers: headers(), - body: JSON.stringify({ userId, productId, plan: planName, status: 'trialing' }), - }); - if (!res.ok) throw new Error(`startTrial failed: ${res.status}`); - const sub = (await res.json()) as SubscriptionDoc; - persistSub(sub); - return sub; - } - - async function cancelSubscription(): Promise { - const res = await globalThis.fetch(`${baseUrl}/subscriptions/${encodeURIComponent(userId)}`, { - method: 'PUT', - headers: headers(), - body: JSON.stringify({ cancelAtPeriodEnd: true }), - }); - if (!res.ok) throw new Error(`cancelSubscription failed: ${res.status}`); - const sub = (await res.json()) as SubscriptionDoc; - persistSub(sub); - return sub; - } - - async function updateSubscription(updates: Partial): Promise { - const res = await globalThis.fetch(`${baseUrl}/subscriptions/${encodeURIComponent(userId)}`, { - method: 'PUT', - headers: headers(), - body: JSON.stringify(updates), - }); - if (!res.ok) throw new Error(`updateSubscription failed: ${res.status}`); - const sub = (await res.json()) as SubscriptionDoc; - persistSub(sub); - return sub; - } - - function isPro(): boolean { - if (!cachedSub) return false; - return ( - cachedSub.plan !== 'free' && - (cachedSub.status === 'active' || cachedSub.status === 'trialing') - ); - } - - function isTrialing(): boolean { - return cachedSub?.status === 'trialing' || false; - } - - function hasFeature(feature: string): boolean { - if (!cachedSub) return false; - const plan = cachedPlans.find(p => p.name === cachedSub!.plan); - if (!plan) return false; - return plan.features.includes(feature); - } - - function daysRemaining(): number | null { - if (!cachedSub) return null; - const end = new Date(cachedSub.currentPeriodEnd).getTime(); - const now = Date.now(); - const diff = end - now; - if (diff <= 0) return 0; - return Math.ceil(diff / (1000 * 60 * 60 * 24)); - } - - function getCachedSubscription(): SubscriptionDoc | null { - return cachedSub; - } - - function getCachedPlans(): PlanConfig[] { - return cachedPlans; - } - - async function refresh(): Promise { - await Promise.all([getMySubscription(), getPlans()]); - } - - return { - getMySubscription, - getPlans, - startTrial, - cancelSubscription, - updateSubscription, - isPro, - isTrialing, - hasFeature, - daysRemaining, - getCachedSubscription, - getCachedPlans, - refresh, - }; -} diff --git a/vendor/bytelyst/subscription-client/src/index.ts b/vendor/bytelyst/subscription-client/src/index.ts deleted file mode 100644 index bd82298..0000000 --- a/vendor/bytelyst/subscription-client/src/index.ts +++ /dev/null @@ -1,156 +0,0 @@ -export interface SubscriptionDoc { - id: string; - userId: string; - productId?: string; - plan: string; - status: 'active' | 'trialing' | 'past_due' | 'cancelled' | 'none'; - currentPeriodStart?: string; - currentPeriodEnd: string; - cancelAtPeriodEnd: boolean; - monthlyPrice?: number; - tokensIncluded?: number; - tokensUsed?: number; - stripeCustomerId?: string; - stripeSubscriptionId?: string; - features?: string[]; - createdAt?: string; - updatedAt?: string; -} - -export interface PlanConfig { - id: string; - name: string; - displayName: string; - price: number; - features: string[]; -} - -export interface SubscriptionClientOptions { - baseUrl: string; - productId: string; - userId: string; - getAccessToken: () => string; -} - -export interface SubscriptionClient { - getMySubscription(): Promise; - getPlans(): Promise; - cancelSubscription(): Promise; - isPro(): boolean; - isTrialing(): boolean; - hasFeature(feature: string): boolean; - daysRemaining(): number | null; -} - -function trimTrailingSlash(url: string): string { - return url.replace(/\/+$/, ''); -} - -export function createSubscriptionClient(opts: SubscriptionClientOptions): SubscriptionClient { - const base = trimTrailingSlash(opts.baseUrl); - let cached: SubscriptionDoc | null | undefined; - - async function request(path: string, init?: RequestInit): Promise { - const token = opts.getAccessToken(); - const headers = new Headers(init?.headers); - headers.set('Authorization', `Bearer ${token}`); - headers.set('X-Product-Id', opts.productId); - if (!headers.has('Content-Type') && init?.body !== undefined) { - headers.set('Content-Type', 'application/json'); - } - const res = await fetch(`${base}${path}`, { ...init, headers }); - if (res.status === 404) { - return null as T; - } - if (!res.ok) { - throw new Error(`Subscription API error: ${res.status} ${res.statusText}`); - } - if (res.status === 204) { - return null as T; - } - const text = await res.text(); - if (!text) { - return null as T; - } - return JSON.parse(text) as T; - } - - function subscriptionFromCache(): SubscriptionDoc | null { - if (cached === undefined || cached === null) { - return null; - } - return cached; - } - - return { - async getMySubscription(): Promise { - const data = await request('/billing/subscriptions/me'); - cached = data; - return data; - }, - - async getPlans(): Promise { - const data = await request('/billing/plans'); - if (data == null) { - return []; - } - if (Array.isArray(data)) { - return data; - } - if (data && typeof data === 'object' && 'plans' in data && Array.isArray(data.plans)) { - return data.plans; - } - return []; - }, - - async cancelSubscription(): Promise { - const data = await request('/billing/subscriptions/cancel', { - method: 'POST', - }); - if (data === null) { - throw new Error('Cancel subscription returned no body'); - } - cached = data; - return data; - }, - - isPro(): boolean { - const sub = subscriptionFromCache(); - if (!sub) { - return false; - } - const paid = sub.status === 'active' || sub.status === 'trialing'; - const planLower = sub.plan.toLowerCase(); - const notFree = planLower !== 'free' && planLower !== 'none'; - return paid && notFree; - }, - - isTrialing(): boolean { - return subscriptionFromCache()?.status === 'trialing'; - }, - - hasFeature(feature: string): boolean { - const sub = subscriptionFromCache(); - if (!sub?.features?.length) { - return false; - } - return sub.features.includes(feature); - }, - - daysRemaining(): number | null { - const sub = subscriptionFromCache(); - if (!sub?.currentPeriodEnd) { - return null; - } - const end = new Date(sub.currentPeriodEnd).getTime(); - if (Number.isNaN(end)) { - return null; - } - const ms = end - Date.now(); - if (ms <= 0) { - return 0; - } - return Math.ceil(ms / (1000 * 60 * 60 * 24)); - }, - }; -} diff --git a/vendor/bytelyst/subscription-client/src/types.ts b/vendor/bytelyst/subscription-client/src/types.ts deleted file mode 100644 index 6092f49..0000000 --- a/vendor/bytelyst/subscription-client/src/types.ts +++ /dev/null @@ -1,76 +0,0 @@ -/** - * Types for @bytelyst/subscription-client. - * Browser/React Native-safe — no Node.js dependencies. - */ - -export interface SubscriptionDoc { - id: string; - productId: string; - userId: string; - plan: 'free' | 'pro' | 'enterprise'; - status: 'active' | 'cancelled' | 'past_due' | 'trialing'; - currentPeriodStart: string; - currentPeriodEnd: string; - cancelAtPeriodEnd: boolean; - monthlyPrice: number; - tokensIncluded: number; - tokensUsed: number; - stripeCustomerId?: string; - stripeSubscriptionId?: string; - createdAt: string; - updatedAt: string; -} - -export interface PlanConfig { - id: string; - productId: string; - name: string; - displayName: string; - price: number; - tokens: number; - words: number; - dictations: number; - features: string[]; - stripePriceId?: string; - active: boolean; - createdAt: string; - updatedAt: string; -} - -export interface SubscriptionClientConfig { - /** Platform-service base URL (e.g. "http://localhost:4003/api"). */ - baseUrl: string; - - /** Product identifier sent as x-product-id header on every request. */ - productId: string; - - /** User ID — subscription routes are keyed by userId, not id. */ - userId: string; - - /** Returns a JWT access token, or null if not authenticated. */ - getAccessToken: () => string | null; - - /** Optional persistent storage adapter for cache. */ - storage?: { - getItem(key: string): string | null; - setItem(key: string, value: string): void; - }; -} - -export interface SubscriptionClient { - // Server-authoritative checks - getMySubscription(): Promise; - getPlans(): Promise; - startTrial(planName?: string): Promise; - cancelSubscription(): Promise; - updateSubscription(updates: Partial): Promise; - - // Client-side helpers (cached, offline-safe) - isPro(): boolean; - isTrialing(): boolean; - hasFeature(feature: string): boolean; - daysRemaining(): number | null; - getCachedSubscription(): SubscriptionDoc | null; - getCachedPlans(): PlanConfig[]; - refresh(): Promise; -} diff --git a/vendor/bytelyst/subscription-client/tsconfig.json b/vendor/bytelyst/subscription-client/tsconfig.json deleted file mode 100644 index 7d61ee3..0000000 --- a/vendor/bytelyst/subscription-client/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "outDir": "dist", - "rootDir": "src", - "declaration": true, - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "lib": ["ES2022", "DOM"] - }, - "include": ["src"] -} diff --git a/vendor/bytelyst/survey-client/README.md b/vendor/bytelyst/survey-client/README.md deleted file mode 100644 index d86a83a..0000000 --- a/vendor/bytelyst/survey-client/README.md +++ /dev/null @@ -1,349 +0,0 @@ -# @bytelyst/survey-client - -TypeScript client for the ByteLyst Survey platform. Provides survey discovery, question flow management, response submission, and offline caching. - -## Installation - -```bash -npm install @bytelyst/survey-client -# or -pnpm add @bytelyst/survey-client -``` - -## Quick Start - -```typescript -import { createSurveyClient } from '@bytelyst/survey-client'; - -const client = createSurveyClient({ - baseURL: 'https://api.bytelyst.io/v1', - productId: 'lysnrai', - getAuthToken: async () => { - return localStorage.getItem('token'); - } -}); - -// Check for active survey -const [survey, error] = await client.getActiveSurvey(); -if (survey) { - console.log('Survey available:', survey.title); -} -``` - -## API Reference - -### `createSurveyClient(config)` - -Creates a new survey client instance. - -**Config:** -| Option | Type | Required | Description | -|--------|------|----------|-------------| -| `baseURL` | string | Yes | API base URL | -| `productId` | string | Yes | Product identifier | -| `getAuthToken` | () => Promise | Yes | Function to retrieve JWT token | -| `enableOfflineCache` | boolean | No | Enable offline response caching (default: true) | - -### Methods - -#### `getActiveSurvey()` -Check if there's an active survey for the current user. - -```typescript -const [survey, error] = await client.getActiveSurvey(); -// Returns: ActiveSurvey | null -``` - -#### `startSurvey(surveyId: string)` -Start a survey session. - -```typescript -const [state, error] = await client.startSurvey('srv_123'); -// Returns: { surveyStateId: string, currentQuestionIndex: number } -``` - -#### `submitAnswer(surveyId: string, questionId: string, answer: SurveyAnswer)` -Submit an answer for a specific question. - -```typescript -const [result, error] = await client.submitAnswer('srv_123', 'q1', { - type: 'rating', - value: { value: 8 } -}); -// Returns: { currentQuestionIndex: number, nextQuestionId: string, isComplete: boolean } -``` - -**Answer Types:** -```typescript -// Single choice -{ type: 'single_choice', value: { value: 'option_1' } } - -// Multiple choice -{ type: 'multiple_choice', value: { values: ['opt_1', 'opt_2'] } } - -// Rating/NPS/Scale -{ type: 'rating', value: { value: 8 } } - -// Text -{ type: 'text', value: { value: 'My feedback here' } } - -// Ranking -{ type: 'ranking', value: { order: ['opt_1', 'opt_2', 'opt_3'] } } -``` - -#### `completeSurvey(surveyId: string)` -Mark survey as complete and claim any incentive. - -```typescript -const [completion, error] = await client.completeSurvey('srv_123'); -// Returns: { success: boolean, incentiveClaimed: boolean, incentiveType?: string, incentiveAmount?: number } -``` - -#### `dismissSurvey(surveyId: string)` -Dismiss survey without completing. - -```typescript -await client.dismissSurvey('srv_123'); -``` - -#### `startPolling(intervalMs: number, callback: (survey) => void)` -Start polling for active surveys. - -```typescript -client.startPolling(60000, (survey) => { - if (survey) { - showSurveyModal(survey); - } -}); -``` - -#### `stopPolling()` -Stop survey polling. - -```typescript -client.stopPolling(); -``` - -#### `flushOfflineQueue()` -Manually flush any cached offline responses. - -```typescript -await client.flushOfflineQueue(); -``` - -## React Integration - -### Hook Usage - -```typescript -import { useSurvey } from './hooks/useSurvey'; - -function SurveyWidget() { - const { - activeSurvey, - currentQuestion, - submitAnswer, - completeSurvey, - progress - } = useSurvey({ - autoStart: true, - pollingInterval: 60000 - }); - - if (!activeSurvey) return null; - - return ( - - ); -} -``` - -### Complete Survey Flow Example - -```typescript -function SurveyFlow() { - const client = useSurveyClient(); - const [survey, setSurvey] = useState(null); - const [currentIndex, setCurrentIndex] = useState(0); - const [answers, setAnswers] = useState>({}); - - useEffect(() => { - loadSurvey(); - }, []); - - async function loadSurvey() { - const [activeSurvey] = await client.getActiveSurvey(); - if (activeSurvey) { - await client.startSurvey(activeSurvey.id); - setSurvey(activeSurvey); - } - } - - async function handleAnswer(questionId: string, answer: SurveyAnswer) { - const [result] = await client.submitAnswer(survey!.id, questionId, answer); - - setAnswers(prev => ({ ...prev, [questionId]: answer })); - setCurrentIndex(result.currentQuestionIndex); - - if (result.isComplete) { - const [completion] = await client.completeSurvey(survey!.id); - if (completion.incentiveClaimed) { - showIncentiveToast(completion.incentiveAmount, completion.incentiveType); - } - } - } - - if (!survey) return null; - - const question = survey.questions[currentIndex]; - const isLast = currentIndex === survey.questions.length - 1; - - return ( - handleAnswer(question.id, answer)} - onSkip={question.required ? undefined : () => handleSkip(question.id)} - /> - ); -} -``` - -## Offline Support - -The client automatically caches responses when offline: - -```typescript -const client = createSurveyClient({ - baseURL: 'https://api.bytelyst.io/v1', - productId: 'lysnrai', - getAuthToken: () => getToken(), - enableOfflineCache: true // Enabled by default -}); - -// Responses are queued when offline -// Flush queue manually or on reconnect -window.addEventListener('online', () => { - client.flushOfflineQueue(); -}); -``` - -## Types - -```typescript -interface ActiveSurvey { - id: string; - title: string; - description?: string; - questions: Question[]; - currentQuestionIndex: number; - incentive?: { - type: 'pro_days' | 'credits'; - amount: number; - }; -} - -interface Question { - id: string; - type: 'single_choice' | 'multiple_choice' | 'rating' | 'scale' | - 'nps' | 'text_short' | 'text_long' | 'ranking' | 'dropdown'; - text: string; - description?: string; - required: boolean; - options?: QuestionOption[]; - minValue?: number; - maxValue?: number; - maxLength?: number; - showIf?: ShowIfCondition; -} - -interface QuestionOption { - id: string; - text: string; - emoji?: string; -} - -interface SurveyAnswer { - type: string; - value: Record; -} - -interface SurveyConfig { - baseURL: string; - productId: string; - getAuthToken: () => Promise; - enableOfflineCache?: boolean; -} -``` - -## Question Type Reference - -| Type | Answer Format | UI Component | -|------|--------------|--------------| -| `single_choice` | `{ value: string }` | Radio group | -| `multiple_choice` | `{ values: string[] }` | Checkboxes | -| `rating` | `{ value: number }` | Star rating (1-5) | -| `scale` | `{ value: number }` | Numeric slider | -| `nps` | `{ value: number }` | 0-10 buttons | -| `text_short` | `{ value: string }` | Single line input | -| `text_long` | `{ value: string }` | Textarea | -| `ranking` | `{ order: string[] }` | Drag-to-sort | -| `dropdown` | `{ value: string }` | Select dropdown | - -## Conditional Logic - -Questions can be shown/hidden based on previous answers: - -```typescript -// Question only shows if q1 answer is NOT 9 or 10 -{ - id: 'q2', - type: 'text_long', - text: 'What could we improve?', - showIf: { - questionId: 'q1', - operator: 'not_equals', - value: ['9', '10'] - } -} -``` - -**Operators:** `equals`, `not_equals`, `greater_than`, `less_than`, `contains` - -## Error Handling - -```typescript -const [data, error] = await client.submitAnswer('srv_123', 'q1', answer); - -if (error) { - switch (error.code) { - case 'SURVEY_NOT_FOUND': - console.error('Survey expired or unavailable'); - break; - case 'ALREADY_COMPLETED': - console.error('User already completed this survey'); - break; - case 'VALIDATION_ERROR': - console.error('Invalid answer format'); - break; - default: - console.error('Survey error:', error.message); - } -} -``` - -## Browser Support - -- Chrome 90+ -- Firefox 88+ -- Safari 14+ -- Edge 90+ - -## License - -MIT © ByteLyst diff --git a/vendor/bytelyst/survey-client/package.json b/vendor/bytelyst/survey-client/package.json deleted file mode 100644 index 656a9b7..0000000 --- a/vendor/bytelyst/survey-client/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "@bytelyst/survey-client", - "version": "0.1.5", - "type": "module", - "description": "Browser/React Native-safe survey client for platform-service", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/survey-client/src/index.ts b/vendor/bytelyst/survey-client/src/index.ts deleted file mode 100644 index bb2681e..0000000 --- a/vendor/bytelyst/survey-client/src/index.ts +++ /dev/null @@ -1,328 +0,0 @@ -/** - * Survey Client — Browser/React Native-safe survey client - * @module @bytelyst/survey-client - */ - -// ============================================================================= -// Types -// ============================================================================= - -export type QuestionType = - | 'single_choice' - | 'multiple_choice' - | 'rating' - | 'nps' - | 'text_short' - | 'text_long' - | 'dropdown' - | 'scale' - | 'ranking'; - -export interface QuestionOption { - id: string; - text: string; - emoji?: string; -} - -export interface Question { - id: string; - type: QuestionType; - text: string; - description?: string; - required: boolean; - options?: QuestionOption[]; - minLength?: number; - maxLength?: number; - minValue?: number; - maxValue?: number; -} - -export interface Survey { - id: string; - productId: string; - title: string; - description?: string; - questions: Question[]; - status: 'draft' | 'active' | 'paused' | 'closed'; - startsAt?: string; - endsAt?: string; - displayTrigger: - | { type: 'immediate' } - | { type: 'delay_seconds'; seconds: number } - | { type: 'event'; eventName: string } - | { type: 'page_view'; pagePattern: string }; - incentive?: { type: 'pro_days' | 'credits'; amount: number }; - createdAt: string; - updatedAt: string; - createdBy: string; -} - -export type QuestionAnswer = - | { type: 'single_choice'; optionId: string } - | { type: 'multiple_choice'; optionIds: string[] } - | { type: 'rating'; value: number } - | { type: 'nps'; value: number } - | { type: 'text'; value: string } - | { type: 'ranking'; rankedOptionIds: string[] }; - -export interface SurveyResponse { - id: string; - surveyId: string; - userId: string; - answers: Record; - currentQuestionIndex: number; - startedAt: string; - completedAt?: string; - isComplete: boolean; - incentiveClaimed: boolean; - incentiveClaimedAt?: string; - createdAt: string; - updatedAt: string; -} - -export interface SurveyClientConfig { - /** Platform service base URL */ - baseUrl: string; - /** Product ID */ - productId: string; - /** Auth token provider */ - getAuthToken: (() => string) | (() => Promise); - /** Platform identifier */ - platform: 'web' | 'ios' | 'android' | 'macos' | 'windows'; - /** App version */ - appVersion: string; - /** OS version */ - osVersion: string; - /** Optional country code */ - countryCode?: string; - /** Optional region code */ - regionCode?: string; - /** User segments (default: ['free']) */ - userSegments?: string[]; - /** Device ID for tracking */ - deviceId?: string; -} - -// ============================================================================= -// Client Factory -// ============================================================================= - -export interface ActiveSurvey { - id: string; - title: string; - description?: string; - questions: Question[]; - incentive?: { type: 'pro_days' | 'credits'; amount: number }; - displayTrigger: Survey['displayTrigger']; -} - -export interface SurveyClient { - /** Get active survey for current user (if any) */ - getActiveSurvey(): Promise<{ survey: ActiveSurvey | null }>; - /** Start a survey session */ - startSurvey(surveyId: string): Promise<{ - responseId: string; - startedAt: string; - currentQuestionIndex: number; - answers: Record; - }>; - /** Submit an answer */ - submitAnswer( - surveyId: string, - questionId: string, - answer: QuestionAnswer - ): Promise<{ - responseId: string; - currentQuestionIndex: number; - answers: Record; - }>; - /** Complete the survey */ - completeSurvey(surveyId: string): Promise<{ - success: boolean; - timeSpentSeconds: number; - incentiveClaimed: boolean; - }>; - /** Dismiss survey (won't show again) */ - dismissSurvey(surveyId: string): Promise; - /** Check for eligible surveys periodically */ - pollSurveys(intervalMs?: number): () => void; - /** Get cached response for a survey (for offline support) */ - getCachedResponse(surveyId: string): SurveyResponse | null; - /** Save response to cache */ - cacheResponse(surveyId: string, response: Partial): void; -} - -export function createSurveyClient(config: SurveyClientConfig): SurveyClient { - const headers = async () => ({ - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${await Promise.resolve(config.getAuthToken())}`, - 'x-product-id': config.productId, - 'x-platform': config.platform, - 'x-app-version': config.appVersion, - 'x-os-version': config.osVersion, - ...(config.countryCode && { 'x-country-code': config.countryCode }), - ...(config.regionCode && { 'x-region-code': config.regionCode }), - 'x-user-segments': (config.userSegments ?? ['free']).join(','), - ...(config.deviceId && { 'x-device-id': config.deviceId }), - }); - - const request = async (path: string, options?: RequestInit): Promise => { - const res = await fetch(`${config.baseUrl}${path}`, { - ...options, - headers: { - ...(await headers()), - ...(options?.headers || {}), - }, - }); - if (!res.ok) { - const err = await res.text(); - throw new Error(`Survey API error: ${res.status} ${err}`); - } - return res.json() as Promise; - }; - - // In-memory cache for offline support - const responseCache = new Map(); - - let pollInterval: ReturnType | null = null; - - return { - async getActiveSurvey() { - return request<{ survey: ActiveSurvey | null }>('/surveys/active'); - }, - - async startSurvey(surveyId: string) { - const result = await request<{ - responseId: string; - startedAt: string; - currentQuestionIndex: number; - answers: Record; - }>(`/surveys/${surveyId}/start`, { method: 'POST' }); - // Cache the initial response - this.cacheResponse(surveyId, { - id: result.responseId, - surveyId, - userId: '', // Will be filled by server - answers: result.answers, - currentQuestionIndex: result.currentQuestionIndex, - startedAt: result.startedAt, - isComplete: false, - incentiveClaimed: false, - createdAt: result.startedAt, - updatedAt: result.startedAt, - }); - return result; - }, - - async submitAnswer(surveyId: string, questionId: string, answer: QuestionAnswer) { - const result = await request<{ - responseId: string; - currentQuestionIndex: number; - answers: Record; - }>(`/surveys/${surveyId}/response`, { - method: 'POST', - body: JSON.stringify({ questionId, answer }), - }); - // Update cache - const cached = responseCache.get(surveyId); - if (cached) { - cached.answers = result.answers; - cached.currentQuestionIndex = result.currentQuestionIndex; - cached.updatedAt = new Date().toISOString(); - } - return result; - }, - - async completeSurvey(surveyId: string) { - const result = await request<{ - success: boolean; - timeSpentSeconds: number; - incentiveClaimed: boolean; - }>(`/surveys/${surveyId}/complete`, { method: 'POST' }); - // Clear cache on completion - responseCache.delete(surveyId); - return result; - }, - - async dismissSurvey(surveyId: string) { - await request(`/surveys/${surveyId}/dismiss`, { method: 'POST' }); - responseCache.delete(surveyId); - }, - - pollSurveys(intervalMs = 60000) { - if (pollInterval) clearInterval(pollInterval); - pollInterval = setInterval(() => { - this.getActiveSurvey().catch(() => {}); - }, intervalMs); - return () => { - if (pollInterval) { - clearInterval(pollInterval); - pollInterval = null; - } - }; - }, - - getCachedResponse(surveyId: string) { - return responseCache.get(surveyId) ?? null; - }, - - cacheResponse(surveyId: string, response: Partial) { - const existing = responseCache.get(surveyId); - responseCache.set(surveyId, { - ...existing, - ...response, - } as SurveyResponse); - }, - }; -} - -// ============================================================================= -// Answer Validation -// ============================================================================= - -export function validateAnswer( - question: Question, - answer: QuestionAnswer -): { valid: boolean; error?: string } { - // Type matching - const expectedType = question.type === 'dropdown' ? 'single_choice' : - question.type === 'scale' ? 'rating' : - question.type === 'text_short' || question.type === 'text_long' ? 'text' : - question.type; - - if (answer.type !== expectedType) { - return { valid: false, error: `Expected ${expectedType}, got ${answer.type}` }; - } - - // Value range validation - if (answer.type === 'rating' || answer.type === 'nps') { - if (question.minValue !== undefined && answer.value < question.minValue) { - return { valid: false, error: `Value below minimum ${question.minValue}` }; - } - if (question.maxValue !== undefined && answer.value > question.maxValue) { - return { valid: false, error: `Value above maximum ${question.maxValue}` }; - } - } - - // Text length validation - if (answer.type === 'text') { - if (question.minLength !== undefined && answer.value.length < question.minLength) { - return { valid: false, error: `Text too short (min ${question.minLength})` }; - } - if (question.maxLength !== undefined && answer.value.length > question.maxLength) { - return { valid: false, error: `Text too long (max ${question.maxLength})` }; - } - } - - return { valid: true }; -} - -// ============================================================================= -// React Hook (optional) -// ============================================================================= - -export function createUseSurvey(client: SurveyClient) { - return function useSurvey() { - return { client }; - }; -} diff --git a/vendor/bytelyst/survey-client/tsconfig.json b/vendor/bytelyst/survey-client/tsconfig.json deleted file mode 100644 index 3686f56..0000000 --- a/vendor/bytelyst/survey-client/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "./dist", - "rootDir": "./src", - "declaration": true - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] -} diff --git a/vendor/bytelyst/swift-diagnostics/Package.swift b/vendor/bytelyst/swift-diagnostics/Package.swift deleted file mode 100644 index 1230ec5..0000000 --- a/vendor/bytelyst/swift-diagnostics/Package.swift +++ /dev/null @@ -1,33 +0,0 @@ -// swift-tools-version:5.9 -import PackageDescription - -let package = Package( - name: "ByteLystDiagnostics", - platforms: [ - .iOS(.v15), - .macOS(.v13), - .watchOS(.v8), - .tvOS(.v15) - ], - products: [ - .library( - name: "ByteLystDiagnostics", - targets: ["ByteLystDiagnostics"] - ), - ], - dependencies: [], - targets: [ - .target( - name: "ByteLystDiagnostics", - path: "Sources/ByteLystDiagnostics", - swiftSettings: [ - .enableExperimentalFeature("StrictConcurrency") - ] - ), - .testTarget( - name: "ByteLystDiagnosticsTests", - dependencies: ["ByteLystDiagnostics"], - path: "Tests/ByteLystDiagnosticsTests" - ), - ] -) diff --git a/vendor/bytelyst/swift-diagnostics/Sources/ByteLystDiagnostics/ByteLystDiagnostics.swift b/vendor/bytelyst/swift-diagnostics/Sources/ByteLystDiagnostics/ByteLystDiagnostics.swift deleted file mode 100644 index 80a9033..0000000 --- a/vendor/bytelyst/swift-diagnostics/Sources/ByteLystDiagnostics/ByteLystDiagnostics.swift +++ /dev/null @@ -1,73 +0,0 @@ -/** - * ByteLystDiagnostics - * - * Remote diagnostics and debug tracing client for the ByteLyst ecosystem. - * Provides polling, logging, tracing, network capture, and breadcrumbs for iOS/macOS. - * - * Example usage: - * ```swift - * import ByteLystDiagnostics - * - * // Configure - * let config = DiagnosticsConfiguration( - * productId: "myapp", - * anonymousInstallId: "install_123", - * platform: "ios", - * channel: "ios_app", - * osFamily: "ios", - * appVersion: "1.0.0", - * buildNumber: "100", - * releaseChannel: "stable", - * serverUrl: "https://api.bytelyst.com" - * ) - * - * await DiagnosticsClient.shared.configure(config) - * await DiagnosticsClient.shared.start() - * - * // Auto-instrumented trace - * let result = try await DiagnosticsClient.shared.trace(name: "fetchUser") { - * try await fetchUser() - * } - * - * // Manual breadcrumb - * await DiagnosticsClient.shared.breadcrumb( - * category: "user", - * message: "Tapped submit button", - * data: ["buttonId": AnyCodable("submit")] - * ) - * - * // Manual log - * await DiagnosticsClient.shared.log( - * level: .info, - * message: "User signed in", - * module: "Auth", - * context: ["userId": AnyCodable(userId)] - * ) - * ``` - */ - -// MARK: - Core -@_exported import Foundation - -// Types -public typealias ByteLystDiagnosticsTypes = DiagnosticsSession -public typealias ByteLystDiagnosticsLogLevel = DiagnosticsLogLevel -public typealias ByteLystDiagnosticsSessionStatus = DiagnosticsSessionStatus -public typealias ByteLystDiagnosticsCollectionLevel = DiagnosticsCollectionLevel -public typealias ByteLystDiagnosticsTraceSpan = DiagnosticsTraceSpan -public typealias ByteLystDiagnosticsLogEntry = DiagnosticsLogEntry -public typealias ByteLystDiagnosticsBreadcrumb = DiagnosticsBreadcrumb -public typealias ByteLystDiagnosticsNetworkRequest = DiagnosticsNetworkRequest -public typealias ByteLystDiagnosticsDeviceState = DiagnosticsDeviceState -public typealias ByteLystDiagnosticsIngestBatch = DiagnosticsIngestBatch -public typealias ByteLystDiagnosticsClientState = DiagnosticsClientState -public typealias ByteLystDiagnosticsConfiguration = DiagnosticsConfiguration -public typealias ByteLystDiagnosticsLogger = DiagnosticsLogger -public typealias ByteLystDiagnosticsNoOpLogger = NoOpDiagnosticsLogger -public typealias ByteLystDiagnosticsOSLogger = OSDiagnosticsLogger - -// Errors -public typealias ByteLystDiagnosticsError = DiagnosticsError - -// Version -public let ByteLystDiagnosticsVersion = "0.1.0" diff --git a/vendor/bytelyst/swift-diagnostics/Sources/ByteLystDiagnostics/Core/BreadcrumbTrail.swift b/vendor/bytelyst/swift-diagnostics/Sources/ByteLystDiagnostics/Core/BreadcrumbTrail.swift deleted file mode 100644 index 6c0f17f..0000000 --- a/vendor/bytelyst/swift-diagnostics/Sources/ByteLystDiagnostics/Core/BreadcrumbTrail.swift +++ /dev/null @@ -1,53 +0,0 @@ -import Foundation - -/// Ring buffer for breadcrumbs with fixed max size -public actor BreadcrumbTrail { - private var breadcrumbs: [DiagnosticsBreadcrumb] = [] - private let maxSize: Int - - public init(maxSize: Int = 100) { - self.maxSize = maxSize - } - - /// Add a breadcrumb to the trail - public func add(category: String, message: String, data: [String: AnyCodable]? = nil) { - let breadcrumb = DiagnosticsBreadcrumb( - timestamp: ISO8601DateFormatter().string(from: Date()), - category: category, - message: message, - data: data - ) - - breadcrumbs.append(breadcrumb) - - // Evict oldest if over limit - if breadcrumbs.count > maxSize { - breadcrumbs.removeFirst() - } - } - - /// Get all breadcrumbs (oldest first) - public func getAll() -> [DiagnosticsBreadcrumb] { - breadcrumbs - } - - /// Get last N breadcrumbs - public func getLast(_ n: Int) -> [DiagnosticsBreadcrumb] { - Array(breadcrumbs.suffix(n)) - } - - /// Get most recent breadcrumb - public func getMostRecent() -> DiagnosticsBreadcrumb? { - breadcrumbs.last - } - - /// Clear all breadcrumbs - public func clear() { - breadcrumbs.removeAll() - } - - /// Get current size - public func size() -> Int { - breadcrumbs.count - } -} diff --git a/vendor/bytelyst/swift-diagnostics/Sources/ByteLystDiagnostics/Core/Configuration.swift b/vendor/bytelyst/swift-diagnostics/Sources/ByteLystDiagnostics/Core/Configuration.swift deleted file mode 100644 index 2f7ef6a..0000000 --- a/vendor/bytelyst/swift-diagnostics/Sources/ByteLystDiagnostics/Core/Configuration.swift +++ /dev/null @@ -1,103 +0,0 @@ -import Foundation - -/// Client configuration -public struct DiagnosticsConfiguration: Sendable { - public let productId: String - public let userId: String? - public let anonymousInstallId: String - public let platform: String - public let channel: String - public let osFamily: String - public let appVersion: String - public let buildNumber: String - public let releaseChannel: String - public let serverUrl: String - public let pollIntervalMs: Int - public let maxBreadcrumbs: Int - public let captureConsole: Bool - public let captureErrors: Bool - public let captureNetwork: Bool - public let getAuthToken: (@Sendable () async throws -> String)? - - public init( - productId: String, - userId: String? = nil, - anonymousInstallId: String, - platform: String, - channel: String, - osFamily: String, - appVersion: String, - buildNumber: String, - releaseChannel: String, - serverUrl: String, - pollIntervalMs: Int = 5000, - maxBreadcrumbs: Int = 100, - captureConsole: Bool = true, - captureErrors: Bool = true, - captureNetwork: Bool = true, - getAuthToken: (@Sendable () async throws -> String)? = nil - ) { - self.productId = productId - self.userId = userId - self.anonymousInstallId = anonymousInstallId - self.platform = platform - self.channel = channel - self.osFamily = osFamily - self.appVersion = appVersion - self.buildNumber = buildNumber - self.releaseChannel = releaseChannel - self.serverUrl = serverUrl - self.pollIntervalMs = pollIntervalMs - self.maxBreadcrumbs = maxBreadcrumbs - self.captureConsole = captureConsole - self.captureErrors = captureErrors - self.captureNetwork = captureNetwork - self.getAuthToken = getAuthToken - } -} - -/// Logger protocol -public protocol DiagnosticsLogger: Sendable { - func debug(_ message: String, metadata: [String: any Sendable]?) - func info(_ message: String, metadata: [String: any Sendable]?) - func warn(_ message: String, metadata: [String: any Sendable]?) - func error(_ message: String, metadata: [String: any Sendable]?) -} - -/// Default no-op logger -public struct NoOpDiagnosticsLogger: DiagnosticsLogger { - public init() {} - public func debug(_ message: String, metadata: [String: any Sendable]?) {} - public func info(_ message: String, metadata: [String: any Sendable]?) {} - public func warn(_ message: String, metadata: [String: any Sendable]?) {} - public func error(_ message: String, metadata: [String: any Sendable]?) {} -} - -/// OSLog-based logger -public struct OSDiagnosticsLogger: DiagnosticsLogger { - private let subsystem: String - private let category: String - - public init(subsystem: String = "com.bytelyst.diagnostics", category: String = "DiagnosticsClient") { - self.subsystem = subsystem - self.category = category - } - - public func debug(_ message: String, metadata: [String: any Sendable]?) { - #if DEBUG - print("[DEBUG] \(message)") - #endif - } - - public func info(_ message: String, metadata: [String: any Sendable]?) { - print("[INFO] \(message)") - } - - public func warn(_ message: String, metadata: [String: any Sendable]?) { - print("[WARN] \(message)") - } - - public func error(_ message: String, metadata: [String: any Sendable]?) { - print("[ERROR] \(message)") - } -} diff --git a/vendor/bytelyst/swift-diagnostics/Sources/ByteLystDiagnostics/Core/DiagnosticsClient.swift b/vendor/bytelyst/swift-diagnostics/Sources/ByteLystDiagnostics/Core/DiagnosticsClient.swift deleted file mode 100644 index fd16053..0000000 --- a/vendor/bytelyst/swift-diagnostics/Sources/ByteLystDiagnostics/Core/DiagnosticsClient.swift +++ /dev/null @@ -1,397 +0,0 @@ -import Foundation - -/// Thread-safe diagnostics client using Swift actors -public actor DiagnosticsClient { - public static let shared = DiagnosticsClient() - - private var configuration: DiagnosticsConfiguration? - private var logger: DiagnosticsLogger = NoOpDiagnosticsLogger() - private var state: DiagnosticsClientState = .idle - private var breadcrumbs = BreadcrumbTrail(maxSize: 100) - private var logBuffer: [DiagnosticsLogEntry] = [] - private var traceBuffer: [DiagnosticsTraceSpan] = [] - private var networkBuffer: [DiagnosticsNetworkRequest] = [] - private var pollTimer: Timer? - private var flushTask: Task? - private var lastEtag: String? - private var networkInterceptor: NetworkInterceptor? - - // MARK: - Singleton - - private init() {} - - // MARK: - Configuration - - public func configure(_ config: DiagnosticsConfiguration, logger: DiagnosticsLogger? = nil) { - self.configuration = config - self.logger = logger ?? NoOpDiagnosticsLogger() - self.breadcrumbs = BreadcrumbTrail(maxSize: config.maxBreadcrumbs) - } - - // MARK: - Lifecycle - - public func start() async { - guard case .idle = state else { - await logger.warn("[diagnostics] Already started", metadata: nil) - return - } - - guard let config = configuration else { - await logger.error("[diagnostics] Not configured", metadata: nil) - state = .error(DiagnosticsError.notConfigured) - return - } - - await logger.info("[diagnostics] Starting diagnostics client", metadata: nil) - state = .polling(session: nil) - - // Initial poll - await pollForSession() - - // Setup network capture if enabled - if config.captureNetwork { - setupNetworkCapture() - } - - // Start flush timer (every 30 seconds) - startFlushTimer() - - await breadcrumbs.add(category: "diagnostics", message: "Client started") - } - - public func stop() async { - await logger.info("[diagnostics] Stopping diagnostics client", metadata: nil) - - pollTimer?.invalidate() - pollTimer = nil - - flushTask?.cancel() - flushTask = nil - - networkInterceptor?.stop() - networkInterceptor = nil - - // Final flush - await flush() - - state = .idle - await breadcrumbs.add(category: "diagnostics", message: "Client stopped") - } - - // MARK: - State - - public func isSessionActive() -> Bool { - if case .active = state { - return true - } - return false - } - - public func getCurrentSession() -> DiagnosticsSession? { - switch state { - case .active(let session), .polling(let session): - return session - default: - return nil - } - } - - public func getState() -> DiagnosticsClientState { - state - } - - // MARK: - Logging - - public func log( - level: DiagnosticsLogLevel, - message: String, - module: String = "unknown", - file: String? = nil, - line: Int? = nil, - function: String? = nil, - context: [String: AnyCodable] = [:], - correlationId: String? = nil - ) async { - let entry = DiagnosticsLogEntry( - level: level, - message: message, - timestamp: ISO8601DateFormatter().string(from: Date()), - module: module, - file: file, - line: line, - function: function, - context: context, - correlationId: correlationId - ) - - logBuffer.append(entry) - await breadcrumbs.add( - category: "log", - message: "[\(level.rawValue.uppercased())] \(String(message.prefix(100)))", - data: ["level": AnyCodable(level.rawValue)] - ) - - // Auto-flush on fatal - if level == .fatal { - Task { await flush() } - } - } - - // MARK: - Tracing - - public func trace( - name: String, - operation: () async throws -> T - ) async rethrows -> T { - let spanId = generateId() - let startTime = ISO8601DateFormatter().string(from: Date()) - - await breadcrumbs.add( - category: "trace", - message: "Starting: \(name)", - data: ["spanId": AnyCodable(spanId)] - ) - - do { - let result = try await operation() - let endTime = ISO8601DateFormatter().string(from: Date()) - let start = ISO8601DateFormatter().date(from: startTime) ?? Date() - let end = ISO8601DateFormatter().date(from: endTime) ?? Date() - let durationMs = end.timeIntervalSince(start) * 1000 - - let span = DiagnosticsTraceSpan( - spanId: spanId, - name: name, - startTime: startTime, - endTime: endTime, - durationMs: durationMs, - attributes: [:], - status: .ok - ) - traceBuffer.append(span) - - await breadcrumbs.add( - category: "trace", - message: "Completed: \(name)", - data: [ - "spanId": AnyCodable(spanId), - "durationMs": AnyCodable(durationMs) - ] - ) - - return result - } catch { - let endTime = ISO8601DateFormatter().string(from: Date()) - let start = ISO8601DateFormatter().date(from: startTime) ?? Date() - let end = ISO8601DateFormatter().date(from: endTime) ?? Date() - let durationMs = end.timeIntervalSince(start) * 1000 - - let span = DiagnosticsTraceSpan( - spanId: spanId, - name: name, - startTime: startTime, - endTime: endTime, - durationMs: durationMs, - attributes: [:], - status: .error, - statusMessage: error.localizedDescription - ) - traceBuffer.append(span) - - await breadcrumbs.add( - category: "trace", - message: "Failed: \(name)", - data: [ - "spanId": AnyCodable(spanId), - "error": AnyCodable(error.localizedDescription) - ] - ) - - throw error - } - } - - // MARK: - Breadcrumbs - - public func breadcrumb( - category: String, - message: String, - data: [String: AnyCodable]? = nil - ) async { - await breadcrumbs.add(category: category, message: message, data: data) - } - - public func getBreadcrumbs() -> [DiagnosticsBreadcrumb] { - breadcrumbs.getAll() - } - - // MARK: - Private - - private func pollForSession() async { - guard let config = configuration else { return } - - var request = URLRequest( - url: URL(string: "\(config.serverUrl)/api/diagnostics/config?productId=\(config.productId)&installId=\(config.anonymousInstallId)")! - ) - request.setValue("application/json", forHTTPHeaderField: "Accept") - - if let etag = lastEtag { - request.setValue(etag, forHTTPHeaderField: "If-None-Match") - } - - if let getToken = config.getAuthToken { - do { - let token = try await getToken() - request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - } catch { - await logger.error("[diagnostics] Failed to get auth token", metadata: ["error": error.localizedDescription]) - } - } - - do { - let (data, response) = try await URLSession.shared.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse else { - throw DiagnosticsError.invalidResponse - } - - if httpResponse.statusCode == 304 { - // No change - return - } - - guard httpResponse.statusCode == 200 else { - throw DiagnosticsError.httpError(statusCode: httpResponse.statusCode) - } - - // Store ETag - if let etag = httpResponse.allHeaderFields["Etag"] as? String { - lastEtag = etag - } - - let decoder = JSONDecoder() - let session = try? decoder.decode(DiagnosticsSession.self, from: data) - - if let session = session, session.status == .active { - if case .active = state { - // Already active, just update session - } else { - await logger.info("[diagnostics] Session activated", metadata: ["sessionId": session.id]) - await breadcrumbs.add(category: "diagnostics", message: "Session activated", data: ["sessionId": AnyCodable(session.id)]) - } - state = .active(session: session) - } else { - if case .active = state { - await logger.info("[diagnostics] Session ended", metadata: nil) - await breadcrumbs.add(category: "diagnostics", message: "Session ended") - } - state = .polling(session: nil) - } - } catch { - await logger.error("[diagnostics] Failed to poll for session", metadata: ["error": error.localizedDescription]) - state = .error(error) - } - } - - private func flush() async { - guard let session = getCurrentSession() else { - logBuffer.removeAll() - traceBuffer.removeAll() - networkBuffer.removeAll() - return - } - - let batch = DiagnosticsIngestBatch( - sessionId: session.id, - traces: traceBuffer.isEmpty ? nil : traceBuffer, - logs: logBuffer.isEmpty ? nil : logBuffer, - breadcrumbs: breadcrumbs.getAll().isEmpty ? nil : breadcrumbs.getAll(), - network: networkBuffer.isEmpty ? nil : networkBuffer - ) - - // Clear buffers - logBuffer.removeAll() - traceBuffer.removeAll() - networkBuffer.removeAll() - breadcrumbs.clear() - - guard let config = configuration else { return } - - var request = URLRequest( - url: URL(string: "\(config.serverUrl)/api/diagnostics/ingest")! - ) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - - if let getToken = config.getAuthToken { - do { - let token = try await getToken() - request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - } catch { - await logger.error("[diagnostics] Failed to get auth token for flush", metadata: ["error": error.localizedDescription]) - } - } - - do { - let encoder = JSONEncoder() - request.httpBody = try encoder.encode(batch) - - let (_, response) = try await URLSession.shared.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { - throw DiagnosticsError.flushFailed - } - - await logger.debug("[diagnostics] Flushed batch", metadata: [ - "logs": batch.logs?.count ?? 0, - "traces": batch.traces?.count ?? 0, - "network": batch.network?.count ?? 0 - ]) - } catch { - await logger.error("[diagnostics] Failed to flush batch", metadata: ["error": error.localizedDescription]) - - // Put items back in buffers for retry - if let logs = batch.logs { logBuffer.append(contentsOf: logs) } - if let traces = batch.traces { traceBuffer.append(contentsOf: traces) } - if let network = batch.network { networkBuffer.append(contentsOf: network) } - } - } - - private func startFlushTimer() { - flushTask = Task { - while !Task.isCancelled { - try? await Task.sleep(nanoseconds: 30 * 1_000_000_000) // 30 seconds - await flush() - } - } - } - - private func setupNetworkCapture() { - networkInterceptor = NetworkInterceptor { [weak self] request in - Task { [weak self] in - await self?.networkBuffer.append(request) - } - } - networkInterceptor?.start() - } - - private func generateId() -> String { - "\(Int(Date().timeIntervalSince1970 * 1000))_\(UUID().uuidString.prefix(7))" - } -} - -/// Client state enum -public enum DiagnosticsClientState: Sendable { - case idle - case polling(session: DiagnosticsSession?) - case active(session: DiagnosticsSession) - case error(Error) -} - -/// Diagnostics errors -public enum DiagnosticsError: Error, Sendable { - case notConfigured - case invalidResponse - case httpError(statusCode: Int) - case flushFailed -} diff --git a/vendor/bytelyst/swift-diagnostics/Sources/ByteLystDiagnostics/Core/Types.swift b/vendor/bytelyst/swift-diagnostics/Sources/ByteLystDiagnostics/Core/Types.swift deleted file mode 100644 index cf2038d..0000000 --- a/vendor/bytelyst/swift-diagnostics/Sources/ByteLystDiagnostics/Core/Types.swift +++ /dev/null @@ -1,335 +0,0 @@ -import Foundation - -/// Log severity levels (matches syslog/OpenTelemetry) -public enum DiagnosticsLogLevel: String, Codable, Sendable { - case debug - case info - case warn - case error - case fatal -} - -/// Session status from the server -public enum DiagnosticsSessionStatus: String, Codable, Sendable { - case pending - case active - case paused - case completed - case cancelled -} - -/// Collection level determines verbosity of captured data -public enum DiagnosticsCollectionLevel: String, Codable, Sendable { - case standard - case debug - case trace -} - -/// Diagnostic session configuration from server -public struct DiagnosticsSession: Codable, Sendable { - public let id: String - public let productId: String - public let status: DiagnosticsSessionStatus - public let collectionLevel: DiagnosticsCollectionLevel - public let captureLogs: Bool - public let captureNetwork: Bool - public let captureScreenshots: Bool - public let screenshotOnError: Bool - public let maxDurationMinutes: Int - public let createdAt: String - public let expiresAt: String - - public init( - id: String, - productId: String, - status: DiagnosticsSessionStatus, - collectionLevel: DiagnosticsCollectionLevel, - captureLogs: Bool, - captureNetwork: Bool, - captureScreenshots: Bool, - screenshotOnError: Bool, - maxDurationMinutes: Int, - createdAt: String, - expiresAt: String - ) { - self.id = id - self.productId = productId - self.status = status - self.collectionLevel = collectionLevel - self.captureLogs = captureLogs - self.captureNetwork = captureNetwork - self.captureScreenshots = captureScreenshots - self.screenshotOnError = screenshotOnError - self.maxDurationMinutes = maxDurationMinutes - self.createdAt = createdAt - self.expiresAt = expiresAt - } -} - -/// Span kind for OpenTelemetry compatibility -public enum DiagnosticsSpanKind: String, Codable, Sendable { - case internal - case server - case client - case producer - case consumer -} - -/// Span status -public enum DiagnosticsSpanStatus: String, Codable, Sendable { - case ok - case error - case unset -} - -/// OpenTelemetry-compatible trace span -public struct DiagnosticsTraceSpan: Codable, Sendable { - public let spanId: String - public let parentId: String? - public let name: String - public let kind: DiagnosticsSpanKind? - public let startTime: String - public let endTime: String? - public let durationMs: Double? - public let attributes: [String: AnyCodable] - public let status: DiagnosticsSpanStatus - public let statusMessage: String? - - public init( - spanId: String, - parentId: String? = nil, - name: String, - kind: DiagnosticsSpanKind? = nil, - startTime: String, - endTime: String? = nil, - durationMs: Double? = nil, - attributes: [String: AnyCodable] = [:], - status: DiagnosticsSpanStatus, - statusMessage: String? = nil - ) { - self.spanId = spanId - self.parentId = parentId - self.name = name - self.kind = kind - self.startTime = startTime - self.endTime = endTime - self.durationMs = durationMs - self.attributes = attributes - self.status = status - self.statusMessage = statusMessage - } -} - -/// Structured log entry -public struct DiagnosticsLogEntry: Codable, Sendable { - public let level: DiagnosticsLogLevel - public let message: String - public let timestamp: String - public let module: String - public let file: String? - public let line: Int? - public let function: String? - public let context: [String: AnyCodable] - public let correlationId: String? - - public init( - level: DiagnosticsLogLevel, - message: String, - timestamp: String, - module: String, - file: String? = nil, - line: Int? = nil, - function: String? = nil, - context: [String: AnyCodable] = [:], - correlationId: String? = nil - ) { - self.level = level - self.message = message - self.timestamp = timestamp - self.module = module - self.file = file - self.line = line - self.function = function - self.context = context - self.correlationId = correlationId - } -} - -/// Breadcrumb for timeline navigation -public struct DiagnosticsBreadcrumb: Codable, Sendable { - public let timestamp: String - public let category: String - public let message: String - public let data: [String: AnyCodable]? - - public init( - timestamp: String, - category: String, - message: String, - data: [String: AnyCodable]? = nil - ) { - self.timestamp = timestamp - self.category = category - self.message = message - self.data = data - } -} - -/// Network request/response capture -public struct DiagnosticsNetworkRequest: Codable, Sendable { - public let id: String - public let url: String - public let method: String - public let requestHeaders: [String: String] - public let requestBody: String? - public let status: Int? - public let responseHeaders: [String: String]? - public let responseBody: String? - public let startTime: String - public let endTime: String? - public let durationMs: Double? - public let error: String? - - public init( - id: String, - url: String, - method: String, - requestHeaders: [String: String] = [:], - requestBody: String? = nil, - status: Int? = nil, - responseHeaders: [String: String]? = nil, - responseBody: String? = nil, - startTime: String, - endTime: String? = nil, - durationMs: Double? = nil, - error: String? = nil - ) { - self.id = id - self.url = url - self.method = method - self.requestHeaders = requestHeaders - self.requestBody = requestBody - self.status = status - self.responseHeaders = responseHeaders - self.responseBody = responseBody - self.startTime = startTime - self.endTime = endTime - self.durationMs = durationMs - self.error = error - } -} - -/// Device state snapshot -public struct DiagnosticsDeviceState: Codable, Sendable { - public let memoryMB: Int? - public let batteryLevel: Double? - public let isCharging: Bool? - public let storageMB: Int? - public let networkType: String? - public let isOnline: Bool - public let thermalState: DiagnosticsThermalState? - - public init( - memoryMB: Int? = nil, - batteryLevel: Double? = nil, - isCharging: Bool? = nil, - storageMB: Int? = nil, - networkType: String? = nil, - isOnline: Bool, - thermalState: DiagnosticsThermalState? = nil - ) { - self.memoryMB = memoryMB - self.batteryLevel = batteryLevel - self.isCharging = isCharging - self.storageMB = storageMB - self.networkType = networkType - self.isOnline = isOnline - self.thermalState = thermalState - } -} - -/// Thermal state -public enum DiagnosticsThermalState: String, Codable, Sendable { - case nominal - case fair - case serious - case critical -} - -/// Client state -public enum DiagnosticsClientState: Sendable { - case idle - case polling(session: DiagnosticsSession?) - case active(session: DiagnosticsSession) - case error(Error) -} - -/// Ingest batch for sending to server -public struct DiagnosticsIngestBatch: Codable, Sendable { - public let sessionId: String - public let traces: [DiagnosticsTraceSpan]? - public let logs: [DiagnosticsLogEntry]? - public let breadcrumbs: [DiagnosticsBreadcrumb]? - public let network: [DiagnosticsNetworkRequest]? - - public init( - sessionId: String, - traces: [DiagnosticsTraceSpan]? = nil, - logs: [DiagnosticsLogEntry]? = nil, - breadcrumbs: [DiagnosticsBreadcrumb]? = nil, - network: [DiagnosticsNetworkRequest]? = nil - ) { - self.sessionId = sessionId - self.traces = traces - self.logs = logs - self.breadcrumbs = breadcrumbs - self.network = network - } -} - -/// Type-erased Codable wrapper for dictionary values -public struct AnyCodable: Codable, Sendable { - private let value: any Codable - - public init(_ value: any Codable) { - self.value = value - } - - public init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - if let boolValue = try? container.decode(Bool.self) { - value = boolValue - } else if let intValue = try? container.decode(Int.self) { - value = intValue - } else if let doubleValue = try? container.decode(Double.self) { - value = doubleValue - } else if let stringValue = try? container.decode(String.self) { - value = stringValue - } else if let arrayValue = try? container.decode([AnyCodable].self) { - value = arrayValue - } else if let dictValue = try? container.decode([String: AnyCodable].self) { - value = dictValue - } else { - value = "" - } - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - if let boolValue = value as? Bool { - try container.encode(boolValue) - } else if let intValue = value as? Int { - try container.encode(intValue) - } else if let doubleValue = value as? Double { - try container.encode(doubleValue) - } else if let stringValue = value as? String { - try container.encode(stringValue) - } else if let arrayValue = value as? [AnyCodable] { - try container.encode(arrayValue) - } else if let dictValue = value as? [String: AnyCodable] { - try container.encode(dictValue) - } else { - try container.encode("") - } - } -} diff --git a/vendor/bytelyst/swift-diagnostics/Sources/ByteLystDiagnostics/Device/DeviceState.swift b/vendor/bytelyst/swift-diagnostics/Sources/ByteLystDiagnostics/Device/DeviceState.swift deleted file mode 100644 index dcd60d6..0000000 --- a/vendor/bytelyst/swift-diagnostics/Sources/ByteLystDiagnostics/Device/DeviceState.swift +++ /dev/null @@ -1,191 +0,0 @@ -import Foundation -import UIKit -#if os(iOS) -import SystemConfiguration -#endif - -/// Device state collector -public struct DeviceStateCollector { - - /// Collect current device state - public static func collect() -> DiagnosticsDeviceState { - #if os(iOS) - return DiagnosticsDeviceState( - memoryMB: getMemoryUsage(), - batteryLevel: getBatteryLevel(), - isCharging: getIsCharging(), - storageMB: getStorageUsage(), - networkType: getNetworkType(), - isOnline: getIsOnline(), - thermalState: getThermalState() - ) - #elseif os(macOS) - return DiagnosticsDeviceState( - memoryMB: getMemoryUsage(), - batteryLevel: nil, - isCharging: nil, - storageMB: nil, - networkType: nil, - isOnline: getIsOnline(), - thermalState: nil - ) - #else - return DiagnosticsDeviceState( - memoryMB: nil, - batteryLevel: nil, - isCharging: nil, - storageMB: nil, - networkType: nil, - isOnline: true, - thermalState: nil - ) - #endif - } - - #if os(iOS) - private static func getMemoryUsage() -> Int? { - var info = mach_task_basic_info() - var count = mach_msg_type_number_t(MemoryLayout.size)/4 - - let kerr: kern_return_t = withUnsafeMutablePointer(to: &info) { - $0.withMemoryRebound(to: integer_t.self, capacity: 1) { - task_info(mach_task_self_, task_flavor_t(MACH_TASK_BASIC_INFO), $0, &count) - } - } - - guard kerr == KERN_SUCCESS else { return nil } - return Int(info.resident_size / 1024 / 1024) - } - - private static func getBatteryLevel() -> Double? { - UIDevice.current.isBatteryMonitoringEnabled = true - return Double(UIDevice.current.batteryLevel) - } - - private static func getIsCharging() -> Bool? { - UIDevice.current.isBatteryMonitoringEnabled = true - return UIDevice.current.batteryState == .charging - } - - private static func getStorageUsage() -> Int? { - do { - let attributes = try FileManager.default.attributesOfFileSystem(forPath: NSHomeDirectory()) - if let totalSize = attributes[.systemSize] as? NSNumber, - let freeSize = attributes[.systemFreeSize] as? NSNumber { - let usedSize = totalSize.int64Value - freeSize.int64Value - return Int(usedSize / 1024 / 1024) - } - } catch { - return nil - } - return nil - } - - private static func getNetworkType() -> String? { - // Simplified - would need more complex reachability check for actual implementation - if getIsOnline() { - return "wifi" // Default assumption - } - return "offline" - } - - private static func getThermalState() -> DiagnosticsThermalState? { - switch ProcessInfo.processInfo.thermalState { - case .nominal: - return .nominal - case .fair: - return .fair - case .serious: - return .serious - case .critical: - return .critical - @unknown default: - return nil - } - } - #endif - - private static func getIsOnline() -> Bool { - #if os(iOS) || os(macOS) - var zeroAddress = sockaddr_in() - zeroAddress.sin_len = UInt8(MemoryLayout.size) - zeroAddress.sin_family = sa_family_t(AF_INET) - - guard let defaultRouteReachability = withUnsafePointer(to: &zeroAddress, { - $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { - SCNetworkReachabilityCreateWithAddress(nil, $0) - } - }) else { - return false - } - - var flags: SCNetworkReachabilityFlags = [] - if !SCNetworkReachabilityGetFlags(defaultRouteReachability, &flags) { - return false - } - - let isReachable = flags.contains(.reachable) - let needsConnection = flags.contains(.connectionRequired) - - return isReachable && !needsConnection - #else - return true - #endif - } -} - -// MARK: - Connectivity Monitoring - -#if os(iOS) || os(macOS) -import SystemConfiguration - -/// Monitor network connectivity changes -public final class ConnectivityMonitor { - private var reachability: SCNetworkReachability? - private var callback: ((Bool) -> Void)? - - public init() { - var zeroAddress = sockaddr_in() - zeroAddress.sin_len = UInt8(MemoryLayout.size) - zeroAddress.sin_family = sa_family_t(AF_INET) - - reachability = withUnsafePointer(to: &zeroAddress) { - $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { - SCNetworkReachabilityCreateWithAddress(nil, $0) - } - } - } - - public func startMonitoring(callback: @escaping (Bool) -> Void) { - self.callback = callback - - guard let reachability = reachability else { return } - - let context = SCNetworkReachabilityContext( - version: 0, - info: Unmanaged.passUnretained(self).toOpaque(), - retain: nil, - release: nil, - copyDescription: nil - ) - - SCNetworkReachabilitySetCallback(reachability, { (_, flags, info) in - guard let info = info else { return } - let monitor = Unmanaged.fromOpaque(info).takeUnretainedValue() - - let isReachable = flags.contains(.reachable) - let needsConnection = flags.contains(.connectionRequired) - let isConnected = isReachable && !needsConnection - - monitor.callback?(isConnected) - }, &context) - - SCNetworkReachabilityScheduleWithRunLoop(reachability, CFRunLoopGetMain(), CFRunLoopMode.commonModes.rawValue) - } - - public func stopMonitoring() { - guard let reachability = reachability else { return } - SCNetworkReachabilityUnscheduleFromRunLoop(reachability, CFRunLoopGetMain(), CFRunLoopMode.commonModes.rawValue) - } -} -#endif diff --git a/vendor/bytelyst/swift-diagnostics/Sources/ByteLystDiagnostics/Network/NetworkInterceptor.swift b/vendor/bytelyst/swift-diagnostics/Sources/ByteLystDiagnostics/Network/NetworkInterceptor.swift deleted file mode 100644 index b7fd71b..0000000 --- a/vendor/bytelyst/swift-diagnostics/Sources/ByteLystDiagnostics/Network/NetworkInterceptor.swift +++ /dev/null @@ -1,160 +0,0 @@ -import Foundation - -/// Network interceptor using URLProtocol for automatic capture -public final class NetworkInterceptor: URLProtocol { - public static var onRequest: ((DiagnosticsNetworkRequest) -> Void)? - private static let shared = NetworkInterceptor() - private var requestId: String? - private var startTime: Date? - private var request: URLRequest? - - private lazy var session: URLSession = { - let config = URLSessionConfiguration.default - return URLSession(configuration: config, delegate: self, delegateQueue: nil) - }() - - private var dataTask: URLSessionDataTask? - - // MARK: - URLProtocol Overrides - - public override class func canInit(with request: URLRequest) -> Bool { - // Don't intercept our own requests - if request.value(forHTTPHeaderField: "X-Diagnostics-Intercepted") != nil { - return false - } - return true - } - - public override class func canonicalRequest(for request: URLRequest) -> URLRequest { - return request - } - - public override func startLoading() { - requestId = "\(Int(Date().timeIntervalSince1970 * 1000))_\(UUID().uuidString.prefix(7))" - startTime = Date() - - var newRequest = request - newRequest?.setValue("true", forHTTPHeaderField: "X-Diagnostics-Intercepted") - - self.request = newRequest - - dataTask = session.dataTask(with: newRequest!) - dataTask?.resume() - } - - public override func stopLoading() { - dataTask?.cancel() - } - - // MARK: - Public API - - public static func start() { - URLProtocol.registerClass(NetworkInterceptor.self) - } - - public static func stop() { - URLProtocol.unregisterClass(NetworkInterceptor.self) - } - - public func start(with handler: @escaping (DiagnosticsNetworkRequest) -> Void) { - NetworkInterceptor.onRequest = handler - NetworkInterceptor.start() - } - - public func stopInterceptor() { - NetworkInterceptor.stop() - NetworkInterceptor.onRequest = nil - } -} - -// MARK: - URLSessionDataDelegate - -extension NetworkInterceptor: URLSessionDataDelegate { - public func urlSession( - _ session: URLSession, - dataTask: URLSessionDataTask, - didReceive data: Data - ) { - client?.urlProtocol(self, didLoad: data) - } - - public func urlSession( - _ session: URLSession, - task: URLSessionTask, - didCompleteWithError error: Error? - ) { - guard let request = request, - let requestId = requestId, - let startTime = startTime else { - return - } - - let endTime = Date() - let durationMs = endTime.timeIntervalSince(startTime) * 1000 - - var networkRequest = DiagnosticsNetworkRequest( - id: requestId, - url: request.url?.absoluteString ?? "", - method: request.httpMethod ?? "GET", - requestHeaders: request.allHTTPHeaderFields?.mapValues { value in - NetworkInterceptor.sanitizeHeader(value, key: "") - } ?? [:], - requestBody: request.httpBody.flatMap { String(data: $0, encoding: .utf8) }, - startTime: ISO8601DateFormatter().string(from: startTime), - endTime: ISO8601DateFormatter().string(from: endTime), - durationMs: durationMs - ) - - if let error = error { - networkRequest = DiagnosticsNetworkRequest( - id: requestId, - url: networkRequest.url, - method: networkRequest.method, - requestHeaders: networkRequest.requestHeaders, - requestBody: networkRequest.requestBody, - status: nil, - responseHeaders: nil, - responseBody: nil, - startTime: networkRequest.startTime, - endTime: networkRequest.endTime, - durationMs: networkRequest.durationMs, - error: error.localizedDescription - ) - } else if let response = task.response as? HTTPURLResponse { - networkRequest = DiagnosticsNetworkRequest( - id: requestId, - url: networkRequest.url, - method: networkRequest.method, - requestHeaders: networkRequest.requestHeaders, - requestBody: networkRequest.requestBody, - status: response.statusCode, - responseHeaders: response.allHeaderFields as? [String: String], - responseBody: nil, // Don't capture response body (too large) - startTime: networkRequest.startTime, - endTime: networkRequest.endTime, - durationMs: networkRequest.durationMs, - error: nil - ) - } - - NetworkInterceptor.onRequest?(networkRequest) - - if let error = error { - client?.urlProtocol(self, didFailWithError: error) - } else { - client?.urlProtocolDidFinishLoading(self) - } - } - - private static func sanitizeHeader(_ value: String, key: String) -> String { - let sensitivePatterns = ["authorization", "cookie", "token", "api-key"] - let lowerKey = key.lowercased() - - for pattern in sensitivePatterns { - if lowerKey.contains(pattern) { - return "[REDACTED]" - } - } - return value - } -} diff --git a/vendor/bytelyst/swift-diagnostics/Tests/ByteLystDiagnosticsTests/DiagnosticsClientTests.swift b/vendor/bytelyst/swift-diagnostics/Tests/ByteLystDiagnosticsTests/DiagnosticsClientTests.swift deleted file mode 100644 index 63f1263..0000000 --- a/vendor/bytelyst/swift-diagnostics/Tests/ByteLystDiagnosticsTests/DiagnosticsClientTests.swift +++ /dev/null @@ -1,296 +0,0 @@ -import XCTest -@testable import ByteLystDiagnostics - -final class DiagnosticsClientTests: XCTestCase { - - override func setUp() { - super.setUp() - // Reset to initial state - Task { - // Client is actor, need to await - } - } - - // MARK: - Singleton Tests - - func testSharedInstance() { - let client1 = DiagnosticsClient.shared - let client2 = DiagnosticsClient.shared - XCTAssertTrue(client1 === client2, "Shared instance should be singleton") - } - - // MARK: - Configuration Tests - - func testConfiguration() async { - let config = DiagnosticsConfiguration( - productId: "test-app", - anonymousInstallId: "install-123", - platform: "ios", - channel: "ios_app", - osFamily: "ios", - appVersion: "1.0.0", - buildNumber: "100", - releaseChannel: "beta", - serverUrl: "https://api.test.com" - ) - - await DiagnosticsClient.shared.configure(config) - - let state = await DiagnosticsClient.shared.getState() - XCTAssertEqual(state, .idle) - } - - // MARK: - Session State Tests - - func testInitialSessionState() async { - let isActive = await DiagnosticsClient.shared.isSessionActive() - XCTAssertFalse(isActive) - - let session = await DiagnosticsClient.shared.getCurrentSession() - XCTAssertNil(session) - } - - // MARK: - Breadcrumb Tests - - func testBreadcrumbAdding() async { - let config = DiagnosticsConfiguration( - productId: "test-app", - anonymousInstallId: "install-123", - platform: "ios", - channel: "ios_app", - osFamily: "ios", - appVersion: "1.0.0", - buildNumber: "100", - releaseChannel: "beta", - serverUrl: "https://api.test.com" - ) - - await DiagnosticsClient.shared.configure(config) - - await DiagnosticsClient.shared.breadcrumb( - category: "navigation", - message: "Page loaded", - data: ["path": AnyCodable("/home")] - ) - - let breadcrumbs = await DiagnosticsClient.shared.getBreadcrumbs() - XCTAssertEqual(breadcrumbs.count, 1) - XCTAssertEqual(breadcrumbs.first?.category, "navigation") - XCTAssertEqual(breadcrumbs.first?.message, "Page loaded") - } - - func testMultipleBreadcrumbs() async { - let config = DiagnosticsConfiguration( - productId: "test-app", - anonymousInstallId: "install-123", - platform: "ios", - channel: "ios_app", - osFamily: "ios", - appVersion: "1.0.0", - buildNumber: "100", - releaseChannel: "beta", - serverUrl: "https://api.test.com" - ) - - await DiagnosticsClient.shared.configure(config) - - for i in 1...5 { - await DiagnosticsClient.shared.breadcrumb( - category: "test", - message: "Message \(i)" - ) - } - - let breadcrumbs = await DiagnosticsClient.shared.getBreadcrumbs() - XCTAssertEqual(breadcrumbs.count, 5) - } - - // MARK: - Tracing Tests - - func testSuccessfulTrace() async throws { - let config = DiagnosticsConfiguration( - productId: "test-app", - anonymousInstallId: "install-123", - platform: "ios", - channel: "ios_app", - osFamily: "ios", - appVersion: "1.0.0", - buildNumber: "100", - releaseChannel: "beta", - serverUrl: "https://api.test.com" - ) - - await DiagnosticsClient.shared.configure(config) - - let result = try await DiagnosticsClient.shared.trace(name: "test-operation") { - return 42 - } - - XCTAssertEqual(result, 42) - } - - func testFailingTrace() async { - let config = DiagnosticsConfiguration( - productId: "test-app", - anonymousInstallId: "install-123", - platform: "ios", - channel: "ios_app", - osFamily: "ios", - appVersion: "1.0.0", - buildNumber: "100", - releaseChannel: "beta", - serverUrl: "https://api.test.com" - ) - - await DiagnosticsClient.shared.configure(config) - - do { - _ = try await DiagnosticsClient.shared.trace(name: "failing-operation") { - throw TestError.test - } - XCTFail("Should have thrown") - } catch { - // Expected - XCTAssertTrue(error is TestError) - } - } - - // MARK: - Logging Tests - - func testLogAdding() async { - let config = DiagnosticsConfiguration( - productId: "test-app", - anonymousInstallId: "install-123", - platform: "ios", - channel: "ios_app", - osFamily: "ios", - appVersion: "1.0.0", - buildNumber: "100", - releaseChannel: "beta", - serverUrl: "https://api.test.com" - ) - - await DiagnosticsClient.shared.configure(config) - - await DiagnosticsClient.shared.log( - level: .info, - message: "Test message", - module: "TestModule" - ) - - // Log is buffered, no immediate assertion possible - // But breadcrumb should be created - let breadcrumbs = await DiagnosticsClient.shared.getBreadcrumbs() - XCTAssertTrue(breadcrumbs.contains { $0.category == "log" }) - } - - // MARK: - Types Tests - - func testDiagnosticsSessionEncoding() throws { - let session = DiagnosticsSession( - id: "ds_test123", - productId: "test-app", - status: .active, - collectionLevel: .debug, - captureLogs: true, - captureNetwork: true, - captureScreenshots: false, - screenshotOnError: true, - maxDurationMinutes: 60, - createdAt: "2026-03-03T12:00:00Z", - expiresAt: "2026-03-03T13:00:00Z" - ) - - let encoder = JSONEncoder() - let data = try encoder.encode(session) - - let decoder = JSONDecoder() - let decoded = try decoder.decode(DiagnosticsSession.self, from: data) - - XCTAssertEqual(decoded.id, session.id) - XCTAssertEqual(decoded.status, session.status) - XCTAssertEqual(decoded.collectionLevel, session.collectionLevel) - } - - func testLogEntryCreation() { - let entry = DiagnosticsLogEntry( - level: .error, - message: "Something went wrong", - timestamp: "2026-03-03T12:00:00Z", - module: "TestModule", - file: "Test.swift", - line: 42, - function: "testFunction", - context: ["key": AnyCodable("value")], - correlationId: "corr-123" - ) - - XCTAssertEqual(entry.level, .error) - XCTAssertEqual(entry.message, "Something went wrong") - XCTAssertEqual(entry.module, "TestModule") - XCTAssertEqual(entry.line, 42) - XCTAssertEqual(entry.correlationId, "corr-123") - } - - func testTraceSpanCreation() { - let span = DiagnosticsTraceSpan( - spanId: "span-123", - parentId: "parent-456", - name: "test-span", - kind: .internal, - startTime: "2026-03-03T12:00:00Z", - endTime: "2026-03-03T12:00:01Z", - durationMs: 1000, - attributes: ["key": AnyCodable("value")], - status: .ok, - statusMessage: nil - ) - - XCTAssertEqual(span.spanId, "span-123") - XCTAssertEqual(span.parentId, "parent-456") - XCTAssertEqual(span.name, "test-span") - XCTAssertEqual(span.status, .ok) - } - - func testBreadcrumbCreation() { - let breadcrumb = DiagnosticsBreadcrumb( - timestamp: "2026-03-03T12:00:00Z", - category: "navigation", - message: "User tapped button", - data: ["buttonId": AnyCodable("submit")] - ) - - XCTAssertEqual(breadcrumb.category, "navigation") - XCTAssertEqual(breadcrumb.message, "User tapped button") - XCTAssertNotNil(breadcrumb.data) - } - - func testAnyCodableEncoding() throws { - let value = AnyCodable("test-string") - let encoder = JSONEncoder() - let data = try encoder.encode(value) - - // Should not throw - XCTAssertFalse(data.isEmpty) - } - - func testAnyCodableIntEncoding() throws { - let value = AnyCodable(42) - let encoder = JSONEncoder() - let data = try encoder.encode(value) - - XCTAssertFalse(data.isEmpty) - } - - func testAnyCodableBoolEncoding() throws { - let value = AnyCodable(true) - let encoder = JSONEncoder() - let data = try encoder.encode(value) - - XCTAssertFalse(data.isEmpty) - } -} - -enum TestError: Error { - case test -} diff --git a/vendor/bytelyst/swift-platform-sdk/Package.swift b/vendor/bytelyst/swift-platform-sdk/Package.swift deleted file mode 100644 index 210cceb..0000000 --- a/vendor/bytelyst/swift-platform-sdk/Package.swift +++ /dev/null @@ -1,31 +0,0 @@ -// swift-tools-version: 5.9 -// ByteLystPlatformSDK — Shared Swift platform client for all ByteLyst iOS/watchOS/macOS apps. -// Lives in learning_ai_common_plat so every product app references ONE source of truth. - -import PackageDescription - -let package = Package( - name: "ByteLystPlatformSDK", - platforms: [ - .iOS(.v17), - .watchOS(.v10), - .macOS(.v14), - ], - products: [ - .library( - name: "ByteLystPlatformSDK", - targets: ["ByteLystPlatformSDK"] - ), - ], - targets: [ - .target( - name: "ByteLystPlatformSDK", - path: "Sources" - ), - .testTarget( - name: "ByteLystPlatformSDKTests", - dependencies: ["ByteLystPlatformSDK"], - path: "Tests" - ), - ] -) diff --git a/vendor/bytelyst/swift-platform-sdk/README.md b/vendor/bytelyst/swift-platform-sdk/README.md deleted file mode 100644 index e500e78..0000000 --- a/vendor/bytelyst/swift-platform-sdk/README.md +++ /dev/null @@ -1,215 +0,0 @@ -# ByteLystPlatformSDK - -Shared Swift platform client for all ByteLyst iOS/watchOS/macOS apps. Eliminates code duplication across products by providing a single source of truth for platform-service integration. - -## What's Inside - -| File | What It Does | -| --------------------- | ----------------------------------------------------------------------------------------------------------------------- | -| `BLPlatformConfig` | Product-specific configuration (productId, baseURL, bundleId, appGroupId) | -| `BLPlatformClient` | Generic HTTP client with auth injection, x-request-id, timeout | -| `BLKeychain` | Keychain CRUD for secure token storage | -| `BLTelemetryClient` | Telemetry event queue + batch flush (matches `@bytelyst/telemetry-client`) | -| `BLAuthClient` | Auth operations: login, register, refresh, password ops, email verify, delete account (matches `@bytelyst/auth-client`) | -| `BLFeatureFlagClient` | Feature flag polling from platform-service `/api/flags/poll` | -| `BLSyncEngine` | Generic offline-first sync engine with delta pull + batch push | -| `BLBlobClient` | Azure Blob Storage upload via SAS tokens from platform-service | -| `BLKillSwitchClient` | Kill switch check from platform-service (fail-open) | -| `BLLicenseClient` | License key activation + status via platform-service | -| `BLBiometricAuth` | Face ID / Touch ID wrapper (LocalAuthentication) | -| `BLCrashReporter` | MetricKit crash and hang reporting with local storage | -| `BLAuditLogger` | Local rotating JSON audit log for debugging | - -## Usage - -### 1. Add to Xcode Project - -In Xcode: **File → Add Package Dependencies → Add Local...** → select this directory: - -``` -../learning_ai_common_plat/packages/swift-platform-sdk/ -``` - -Or in `Package.swift`: - -```swift -.package(path: "../learning_ai_common_plat/packages/swift-platform-sdk") -``` - -### 2. Configure at App Launch - -```swift -import ByteLystPlatformSDK - -// Create config — one per app -let config = BLPlatformConfig.fromInfoPlist( - productId: "peakpulse", - defaultBaseURL: "https://api.peakpulse.app", - bundleId: "com.saravana.peakpulse" -) - -// Create shared HTTP client -let client = BLPlatformClient(config: config) - -// Create services -let telemetry = BLTelemetryClient(config: config, client: client) -let auth = BLAuthClient(config: config, client: client) -let flags = BLFeatureFlagClient(config: config, client: client) -``` - -### 3. Telemetry - -```swift -telemetry.start() -telemetry.trackEvent("info", module: "session", name: "session_started", metrics: ["elevation": 2450.0]) -telemetry.trackScreen("live_tracking") -// On app background: -telemetry.stop() -``` - -### 4. Auth - -```swift -// Login -let user = try await auth.login(email: "user@example.com", password: "secret") - -// Restore on launch -await auth.restoreSession() - -// Listen for state changes -auth.onAuthStateChanged = { state in - switch state { - case .loggedIn(let user): print("Hello, \(user.displayName)") - case .loggedOut: print("Signed out") - case .error(let msg): print("Auth error: \(msg)") - case .loading: print("Loading...") - } -} -``` - -### 5. Feature Flags - -```swift -flags.start(userId: auth.accessToken != nil ? "user-id" : nil) -if flags.isEnabled("peakpulse.pro_charts") { - // Show Pro charts -} -``` - -### 6. Sync Engine - -```swift -// Implement your product-specific adapter -struct PeakSessionSyncAdapter: BLSyncAdapter { - typealias SyncItem = PeakSessionDTO - - func pullDelta(since: Date?, client: BLPlatformClient) async throws -> [PeakSessionDTO] { - var path = "/api/peak-sessions/sync" - if let since { path += "?since=\(ISO8601DateFormatter().string(from: since))" } - return try await client.request(path: path, responseType: [PeakSessionDTO].self) - } - - func pushBatch(_ items: [BLOfflineQueueItem], client: BLPlatformClient) async throws -> BLBatchResult { - let body = try JSONEncoder().encode(["items": items.compactMap(\.payload)]) - let (data, _) = try await client.rawRequest(path: "/api/peak-sessions/batch", method: "POST", body: body) - return try JSONDecoder().decode(BLBatchResult.self, from: data) - } -} - -let syncEngine = BLSyncEngine( - config: config, - client: client, - adapter: PeakSessionSyncAdapter() -) -``` - -## Product Apps Using This SDK - -| Product | Repo | Wrappers | Status | -| ---------- | ----------------------------------- | -------- | ----------------------------------- | -| ChronoMind | `learning_ai_clock` | 5 files | ✅ Migrated (Cloud/ + Diagnostics/) | -| LysnrAI | `learning_voice_ai_agent` | 9 files | ✅ Migrated (Auth/ + Util/) | -| MindLyst | `learning_multimodal_memory_agents` | 4 files | ✅ Migrated (Services/) | -| PeakPulse | `learning_ai_peakpulse` | — | New — will use SDK from day one | -| NomGap | `learning_ai_fastgap` | — | React Native — uses TS packages | - -## What This Replaces - -Before this SDK, each iOS app had its own copy of platform integration code: - -| ChronoMind (old) | LysnrAI (old) | MindLyst (old) | SDK (new) | -| --------------------------------- | ------------------------------- | ------------------------------- | --------------------- | -| `KeychainHelper` (53 lines) | `KeychainHelper` (60 lines) | `KeychainHelper` (60 lines) | `BLKeychain` | -| `CMTelemetryService` (139 lines) | `TelemetryService` (288 lines) | `TelemetryService` (139 lines) | `BLTelemetryClient` | -| `CMAuthService` (359 lines) | `AuthService` (421 lines) | `AuthService` (389 lines) | `BLAuthClient` | -| `FeatureFlagService` (72 lines) | `FeatureFlagService` (71 lines) | `FeatureFlagService` (72 lines) | `BLFeatureFlagClient` | -| `CrashReporter` (153 lines) | — | — | `BLCrashReporter` | -| — | `BlobService` (118 lines) | — | `BLBlobClient` | -| — | `KillSwitchService` (48 lines) | — | `BLKillSwitchClient` | -| — | `LicenseService` (135 lines) | — | `BLLicenseClient` | -| — | `BiometricAuth` (65 lines) | — | `BLBiometricAuth` | -| — | `AuditLogger` (70 lines) | — | `BLAuditLogger` | -| `PlatformSyncManager` (450 lines) | Various sync files | — | `BLSyncEngine` | - -Total duplicated code eliminated: **~2,600+ lines across 3 product apps**. - -## Design Decisions - -1. **No `@MainActor`** — the SDK is thread-safe via NSLock. Product apps can wrap in `@MainActor` at the view model layer. -2. **No singletons** — product apps own the lifecycle. Create instances at app launch, inject where needed. -3. **No SwiftUI dependency** — pure Foundation. Works in watchOS, macOS, widgets, extensions. -4. **Protocol-based sync** — `BLSyncAdapter` lets each product define its own DTOs and endpoints while reusing the queue/timer/conflict plumbing. -5. **Fire-and-forget telemetry** — errors never surface to the user. Matches the TypeScript package behavior. - -## Platforms - -- iOS 17+ -- watchOS 10+ -- macOS 14+ - -## Broadcast & Survey - -New in v1.2: In-app messaging and survey capabilities. - -### Broadcast Client - -```swift -import ByteLystPlatformSDK - -let config = BLPlatformConfig( - productId: "lysnrai", - baseURL: URL(string: "https://api.bytelyst.io/v1")!, - getAuthToken: { await getToken() } -) - -let broadcastClient = BLBroadcastClient(config: config) - -// Start polling for messages -broadcastClient.startPolling(intervalMs: 60000) { messages in - // Handle new messages -} - -// SwiftUI UI components -BLInAppMessageBanner(client: broadcastClient, position: .top) -BLBroadcastModal(client: broadcastClient) -``` - -### Survey Client - -```swift -let surveyClient = BLSurveyClient(config: config) - -// Check for active surveys -let (survey, _) = await surveyClient.getActiveSurvey() - -// Start and complete survey -await surveyClient.startSurvey(surveyId: survey.id) -await surveyClient.submitAnswer(surveyId: survey.id, questionId: "q1", answer: answer) -await surveyClient.completeSurvey(surveyId: survey.id) - -// SwiftUI modal -BLSurveyModal(client: surveyClient) -``` - -See [Broadcast & Survey Guide](BROADCAST_SURVEY_GUIDE.md) for full documentation. - diff --git a/vendor/bytelyst/swift-platform-sdk/Sources/BLAuditLogger.swift b/vendor/bytelyst/swift-platform-sdk/Sources/BLAuditLogger.swift deleted file mode 100644 index 89c4802..0000000 --- a/vendor/bytelyst/swift-platform-sdk/Sources/BLAuditLogger.swift +++ /dev/null @@ -1,82 +0,0 @@ -// ── Audit Logger ──────────────────────────────────────────── -// Generic local audit logger that tracks user actions for debugging. -// Stores events in a rotating JSON file (configurable max entries). -// Product apps configure with a product-specific file name. - -import Foundation - -/// Audit event stored locally. -public struct BLAuditEvent: Codable, Sendable { - public let id: String - public let action: String - public let details: String? - public let timestamp: Date - - public init(action: String, details: String? = nil) { - self.id = UUID().uuidString - self.action = action - self.details = details - self.timestamp = Date() - } -} - -/// Generic local audit logger for all ByteLyst iOS apps. -/// Stores events in a rotating JSON file in the Documents directory. -public enum BLAuditLogger { - - private static var maxEvents = 1000 - private static var fileName = "audit_log.json" - - /// Configure the logger with a product-specific file name and max events. - public static func configure(fileName: String = "audit_log.json", maxEvents: Int = 1000) { - self.fileName = fileName - self.maxEvents = maxEvents - } - - private static var fileURL: URL { - let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! - return docs.appendingPathComponent(fileName) - } - - /// Log a user action. - public static func log(_ action: String, details: String? = nil) { - let event = BLAuditEvent(action: action, details: details) - - var events = loadEvents() - events.append(event) - - // Rotate: keep only the most recent maxEvents - if events.count > maxEvents { - events = Array(events.suffix(maxEvents)) - } - - saveEvents(events) - } - - /// Get all logged events (newest first). - public static func getEvents(limit: Int = 100) -> [BLAuditEvent] { - let events = loadEvents() - return Array(events.suffix(limit).reversed()) - } - - /// Clear all audit logs. - public static func clear() { - try? FileManager.default.removeItem(at: fileURL) - } - - // MARK: - Persistence - - private static func loadEvents() -> [BLAuditEvent] { - guard let data = try? Data(contentsOf: fileURL) else { return [] } - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - return (try? decoder.decode([BLAuditEvent].self, from: data)) ?? [] - } - - private static func saveEvents(_ events: [BLAuditEvent]) { - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 - guard let data = try? encoder.encode(events) else { return } - try? data.write(to: fileURL, options: .atomic) - } -} diff --git a/vendor/bytelyst/swift-platform-sdk/Sources/BLAuthClient.swift b/vendor/bytelyst/swift-platform-sdk/Sources/BLAuthClient.swift deleted file mode 100644 index 89b0616..0000000 --- a/vendor/bytelyst/swift-platform-sdk/Sources/BLAuthClient.swift +++ /dev/null @@ -1,665 +0,0 @@ -// ── Auth Client ────────────────────────────────────────────── -// Generic auth client matching @bytelyst/auth-client TypeScript interface. -// Login, register, refresh, forgot/reset/change password, verify email, delete account. -// SmartAuth v2: social login, MFA (TOTP), passkeys, device trust. -// Token storage via BLKeychain. Product apps configure via BLPlatformConfig. - -import Foundation - -// MARK: - Public Types - -public struct BLAuthUser: Codable, Sendable { - public let id: String - public let email: String - public let displayName: String - public let plan: String - public let role: String - - enum CodingKeys: String, CodingKey { - case id, email, displayName, plan, role - } - - public init(id: String, email: String, displayName: String, plan: String = "free", role: String = "user") { - self.id = id - self.email = email - self.displayName = displayName - self.plan = plan - self.role = role - } - - public init(from decoder: Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - id = try c.decode(String.self, forKey: .id) - email = try c.decode(String.self, forKey: .email) - displayName = try c.decode(String.self, forKey: .displayName) - plan = try c.decodeIfPresent(String.self, forKey: .plan) ?? "free" - role = try c.decodeIfPresent(String.self, forKey: .role) ?? "user" - } -} - -public enum BLAuthState: Sendable { - case loading - case loggedOut - case loggedIn(BLAuthUser) - case mfaRequired(BLMfaChallenge) - case error(String) -} - -// MARK: - SmartAuth Types - -/// MFA challenge returned when login requires multi-factor verification. -public struct BLMfaChallenge: Codable, Sendable { - public let mfaRequired: Bool - public let mfaChallenge: String - public let methods: [String] - - public init(mfaRequired: Bool, mfaChallenge: String, methods: [String]) { - self.mfaRequired = mfaRequired - self.mfaChallenge = mfaChallenge - self.methods = methods - } -} - -/// TOTP setup response with secret URI and recovery codes. -public struct BLTotpSetup: Codable, Sendable { - public let otpauthUri: String - public let qrCode: String - public let recoveryCodes: [String] -} - -/// MFA status for the current user. -public struct BLMfaStatus: Codable, Sendable { - public let mfaEnabled: Bool - public let methods: [String] - public let recoveryCodesRemaining: Int -} - -/// Linked OAuth provider. -public struct BLAuthProvider: Codable, Sendable { - public let provider: String - public let email: String - public let linkedAt: String - public let lastUsedAt: String? -} - -/// Passkey metadata. -public struct BLPasskey: Codable, Sendable { - public let id: String - public let friendlyName: String - public let deviceType: String - public let lastUsedAt: String? - public let createdAt: String -} - -/// Trusted/remembered device. -public struct BLDevice: Codable, Sendable { - public let fingerprint: String - public let trustLevel: String - public let deviceInfo: DeviceInfo? - public let lastIp: String? - public let lastLocation: String? - public let trustExpiresAt: String? - public let createdAt: String - public let lastSeenAt: String - public let isTrusted: Bool - - public struct DeviceInfo: Codable, Sendable { - public let userAgent: String? - public let platform: String? - public let model: String? - public let os: String? - } - - /// Convenience: display name derived from device info. - public var name: String { - deviceInfo?.model ?? deviceInfo?.platform ?? fingerprint.prefix(8).description - } - - /// Convenience: platform string. - public var platform: String { - deviceInfo?.platform ?? "unknown" - } - - /// Stable identifier for SwiftUI ForEach. - public var id: String { fingerprint } -} - -/// Login event for security log. -public struct BLLoginEvent: Codable, Sendable { - public let id: String - public let eventType: String - public let method: String - public let ip: String - public let geo: BLGeo? - public let riskScore: Int - public let createdAt: String -} - -/// Geo location. -public struct BLGeo: Codable, Sendable { - public let country: String - public let city: String -} - -// MARK: - Phase 5C–5E Types - -/// Decrypted TOTP secret for local code generation in the auth app. -public struct BLTotpSecret: Codable, Sendable { - public let secret: String - public let issuer: String - public let accountName: String - public let digits: Int - public let period: Int - public let algorithm: String -} - -/// Pending push MFA approval. -public struct BLPushApproval: Codable, Sendable { - public let id: String - public let requestProductId: String - public let requestPlatform: String - public let requestIp: String - public let requestGeo: BLGeo? - public let createdAt: String - public let expiresAt: String -} - -/// Push approval response after approve/deny. -public struct BLPushApprovalResponse: Codable, Sendable { - public let id: String - public let status: String - public let respondedAt: String? -} - -/// QR challenge for desktop/TV login. -public struct BLQrChallenge: Codable, Sendable { - public let id: String - public let challengeToken: String - public let expiresAt: String -} - -/// QR challenge poll status. -public struct BLQrStatus: Codable, Sendable { - public let status: String - public let accessToken: String? - public let refreshToken: String? - public let user: BLAuthUser? -} - -// MARK: - Auth Errors - -/// Auth-specific errors for SmartAuth flows. -public enum BLAuthError: LocalizedError { - case mfaRequired(BLMfaChallenge) - - public var errorDescription: String? { - switch self { - case .mfaRequired: return "Multi-factor authentication required" - } - } -} - -// MARK: - Auth Client - -/// Generic auth client for all ByteLyst iOS apps. -/// Handles login, register, token refresh, password operations, and account management. -/// SmartAuth v2: social login, MFA, passkeys, device trust, step-up auth. -/// Stores tokens in Keychain. Notifies via `onAuthStateChanged` callback. -public final class BLAuthClient { - - private let config: BLPlatformConfig - private let client: BLPlatformClient - private let keychainService: String - - /// Called whenever auth state changes. Set by the product app's view model / observable. - public var onAuthStateChanged: ((BLAuthState) -> Void)? - - /// Called when tokens are updated (for wiring into sync managers). - public var onTokensUpdated: ((String?) -> Void)? - - private var refreshTimer: Timer? - - private struct TokenResponse: Codable { - let accessToken: String - let refreshToken: String - let user: BLAuthUser - } - - private struct RefreshResponse: Codable { - let accessToken: String - let refreshToken: String - } - - private struct MessageResponse: Codable { - let message: String - } - - public init(config: BLPlatformConfig, client: BLPlatformClient) { - self.config = config - self.client = client - self.keychainService = config.bundleId - } - - // MARK: - Token Access - - public var accessToken: String? { - BLKeychain.read(service: keychainService, key: "access_token") - } - - public var refreshTokenValue: String? { - BLKeychain.read(service: keychainService, key: "refresh_token") - } - - public var isAuthenticated: Bool { - accessToken != nil && !(accessToken?.isEmpty ?? true) - } - - // MARK: - Auth Operations - - /// Login with email and password. - /// If MFA is enabled, throws `BLAuthError.mfaRequired` with the challenge. - public func login(email: String, password: String) async throws -> BLAuthUser { - let body: [String: String] = [ - "email": email, - "password": password, - "productId": config.productId, - ] - let (data, _) = try await client.rawRequest(path: "/api/auth/login", method: "POST", body: body) - // Check for MFA challenge response - if let challenge = try? JSONDecoder().decode(BLMfaChallenge.self, from: data), - challenge.mfaRequired { - onAuthStateChanged?(.mfaRequired(challenge)) - throw BLAuthError.mfaRequired(challenge) - } - let result = try JSONDecoder().decode(TokenResponse.self, from: data) - saveTokens(access: result.accessToken, refresh: result.refreshToken) - startRefreshTimer() - onAuthStateChanged?(.loggedIn(result.user)) - return result.user - } - - /// Register a new account. - public func register(displayName: String, email: String, password: String) async throws -> BLAuthUser { - let body: [String: String] = [ - "displayName": displayName, - "email": email, - "password": password, - "productId": config.productId, - ] - let (data, _) = try await client.rawRequest(path: "/api/auth/register", method: "POST", body: body) - let result = try JSONDecoder().decode(TokenResponse.self, from: data) - saveTokens(access: result.accessToken, refresh: result.refreshToken) - startRefreshTimer() - onAuthStateChanged?(.loggedIn(result.user)) - return result.user - } - - /// Fetch current user profile. - public func getMe() async throws -> BLAuthUser { - return try await client.request(path: "/api/auth/me", responseType: BLAuthUser.self) - } - - /// Refresh the access token using the stored refresh token. - @discardableResult - public func refreshAccessToken() async -> Bool { - guard let rt = refreshTokenValue, !rt.isEmpty else { return false } - let body = ["refreshToken": rt] - - do { - let (data, _) = try await client.rawRequest(path: "/api/auth/refresh", method: "POST", body: body) - let result = try JSONDecoder().decode(RefreshResponse.self, from: data) - saveTokens(access: result.accessToken, refresh: result.refreshToken) - return true - } catch { - if let netErr = error as? BLNetworkError, netErr.statusCode == 401 { - logout() - } - return false - } - } - - /// Request password reset email. - public func forgotPassword(email: String) async throws { - let body = ["email": email, "productId": config.productId] - _ = try await client.rawRequest(path: "/api/auth/forgot-password", method: "POST", body: body) - } - - /// Reset password with token. - public func resetPassword(token: String, newPassword: String) async throws { - let body = ["token": token, "newPassword": newPassword] - _ = try await client.rawRequest(path: "/api/auth/reset-password", method: "POST", body: body) - } - - /// Change password (authenticated). - public func changePassword(currentPassword: String, newPassword: String) async throws { - let body = ["currentPassword": currentPassword, "newPassword": newPassword] - _ = try await client.rawRequest(path: "/api/auth/change-password", method: "POST", body: body) - } - - /// Verify email with token. - public func verifyEmail(token: String) async throws { - let body = ["token": token] - _ = try await client.rawRequest(path: "/api/auth/verify-email", method: "POST", body: body) - } - - /// Resend verification email. - public func resendVerification(email: String) async throws { - let body = ["email": email, "productId": config.productId] - _ = try await client.rawRequest(path: "/api/auth/resend-verification", method: "POST", body: body) - } - - /// Delete account (requires password confirmation). - public func deleteAccount(password: String) async throws { - let body = ["password": password] - _ = try await client.rawRequest(path: "/api/auth/account", method: "DELETE", body: body) - logout() - } - - /// Logout — clear tokens and notify. - public func logout() { - stopRefreshTimer() - clearTokens() - onAuthStateChanged?(.loggedOut) - } - - // MARK: - Social Login (SmartAuth v2) - - /// Login with Google id_token. - public func loginWithGoogle(idToken: String) async throws -> BLAuthUser { - return try await socialLogin(provider: "google", idToken: idToken) - } - - /// Login with Microsoft id_token. - public func loginWithMicrosoft(idToken: String) async throws -> BLAuthUser { - return try await socialLogin(provider: "microsoft", idToken: idToken) - } - - /// Login with Apple id_token. - public func loginWithApple(idToken: String) async throws -> BLAuthUser { - return try await socialLogin(provider: "apple", idToken: idToken) - } - - /// Generic social login — sends id_token to /auth/oauth/{provider}. - private func socialLogin(provider: String, idToken: String) async throws -> BLAuthUser { - let body: [String: String] = ["idToken": idToken, "productId": config.productId] - let (data, _) = try await client.rawRequest( - path: "/api/auth/oauth/\(provider)", - method: "POST", - body: body - ) - // Server may return MFA challenge or tokens - if let challenge = try? JSONDecoder().decode(BLMfaChallenge.self, from: data), - challenge.mfaRequired { - onAuthStateChanged?(.mfaRequired(challenge)) - throw BLAuthError.mfaRequired(challenge) - } - let result = try JSONDecoder().decode(TokenResponse.self, from: data) - saveTokens(access: result.accessToken, refresh: result.refreshToken) - startRefreshTimer() - onAuthStateChanged?(.loggedIn(result.user)) - return result.user - } - - // MARK: - MFA (SmartAuth v2) - - /// Verify MFA challenge (TOTP code or recovery code). - public func verifyMfa(challengeToken: String, code: String, method: String = "totp") async throws -> BLAuthUser { - let body: [String: String] = [ - "challengeToken": challengeToken, - "code": code, - "method": method, - ] - let (data, _) = try await client.rawRequest(path: "/api/auth/mfa/verify", method: "POST", body: body) - let result = try JSONDecoder().decode(TokenResponse.self, from: data) - saveTokens(access: result.accessToken, refresh: result.refreshToken) - startRefreshTimer() - return result.user - } - - /// Begin TOTP setup — returns otpauth URI, QR code, and recovery codes. - public func setupTotp() async throws -> BLTotpSetup { - return try await client.request(path: "/api/auth/mfa/totp/setup", method: "POST", responseType: BLTotpSetup.self) - } - - /// Verify TOTP setup with a code from the authenticator app. - public func verifyTotpSetup(code: String) async throws { - let body = ["code": code] - _ = try await client.rawRequest(path: "/api/auth/mfa/totp/verify-setup", method: "POST", body: body) - } - - /// Disable MFA (requires step-up token via X-Step-Up-Token header). - public func disableMfa() async throws { - _ = try await client.rawRequest(path: "/api/auth/mfa/totp", method: "DELETE") - } - - /// Get current MFA status. - public func getMfaStatus() async throws -> BLMfaStatus { - return try await client.request(path: "/api/auth/mfa/status", responseType: BLMfaStatus.self) - } - - /// Regenerate recovery codes (requires step-up). - public func regenerateRecoveryCodes() async throws -> [String] { - struct CodesResponse: Codable { let recoveryCodes: [String] } - let result = try await client.request( - path: "/api/auth/mfa/recovery/regenerate", - method: "POST", - responseType: CodesResponse.self - ) - return result.recoveryCodes - } - - // MARK: - Providers (SmartAuth v2) - - /// List linked OAuth providers. - public func getProviders() async throws -> [BLAuthProvider] { - return try await client.request(path: "/api/auth/providers", responseType: [BLAuthProvider].self) - } - - /// Link an OAuth provider to the current account. - public func linkProvider(provider: String, idToken: String) async throws { - let body = ["provider": provider, "idToken": idToken] - _ = try await client.rawRequest(path: "/api/auth/providers/link", method: "POST", body: body) - } - - /// Unlink an OAuth provider. - public func unlinkProvider(provider: String) async throws { - _ = try await client.rawRequest(path: "/api/auth/providers/\(provider)", method: "DELETE") - } - - // MARK: - Passkeys (SmartAuth v2) - - /// Get passkey registration options from server. - public func getPasskeyRegistrationOptions() async throws -> Data { - let (data, _) = try await client.rawRequest( - path: "/api/auth/passkeys/register/options", - method: "POST" - ) - return data - } - - /// Verify passkey registration with attestation response. - public func verifyPasskeyRegistration(attestation: [String: Any], friendlyName: String) async throws { - var payload = attestation - payload["friendlyName"] = friendlyName - let jsonData = try JSONSerialization.data(withJSONObject: payload) - // Wrap raw JSON data in a Codable struct for BLPlatformClient - _ = try await client.rawRequest( - path: "/api/auth/passkeys/register/verify", - method: "POST", - rawBody: jsonData - ) - } - - /// Get passkey authentication options from server. - public func getPasskeyAuthenticationOptions() async throws -> Data { - let (data, _) = try await client.rawRequest( - path: "/api/auth/passkeys/authenticate/options", - method: "POST" - ) - return data - } - - /// Verify passkey authentication with assertion response. - public func verifyPasskeyAuthentication(assertion: [String: Any]) async throws -> BLAuthUser { - let jsonData = try JSONSerialization.data(withJSONObject: assertion) - let (responseData, _) = try await client.rawRequest( - path: "/api/auth/passkeys/authenticate/verify", - method: "POST", - rawBody: jsonData - ) - let result = try JSONDecoder().decode(TokenResponse.self, from: responseData) - saveTokens(access: result.accessToken, refresh: result.refreshToken) - startRefreshTimer() - onAuthStateChanged?(.loggedIn(result.user)) - return result.user - } - - /// List registered passkeys. - public func listPasskeys() async throws -> [BLPasskey] { - return try await client.request(path: "/api/auth/passkeys", responseType: [BLPasskey].self) - } - - /// Delete a passkey (requires step-up). - public func deletePasskey(passkeyId: String) async throws { - _ = try await client.rawRequest(path: "/api/auth/passkeys/\(passkeyId)", method: "DELETE") - } - - // MARK: - Devices (SmartAuth v2) - - /// List devices for current user. - public func listDevices() async throws -> [BLDevice] { - struct DevicesResponse: Codable { let devices: [BLDevice] } - let result = try await client.request(path: "/api/auth/devices", responseType: DevicesResponse.self) - return result.devices - } - - /// Trust the current device (promotes to trusted, skips MFA for 90 days). - public func trustDevice() async throws { - _ = try await client.rawRequest(path: "/api/auth/devices/trust", method: "POST") - } - - /// Revoke trust on a specific device by fingerprint. - public func revokeDevice(fingerprint: String) async throws { - _ = try await client.rawRequest(path: "/api/auth/devices/\(fingerprint)", method: "DELETE") - } - - /// Revoke all device trust. - public func revokeAllDevices() async throws { - _ = try await client.rawRequest(path: "/api/auth/devices/revoke-all", method: "POST") - } - - // MARK: - Step-Up Auth (SmartAuth v2) - - /// Perform step-up authentication. Returns a short-lived step-up token. - public func stepUp(method: String, credential: String) async throws -> String { - let body = ["method": method, "credential": credential] - struct StepUpResponse: Codable { let stepUpToken: String } - let result = try await client.request( - path: "/api/auth/step-up", - method: "POST", - body: body, - responseType: StepUpResponse.self - ) - return result.stepUpToken - } - - // MARK: - Login History (SmartAuth v2) - - /// Get login events for the current user. - public func getLoginHistory(limit: Int = 20) async throws -> [BLLoginEvent] { - struct EventsResponse: Codable { let events: [BLLoginEvent] } - let result = try await client.request( - path: "/api/auth/login-events?limit=\(limit)", - responseType: EventsResponse.self - ) - return result.events - } - - // MARK: - TOTP Secret Retrieval (Phase 5C) - - /// Get the decrypted TOTP secret for local code generation (auth app). - public func getTotpSecret() async throws -> BLTotpSecret { - return try await client.request(path: "/api/auth/mfa/totp/secret", responseType: BLTotpSecret.self) - } - - // MARK: - Push Approvals (Phase 5D) - - /// List pending push MFA approvals for the current user. - public func getPendingApprovals() async throws -> [BLPushApproval] { - return try await client.request(path: "/api/auth/mfa/push/pending", responseType: [BLPushApproval].self) - } - - /// Respond to a push MFA approval (approve or deny). - public func respondToApproval(approvalId: String, action: String) async throws -> BLPushApprovalResponse { - let body = ["action": action] - return try await client.request(path: "/api/auth/mfa/push/\(approvalId)/respond", method: "POST", body: body, responseType: BLPushApprovalResponse.self) - } - - // MARK: - QR Auth (Phase 5E) - - /// Confirm a QR login challenge from the auth app. - public func confirmQrLogin(challengeToken: String) async throws { - let body = ["challengeToken": challengeToken] - _ = try await client.rawRequest(path: "/api/auth/qr/confirm", method: "POST", body: body) - } - - /// Restore session from stored tokens. Call on app launch. - public func restoreSession() async { - guard isAuthenticated else { - onAuthStateChanged?(.loggedOut) - return - } - - onAuthStateChanged?(.loading) - - do { - let user = try await getMe() - onAuthStateChanged?(.loggedIn(user)) - startRefreshTimer() - } catch { - // Try refresh - let ok = await refreshAccessToken() - if ok { - do { - let user = try await getMe() - onAuthStateChanged?(.loggedIn(user)) - startRefreshTimer() - } catch { - onAuthStateChanged?(.loggedOut) - } - } else { - onAuthStateChanged?(.loggedOut) - } - } - } - - // MARK: - Private - - private func saveTokens(access: String, refresh: String) { - BLKeychain.save(service: keychainService, key: "access_token", value: access) - BLKeychain.save(service: keychainService, key: "refresh_token", value: refresh) - client.authToken = access - onTokensUpdated?(access) - } - - private func clearTokens() { - BLKeychain.delete(service: keychainService, key: "access_token") - BLKeychain.delete(service: keychainService, key: "refresh_token") - client.authToken = nil - onTokensUpdated?(nil) - } - - private func startRefreshTimer() { - stopRefreshTimer() - // Refresh every 12 minutes (access tokens expire in 15 minutes per PRD) - refreshTimer = Timer.scheduledTimer(withTimeInterval: 12 * 60, repeats: true) { [weak self] _ in - guard let self else { return } - Task { await self.refreshAccessToken() } - } - } - - private func stopRefreshTimer() { - refreshTimer?.invalidate() - refreshTimer = nil - } -} diff --git a/vendor/bytelyst/swift-platform-sdk/Sources/BLAuthUI.swift b/vendor/bytelyst/swift-platform-sdk/Sources/BLAuthUI.swift deleted file mode 100644 index 4a86ecb..0000000 --- a/vendor/bytelyst/swift-platform-sdk/Sources/BLAuthUI.swift +++ /dev/null @@ -1,740 +0,0 @@ -// ── Auth UI Kit ───────────────────────────────────────────── -// Reusable SwiftUI auth views for all ByteLyst iOS/macOS apps. -// BLLoginView, BLMfaChallengeView, BLPasskeyView, BLStepUpSheet. -// Themed via @Environment injection — always matches host product. - -import SwiftUI -import os - -#if canImport(AuthenticationServices) -import AuthenticationServices -#endif - -private let logger = Logger(subsystem: "com.bytelyst.platform", category: "BLAuthUI") - -// MARK: - Auth UI Configuration - -/// Configuration for BLAuthUI views — passed via Environment. -public struct BLAuthUIConfig { - public let productName: String - public let accentColor: Color - public let backgroundColor: Color - public let textColor: Color - public let secondaryTextColor: Color - public let cardColor: Color - public let enabledProviders: [BLAuthUIProvider] - - public init( - productName: String = "ByteLyst", - accentColor: Color = .blue, - backgroundColor: Color = Color(.systemBackground), - textColor: Color = .primary, - secondaryTextColor: Color = .secondary, - cardColor: Color = Color(.secondarySystemBackground), - enabledProviders: [BLAuthUIProvider] = [.google, .apple] - ) { - self.productName = productName - self.accentColor = accentColor - self.backgroundColor = backgroundColor - self.textColor = textColor - self.secondaryTextColor = secondaryTextColor - self.cardColor = cardColor - self.enabledProviders = enabledProviders - } -} - -/// OAuth providers supported by BLAuthUI. -public enum BLAuthUIProvider: String, CaseIterable, Sendable { - case google - case microsoft - case apple -} - -// MARK: - Environment Key - -private struct BLAuthUIConfigKey: EnvironmentKey { - static let defaultValue = BLAuthUIConfig() -} - -extension EnvironmentValues { - public var blAuthUIConfig: BLAuthUIConfig { - get { self[BLAuthUIConfigKey.self] } - set { self[BLAuthUIConfigKey.self] = newValue } - } -} - -// MARK: - BLLoginView - -/// Full login view with email/password + social buttons + passkey option. -/// Host product injects theme via `.environment(\.blAuthUIConfig, config)`. -public struct BLLoginView: View { - @Environment(\.blAuthUIConfig) private var config - - @State private var email = "" - @State private var password = "" - @State private var isLoading = false - @State private var errorMessage: String? - - /// Called with email + password when user taps Sign In. - public var onLogin: (String, String) async throws -> Void - /// Called with provider name when user taps a social button. - public var onSocialLogin: (BLAuthUIProvider) -> Void - /// Called when user taps "Use Passkey". - public var onPasskeyLogin: (() -> Void)? - /// Called when user taps "Forgot Password?". - public var onForgotPassword: (() -> Void)? - /// Called when user taps "Create Account". - public var onCreateAccount: (() -> Void)? - - public init( - onLogin: @escaping (String, String) async throws -> Void, - onSocialLogin: @escaping (BLAuthUIProvider) -> Void, - onPasskeyLogin: (() -> Void)? = nil, - onForgotPassword: (() -> Void)? = nil, - onCreateAccount: (() -> Void)? = nil - ) { - self.onLogin = onLogin - self.onSocialLogin = onSocialLogin - self.onPasskeyLogin = onPasskeyLogin - self.onForgotPassword = onForgotPassword - self.onCreateAccount = onCreateAccount - } - - public var body: some View { - ScrollView { - VStack(spacing: 24) { - // Header - VStack(spacing: 8) { - Text("Sign in to \(config.productName)") - .font(.title2.bold()) - .foregroundColor(config.textColor) - Text("Welcome back") - .font(.subheadline) - .foregroundColor(config.secondaryTextColor) - } - .padding(.top, 40) - - // Social Buttons - if !config.enabledProviders.isEmpty { - VStack(spacing: 12) { - ForEach(config.enabledProviders, id: \.self) { provider in - socialButton(for: provider) - } - } - - dividerRow - } - - // Email / Password - VStack(spacing: 16) { - TextField("Email", text: $email) - .textFieldStyle(.roundedBorder) - .textContentType(.emailAddress) - .keyboardType(.emailAddress) - .autocapitalization(.none) - .disableAutocorrection(true) - - SecureField("Password", text: $password) - .textFieldStyle(.roundedBorder) - .textContentType(.password) - } - - // Error - if let errorMessage { - Text(errorMessage) - .font(.caption) - .foregroundColor(.red) - .multilineTextAlignment(.center) - } - - // Sign In Button - Button { - Task { await performLogin() } - } label: { - HStack { - if isLoading { - ProgressView() - .tint(.white) - } - Text("Sign In") - .fontWeight(.semibold) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 14) - .background(config.accentColor) - .foregroundColor(.white) - .cornerRadius(10) - } - .disabled(email.isEmpty || password.isEmpty || isLoading) - - // Passkey - if let onPasskeyLogin { - Button { - logger.debug("Passkey login tapped") - onPasskeyLogin() - } label: { - HStack { - Image(systemName: "person.badge.key.fill") - Text("Sign in with Passkey") - } - .frame(maxWidth: .infinity) - .padding(.vertical, 14) - .background(config.cardColor) - .foregroundColor(config.textColor) - .cornerRadius(10) - } - } - - // Forgot Password / Create Account - VStack(spacing: 12) { - if let onForgotPassword { - Button("Forgot Password?") { - onForgotPassword() - } - .font(.subheadline) - .foregroundColor(config.accentColor) - } - - if let onCreateAccount { - HStack { - Text("Don't have an account?") - .foregroundColor(config.secondaryTextColor) - Button("Create Account") { - onCreateAccount() - } - .foregroundColor(config.accentColor) - } - .font(.subheadline) - } - } - } - .padding(.horizontal, 24) - } - .background(config.backgroundColor.ignoresSafeArea()) - } - - private func performLogin() async { - isLoading = true - errorMessage = nil - do { - logger.debug("Attempting email/password login for \(email)") - try await onLogin(email, password) - } catch let error as BLAuthError { - // MFA required is not an error for the user — handled upstream - logger.info("MFA required for \(email)") - _ = error // Suppress unused warning - } catch { - logger.error("Login failed: \(error.localizedDescription)") - errorMessage = error.localizedDescription - } - isLoading = false - } - - @ViewBuilder - private func socialButton(for provider: BLAuthUIProvider) -> some View { - Button { - logger.debug("Social login: \(provider.rawValue)") - onSocialLogin(provider) - } label: { - HStack { - providerIcon(for: provider) - Text("Continue with \(providerDisplayName(provider))") - .fontWeight(.medium) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 14) - .background(config.cardColor) - .foregroundColor(config.textColor) - .cornerRadius(10) - .overlay( - RoundedRectangle(cornerRadius: 10) - .stroke(Color.gray.opacity(0.3), lineWidth: 1) - ) - } - } - - @ViewBuilder - private func providerIcon(for provider: BLAuthUIProvider) -> some View { - switch provider { - case .google: - Image(systemName: "globe") - case .microsoft: - Image(systemName: "building.2") - case .apple: - Image(systemName: "applelogo") - } - } - - private func providerDisplayName(_ provider: BLAuthUIProvider) -> String { - switch provider { - case .google: return "Google" - case .microsoft: return "Microsoft" - case .apple: return "Apple" - } - } - - private var dividerRow: some View { - HStack { - Rectangle().fill(Color.gray.opacity(0.3)).frame(height: 1) - Text("or") - .font(.caption) - .foregroundColor(config.secondaryTextColor) - Rectangle().fill(Color.gray.opacity(0.3)).frame(height: 1) - } - } -} - -// MARK: - BLMfaChallengeView - -/// 6-digit TOTP code entry with countdown and recovery code fallback. -public struct BLMfaChallengeView: View { - @Environment(\.blAuthUIConfig) private var config - - @State private var code = "" - @State private var isLoading = false - @State private var errorMessage: String? - @State private var showRecovery = false - - public let challenge: BLMfaChallenge - public var onVerify: (String, String, String) async throws -> Void - public var onCancel: (() -> Void)? - - public init( - challenge: BLMfaChallenge, - onVerify: @escaping (String, String, String) async throws -> Void, - onCancel: (() -> Void)? = nil - ) { - self.challenge = challenge - self.onVerify = onVerify - self.onCancel = onCancel - } - - public var body: some View { - VStack(spacing: 24) { - // Header - VStack(spacing: 8) { - Image(systemName: "lock.shield.fill") - .font(.system(size: 48)) - .foregroundColor(config.accentColor) - - Text("Two-Factor Authentication") - .font(.title3.bold()) - .foregroundColor(config.textColor) - - Text(showRecovery - ? "Enter a recovery code" - : "Enter the 6-digit code from your authenticator app") - .font(.subheadline) - .foregroundColor(config.secondaryTextColor) - .multilineTextAlignment(.center) - } - - // Code Input - TextField(showRecovery ? "Recovery Code" : "000000", text: $code) - .textFieldStyle(.roundedBorder) - .keyboardType(showRecovery ? .default : .numberPad) - .multilineTextAlignment(.center) - .font(.title2.monospaced()) - .frame(maxWidth: 200) - - // Error - if let errorMessage { - Text(errorMessage) - .font(.caption) - .foregroundColor(.red) - } - - // Verify Button - Button { - Task { await verify() } - } label: { - HStack { - if isLoading { ProgressView().tint(.white) } - Text("Verify") - .fontWeight(.semibold) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 14) - .background(config.accentColor) - .foregroundColor(.white) - .cornerRadius(10) - } - .disabled(code.isEmpty || isLoading) - - // Toggle recovery / cancel - VStack(spacing: 12) { - Button(showRecovery ? "Use authenticator code" : "Use recovery code") { - showRecovery.toggle() - code = "" - errorMessage = nil - } - .font(.subheadline) - .foregroundColor(config.accentColor) - - if let onCancel { - Button("Cancel") { onCancel() } - .font(.subheadline) - .foregroundColor(config.secondaryTextColor) - } - } - } - .padding(24) - .background(config.backgroundColor) - } - - private func verify() async { - isLoading = true - errorMessage = nil - let method = showRecovery ? "recovery" : "totp" - do { - logger.debug("Verifying MFA: method=\(method)") - try await onVerify(challenge.mfaChallenge, code, method) - } catch { - logger.error("MFA verify failed: \(error.localizedDescription)") - errorMessage = error.localizedDescription - code = "" - } - isLoading = false - } -} - -// MARK: - BLPasskeyView - -/// Passkey prompt with biometric hint text. -/// Triggers ASAuthorizationController for platform passkey authentication. -public struct BLPasskeyView: View { - @Environment(\.blAuthUIConfig) private var config - - @State private var isLoading = false - @State private var errorMessage: String? - - public var onAuthenticate: () async throws -> Void - public var onCancel: (() -> Void)? - - public init( - onAuthenticate: @escaping () async throws -> Void, - onCancel: (() -> Void)? = nil - ) { - self.onAuthenticate = onAuthenticate - self.onCancel = onCancel - } - - public var body: some View { - VStack(spacing: 24) { - VStack(spacing: 12) { - Image(systemName: "person.badge.key.fill") - .font(.system(size: 48)) - .foregroundColor(config.accentColor) - - Text("Sign in with Passkey") - .font(.title3.bold()) - .foregroundColor(config.textColor) - - Text("Use Face ID, Touch ID, or your security key to sign in") - .font(.subheadline) - .foregroundColor(config.secondaryTextColor) - .multilineTextAlignment(.center) - } - - if let errorMessage { - Text(errorMessage) - .font(.caption) - .foregroundColor(.red) - } - - Button { - Task { await authenticate() } - } label: { - HStack { - if isLoading { ProgressView().tint(.white) } - Image(systemName: "faceid") - Text("Continue") - .fontWeight(.semibold) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 14) - .background(config.accentColor) - .foregroundColor(.white) - .cornerRadius(10) - } - .disabled(isLoading) - - if let onCancel { - Button("Use another method") { onCancel() } - .font(.subheadline) - .foregroundColor(config.accentColor) - } - } - .padding(24) - .background(config.backgroundColor) - } - - private func authenticate() async { - isLoading = true - errorMessage = nil - do { - logger.debug("Starting passkey authentication") - try await onAuthenticate() - } catch { - logger.error("Passkey auth failed: \(error.localizedDescription)") - errorMessage = error.localizedDescription - } - isLoading = false - } -} - -// MARK: - BLStepUpSheet - -/// Re-authentication sheet for sensitive operations. -/// Supports password re-entry or biometric confirmation. -public struct BLStepUpSheet: View { - @Environment(\.blAuthUIConfig) private var config - @Environment(\.dismiss) private var dismiss - - @State private var password = "" - @State private var isLoading = false - @State private var errorMessage: String? - - public let reason: String - public var onStepUp: (String, String) async throws -> String - public var onComplete: (String) -> Void - - public init( - reason: String = "This action requires re-authentication", - onStepUp: @escaping (String, String) async throws -> String, - onComplete: @escaping (String) -> Void - ) { - self.reason = reason - self.onStepUp = onStepUp - self.onComplete = onComplete - } - - public var body: some View { - NavigationStack { - VStack(spacing: 24) { - VStack(spacing: 8) { - Image(systemName: "lock.fill") - .font(.system(size: 36)) - .foregroundColor(config.accentColor) - - Text("Confirm Your Identity") - .font(.title3.bold()) - .foregroundColor(config.textColor) - - Text(reason) - .font(.subheadline) - .foregroundColor(config.secondaryTextColor) - .multilineTextAlignment(.center) - } - - SecureField("Password", text: $password) - .textFieldStyle(.roundedBorder) - .textContentType(.password) - - if let errorMessage { - Text(errorMessage) - .font(.caption) - .foregroundColor(.red) - } - - #if canImport(LocalAuthentication) - Button { - Task { await biometricStepUp() } - } label: { - HStack { - Image(systemName: "faceid") - Text("Use Biometrics") - .fontWeight(.medium) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 14) - .background(config.cardColor) - .foregroundColor(config.textColor) - .cornerRadius(10) - } - #endif - - Button { - Task { await passwordStepUp() } - } label: { - HStack { - if isLoading { ProgressView().tint(.white) } - Text("Confirm") - .fontWeight(.semibold) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 14) - .background(config.accentColor) - .foregroundColor(.white) - .cornerRadius(10) - } - .disabled(password.isEmpty || isLoading) - - Spacer() - } - .padding(24) - .background(config.backgroundColor) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { dismiss() } - } - } - } - } - - private func passwordStepUp() async { - isLoading = true - errorMessage = nil - do { - logger.debug("Step-up: password method") - let token = try await onStepUp("password", password) - onComplete(token) - dismiss() - } catch { - logger.error("Step-up failed: \(error.localizedDescription)") - errorMessage = error.localizedDescription - } - isLoading = false - } - - #if canImport(LocalAuthentication) - private func biometricStepUp() async { - let success = await BLBiometricAuth.authenticate(reason: "Confirm your identity") - if success { - isLoading = true - errorMessage = nil - do { - logger.debug("Step-up: biometric method") - let token = try await onStepUp("biometric", "biometric_verified") - onComplete(token) - dismiss() - } catch { - logger.error("Biometric step-up failed: \(error.localizedDescription)") - errorMessage = error.localizedDescription - } - isLoading = false - } else { - errorMessage = "Biometric authentication failed" - } - } - #endif -} - -// ── BLDeviceListView ──────────────────────────────────────── - -/// Device management view — list trusted/remembered devices, revoke trust. -/// Mirrors the Kotlin `BLDeviceListScreen` for platform parity. -/// -/// - Parameters: -/// - devices: List of devices from `BLAuthClient.listDevices()`. -/// - onRevokeDevice: Called with device ID when user revokes a device. -/// - onRevokeAll: Called when user revokes all devices. `nil` hides the button. -/// - isLoading: Whether data is loading. -public struct BLDeviceListView: View { - public let devices: [BLDevice] - public let onRevokeDevice: (String) -> Void - public var onRevokeAll: (() -> Void)? - public var isLoading: Bool = false - - public init( - devices: [BLDevice], - onRevokeDevice: @escaping (String) -> Void, - onRevokeAll: (() -> Void)? = nil, - isLoading: Bool = false - ) { - self.devices = devices - self.onRevokeDevice = onRevokeDevice - self.onRevokeAll = onRevokeAll - self.isLoading = isLoading - } - - public var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 16) { - HStack { - Text("Your Devices") - .font(.title2.bold()) - Spacer() - if let onRevokeAll, !devices.isEmpty { - Button(role: .destructive, action: onRevokeAll) { - Text("Revoke All") - } - } - } - - if isLoading { - HStack { - Spacer() - ProgressView() - Spacer() - } - .padding(.vertical, 32) - } else if devices.isEmpty { - Text("No devices found") - .foregroundStyle(.secondary) - .padding(.vertical, 16) - } else { - ForEach(devices, id: \.id) { device in - DeviceCardView(device: device) { - onRevokeDevice(device.id) - } - } - } - } - .padding() - } - } -} - -/// Individual device card within BLDeviceListView. -private struct DeviceCardView: View { - let device: BLDevice - let onRevoke: () -> Void - - private var platformIcon: String { - switch device.platform { - case "ios": return "iphone" - case "android": return "apps.iphone" - case "macos": return "laptopcomputer" - case "windows", "linux": return "desktopcomputer" - default: return "display" - } - } - - private var trustColor: Color { - switch device.trustLevel { - case "trusted": return .blue - case "remembered": return .green - default: return .secondary - } - } - - var body: some View { - HStack(spacing: 12) { - Image(systemName: platformIcon) - .font(.title2) - .foregroundStyle(trustColor) - .frame(width: 32) - - VStack(alignment: .leading, spacing: 2) { - Text(device.name) - .font(.body) - Text("\(device.trustLevel) · \(device.platform)") - .font(.caption) - .foregroundStyle(.secondary) - } - - Spacer() - - if device.trustLevel == "trusted" || device.trustLevel == "remembered" { - Button(role: .destructive, action: onRevoke) { - Image(systemName: "xmark.circle.fill") - .foregroundStyle(.red) - } - .buttonStyle(.plain) - } - } - .padding() - .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12)) - } -} diff --git a/vendor/bytelyst/swift-platform-sdk/Sources/BLBiometricAuth.swift b/vendor/bytelyst/swift-platform-sdk/Sources/BLBiometricAuth.swift deleted file mode 100644 index 9435ec6..0000000 --- a/vendor/bytelyst/swift-platform-sdk/Sources/BLBiometricAuth.swift +++ /dev/null @@ -1,77 +0,0 @@ -// ── Biometric Authentication ──────────────────────────────── -// Generic Face ID / Touch ID wrapper using LocalAuthentication. -// Product apps pass a custom reason string for the biometric prompt. -// Not available on watchOS — guarded with #if canImport. - -import Foundation - -#if canImport(LocalAuthentication) -import LocalAuthentication - -/// Generic biometric authentication for all ByteLyst iOS/macOS apps. -/// Not available on watchOS (LocalAuthentication is iOS/macOS only). -public enum BLBiometricAuth { - - public enum BiometricType { - case faceID, touchID, none - } - - /// Check what biometric type is available on this device. - public static var availableType: BiometricType { - let context = LAContext() - var error: NSError? - guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else { - return .none - } - switch context.biometryType { - case .faceID: return .faceID - case .touchID: return .touchID - default: return .none - } - } - - /// Whether biometric auth is available on this device. - public static var isAvailable: Bool { - availableType != .none - } - - /// Whether the user has enabled biometric lock in settings. - /// Uses a configurable UserDefaults key. - public static func isEnabled(key: String = "biometric_lock_enabled") -> Bool { - UserDefaults.standard.bool(forKey: key) - } - - /// Set biometric lock enabled state. - public static func setEnabled(_ enabled: Bool, key: String = "biometric_lock_enabled") { - UserDefaults.standard.set(enabled, forKey: key) - } - - /// Authenticate with biometrics only. Returns true on success. - public static func authenticate(reason: String = "Unlock app") async -> Bool { - let context = LAContext() - context.localizedCancelTitle = "Use Password" - - do { - return try await context.evaluatePolicy( - .deviceOwnerAuthenticationWithBiometrics, - localizedReason: reason - ) - } catch { - return false - } - } - - /// Authenticate with biometrics or device passcode fallback. - public static func authenticateWithPasscode(reason: String = "Unlock app") async -> Bool { - let context = LAContext() - do { - return try await context.evaluatePolicy( - .deviceOwnerAuthentication, - localizedReason: reason - ) - } catch { - return false - } - } -} -#endif diff --git a/vendor/bytelyst/swift-platform-sdk/Sources/BLBlobClient.swift b/vendor/bytelyst/swift-platform-sdk/Sources/BLBlobClient.swift deleted file mode 100644 index a211d32..0000000 --- a/vendor/bytelyst/swift-platform-sdk/Sources/BLBlobClient.swift +++ /dev/null @@ -1,86 +0,0 @@ -// ── Blob Storage Client ───────────────────────────────────── -// Generic Azure Blob Storage client via platform-service SAS tokens. -// Upload files to Azure Blob using SAS URL from POST /api/blob/sas. -// Product apps configure with BLPlatformConfig. - -import Foundation - -/// Response from platform-service SAS token endpoint. -public struct BLSASResponse: Codable, Sendable { - public let sasUrl: String - public let blobUrl: String - public let container: String - public let blobName: String -} - -/// Generic blob storage client for all ByteLyst iOS apps. -/// Handles SAS token acquisition + direct Azure Blob upload. -public final class BLBlobClient { - - private let config: BLPlatformConfig - private let client: BLPlatformClient - - public init(config: BLPlatformConfig, client: BLPlatformClient) { - self.config = config - self.client = client - } - - // MARK: - Upload - - /// Upload data to Azure Blob Storage. - /// 1. Acquires SAS token from platform-service - /// 2. Uploads directly to Azure Blob using the SAS URL - /// Returns the permanent blob URL on success. - public func upload( - data: Data, - container: String, - fileName: String, - contentType: String - ) async throws -> String { - // Step 1: Get SAS token - let sas = try await getSASToken(container: container, blobName: fileName, permissions: "w") - - // Step 2: Upload to blob storage using SAS URL - guard let url = URL(string: sas.sasUrl) else { - throw BLNetworkError.invalidURL(sas.sasUrl) - } - - var request = URLRequest(url: url) - request.httpMethod = "PUT" - request.setValue(contentType, forHTTPHeaderField: "Content-Type") - request.setValue("BlockBlob", forHTTPHeaderField: "x-ms-blob-type") - request.httpBody = data - request.timeoutInterval = 120 - - let (_, response) = try await URLSession.shared.data(for: request) - - guard let http = response as? HTTPURLResponse, - (200...299).contains(http.statusCode) else { - throw BLNetworkError.httpError(statusCode: (response as? HTTPURLResponse)?.statusCode ?? 0, message: "Blob upload failed") - } - - return sas.blobUrl - } - - /// Convenience: upload audio data. - public func uploadAudio(data: Data, fileName: String) async throws -> String { - try await upload(data: data, container: "audio", fileName: fileName, contentType: "audio/wav") - } - - /// Convenience: upload an attachment (image, document, etc.). - public func uploadAttachment(data: Data, fileName: String, contentType: String) async throws -> String { - try await upload(data: data, container: "attachments", fileName: fileName, contentType: contentType) - } - - // MARK: - SAS Token - - /// Get a SAS token from platform-service. - public func getSASToken(container: String, blobName: String, permissions: String = "r") async throws -> BLSASResponse { - let body = [ - "container": container, - "blobName": blobName, - "permissions": permissions, - ] - return try await client.request(path: "/api/blob/sas", method: "POST", body: body, responseType: BLSASResponse.self) - } -} diff --git a/vendor/bytelyst/swift-platform-sdk/Sources/BLBroadcastClient.swift b/vendor/bytelyst/swift-platform-sdk/Sources/BLBroadcastClient.swift deleted file mode 100644 index 888e9d7..0000000 --- a/vendor/bytelyst/swift-platform-sdk/Sources/BLBroadcastClient.swift +++ /dev/null @@ -1,153 +0,0 @@ -// ── Broadcast Client ───────────────────────────────────── -// In-app broadcast message client for iOS/watchOS/macOS. -// Part of ByteLystPlatformSDK. - -import Foundation - -/// In-app message priority levels. -public enum BLBroadcastPriority: String, Codable, Sendable { - case low = "low" - case normal = "normal" - case high = "high" - case urgent = "urgent" -} - -/// In-app message display styles. -public enum BLBroadcastStyle: String, Codable, Sendable { - case banner = "banner" - case modal = "modal" - case toast = "toast" - case fullscreen = "fullscreen" -} - -/// In-app message status. -public enum BLBroadcastStatus: String, Codable, Sendable { - case unread = "unread" - case read = "read" - case dismissed = "dismissed" -} - -/// Represents an in-app broadcast message. -public struct BLInAppMessage: Codable, Sendable, Identifiable { - public let id: String - public let userId: String - public let productId: String - public let broadcastId: String - public let title: String - public let body: String - public let bodyMarkdown: String? - public let ctaText: String? - public let ctaUrl: String? - public let priority: BLBroadcastPriority - public let style: BLBroadcastStyle - public let dismissible: Bool - public let expiresAt: String? - public let status: BLBroadcastStatus - public let createdAt: String - public let updatedAt: String -} - -/// Broadcast client for fetching and managing in-app messages. -@available(iOS 15.0, macOS 12.0, watchOS 8.0, *) -public class BLBroadcastClient: ObservableObject { - private let platformClient: BLPlatformClient - private var pollTask: Task? - - public init(platformClient: BLPlatformClient) { - self.platformClient = platformClient - } - - /// List active in-app messages for the current user. - public func listMessages() async throws -> [BLInAppMessage] { - let request = try platformClient.buildRequest(path: "/broadcasts") - let (data, response) = try await URLSession.shared.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse, - httpResponse.statusCode == 200 else { - throw BLPlatformError.requestFailed(String(data: data, encoding: .utf8) ?? "Unknown error") - } - - let result = try JSONDecoder().decode(MessagesResponse.self, from: data) - return result.messages - } - - /// Mark a message as read. - public func markRead(messageId: String) async throws { - let request = try platformClient.buildRequest( - path: "/broadcasts/\(messageId)/read", - method: "POST" - ) - let (_, response) = try await URLSession.shared.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse, - httpResponse.statusCode == 200 else { - throw BLPlatformError.requestFailed("Failed to mark message as read") - } - } - - /// Mark a message as dismissed. - public func markDismissed(messageId: String) async throws { - let request = try platformClient.buildRequest( - path: "/broadcasts/\(messageId)/dismiss", - method: "POST" - ) - let (_, response) = try await URLSession.shared.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse, - httpResponse.statusCode == 200 else { - throw BLPlatformError.requestFailed("Failed to dismiss message") - } - } - - /// Track a CTA click and get the redirect URL. - public func trackClick(messageId: String) async throws -> String? { - let request = try platformClient.buildRequest( - path: "/broadcasts/\(messageId)/click", - method: "POST" - ) - let (data, response) = try await URLSession.shared.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse, - httpResponse.statusCode == 200 else { - throw BLPlatformError.requestFailed("Failed to track click") - } - - let result = try JSONDecoder().decode(ClickResponse.self, from: data) - return result.redirectUrl - } - - /// Start polling for new messages. - public func startPolling(interval: TimeInterval = 60, onUpdate: @escaping ([BLInAppMessage]) -> Void) { - stopPolling() - - pollTask = Task { - while !Task.isCancelled { - do { - let messages = try await listMessages() - onUpdate(messages) - } catch { - // Silently ignore polling errors - } - - try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) - } - } - } - - /// Stop polling for messages. - public func stopPolling() { - pollTask?.cancel() - pollTask = nil - } -} - -// MARK: - Response Types - -private struct MessagesResponse: Codable { - let messages: [BLInAppMessage] -} - -private struct ClickResponse: Codable { - let success: Bool - let redirectUrl: String? -} diff --git a/vendor/bytelyst/swift-platform-sdk/Sources/BLCrashReporter.swift b/vendor/bytelyst/swift-platform-sdk/Sources/BLCrashReporter.swift deleted file mode 100644 index 4151fb0..0000000 --- a/vendor/bytelyst/swift-platform-sdk/Sources/BLCrashReporter.swift +++ /dev/null @@ -1,135 +0,0 @@ -// ── Crash Reporter ────────────────────────────────────────── -// Generic MetricKit-based crash and performance reporting. -// Stores crash diagnostics locally for debugging and feedback forms. -// Product apps configure with a product-specific persistence key. -// Not available on watchOS — MetricKit is iOS/macOS only. - -import Foundation - -/// Crash report model stored locally. -/// Available on all platforms (data-only struct). -public struct BLCrashReport: Codable, Identifiable, Sendable { - public let id: String - public let date: Date - public let exceptionType: String? - public let signal: String? - public let terminationReason: String? - public let callStackData: Data? - - public init(date: Date, exceptionType: String?, signal: String?, terminationReason: String?, callStackTree: Data) { - self.id = UUID().uuidString - self.date = date - self.exceptionType = exceptionType - self.signal = signal - self.terminationReason = terminationReason - self.callStackData = callStackTree - } -} - -#if canImport(MetricKit) -import MetricKit - -/// Generic MetricKit crash reporter for all ByteLyst iOS/macOS apps. -/// Subscribes to MetricKit, stores crash reports in UserDefaults. -/// Not available on watchOS (MetricKit is iOS 13+ / macOS 12+ only). -@MainActor -public final class BLCrashReporter: NSObject, ObservableObject, MXMetricManagerSubscriber { - - private let persistenceKey: String - private let maxReports: Int - - @Published public var lastCrashReport: Date? - @Published public var diagnosticCount: Int = 0 - - public init(productId: String, maxReports: Int = 50) { - self.persistenceKey = "\(productId)-crash-reports" - self.maxReports = maxReports - super.init() - MXMetricManager.shared.add(self) - loadStats() - } - - deinit { - MXMetricManager.shared.remove(self) - } - - // MARK: - MXMetricManagerSubscriber - - nonisolated public func didReceive(_ payloads: [MXMetricPayload]) { - // MetricKit delivers daily aggregated metrics — no action needed by default - } - - nonisolated public func didReceive(_ payloads: [MXDiagnosticPayload]) { - Task { @MainActor [weak self] in - guard let self else { return } - for payload in payloads { - self.processDiagnosticPayload(payload) - } - self.diagnosticCount += payloads.count - self.lastCrashReport = Date() - } - } - - // MARK: - Public API - - /// Get all stored crash reports. - public func loadCrashReports() -> [BLCrashReport] { - guard let data = UserDefaults.standard.data(forKey: persistenceKey), - let reports = try? JSONDecoder().decode([BLCrashReport].self, from: data) else { - return [] - } - return reports - } - - /// Clear all stored crash reports. - public func clearReports() { - UserDefaults.standard.removeObject(forKey: persistenceKey) - diagnosticCount = 0 - } - - // MARK: - Private - - private func processDiagnosticPayload(_ payload: MXDiagnosticPayload) { - if let crashDiagnostics = payload.crashDiagnostics { - for crash in crashDiagnostics { - let report = BLCrashReport( - date: Date(), - exceptionType: crash.exceptionType?.description, - signal: crash.signal?.description, - terminationReason: crash.terminationReason?.description, - callStackTree: crash.callStackTree.jsonRepresentation() - ) - storeCrashReport(report) - } - } - - if let hangDiagnostics = payload.hangDiagnostics { - for hang in hangDiagnostics { - let report = BLCrashReport( - date: Date(), - exceptionType: nil, - signal: nil, - terminationReason: "Hang: \(hang.hangDuration.description)", - callStackTree: hang.callStackTree.jsonRepresentation() - ) - storeCrashReport(report) - } - } - } - - private func storeCrashReport(_ report: BLCrashReport) { - var reports = loadCrashReports() - reports.append(report) - if reports.count > maxReports { - reports = Array(reports.suffix(maxReports)) - } - if let data = try? JSONEncoder().encode(reports) { - UserDefaults.standard.set(data, forKey: persistenceKey) - } - } - - private func loadStats() { - diagnosticCount = loadCrashReports().count - } -} -#endif diff --git a/vendor/bytelyst/swift-platform-sdk/Sources/BLDeepLinkRouter.swift b/vendor/bytelyst/swift-platform-sdk/Sources/BLDeepLinkRouter.swift deleted file mode 100644 index 96468bf..0000000 --- a/vendor/bytelyst/swift-platform-sdk/Sources/BLDeepLinkRouter.swift +++ /dev/null @@ -1,182 +0,0 @@ -import Foundation -import os - -/** - * Deep Link Router — Swift - * Handles routing from push notification deep links to app screens - */ - -public struct BLDeepLinkRoute { - public let screen: String - public let params: [String: String] - - public init(screen: String, params: [String: String] = [:]) { - self.screen = screen - self.params = params - } -} - -public typealias BLDeepLinkHandler = (BLDeepLinkRoute) -> Void - -/** - * Deep Link Router class - */ -@available(iOS 15.0, *) -public class BLDeepLinkRouter { - private var handlers: [String: BLDeepLinkHandler] = [:] - private var fallbackHandler: BLDeepLinkHandler? - - public init() {} - - /** - * Register a handler for a specific screen - */ - public func register(screen: String, handler: @escaping BLDeepLinkHandler) { - handlers[screen] = handler - } - - /** - * Set a fallback handler for unregistered screens - */ - public func setFallback(handler: @escaping BLDeepLinkHandler) { - fallbackHandler = handler - } - - /** - * Parse a deep link URL and extract route - */ - public func parseDeepLink(_ urlString: String) -> BLDeepLinkRoute? { - guard let url = URL(string: urlString) else { - return nil - } - - // Handle app-specific URLs: myapp://screen/params - if url.scheme != "http" && url.scheme != "https" { - let pathComponents = url.pathComponents.filter { $0 != "/" && !$0.isEmpty } - let screen = pathComponents.first ?? "home" - - var params: [String: String] = [:] - if let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems { - for item in queryItems { - if let value = item.value { - params[item.name] = value - } - } - } - - return BLDeepLinkRoute(screen: screen, params: params) - } - - // Handle web URLs with deep link params - if let components = URLComponents(url: url, resolvingAgainstBaseURL: false), - let dlParam = components.queryItems?.first(where: { $0.name == "dl" })?.value { - return parseDeepLink(dlParam) - } - - // Handle path-based routing: /screen/params - let pathComponents = url.pathComponents.filter { $0 != "/" && !$0.isEmpty } - if !pathComponents.isEmpty { - let screen = pathComponents[0] - - var params: [String: String] = [:] - if let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems { - for item in queryItems { - if let value = item.value { - params[item.name] = value - } - } - } - - return BLDeepLinkRoute(screen: screen, params: params) - } - - return nil - } - - /** - * Handle a deep link route - */ - @discardableResult - public func handle(_ route: BLDeepLinkRoute) -> Bool { - if let handler = handlers[route.screen] { - handler(route) - return true - } - - if let fallback = fallbackHandler { - fallback(route) - return true - } - - Logger.deepLink.warning("No handler for screen: \(route.screen)") - return false - } - - /** - * Process a deep link URL end-to-end - */ - @discardableResult - public func process(_ urlString: String) -> Bool { - guard let route = parseDeepLink(urlString) else { - Logger.deepLink.warning("Failed to parse deep link: \(urlString)") - return false - } - return handle(route) - } -} - -/** - * Create a broadcast deep link URL - */ -public func createBroadcastDeepLink( - baseURL: String, - screen: String, - params: [String: String] = [:], - broadcastId: String? = nil -) -> String? { - guard var components = URLComponents(string: baseURL) else { - return nil - } - - components.path = "/\(screen)" - - var queryItems: [URLQueryItem] = params.map { URLQueryItem(name: $0.key, value: $0.value) } - - if let broadcastId = broadcastId { - queryItems.append(URLQueryItem(name: "broadcastId", value: broadcastId)) - } - - if !queryItems.isEmpty { - components.queryItems = queryItems - } - - return components.string -} - -/** - * Common deep link screens - */ -public struct BLDeepLinkScreens { - // Broadcasts - public static let broadcast = "broadcast" - public static let announcements = "announcements" - - // Surveys - public static let survey = "survey" - public static let surveyList = "surveys" - - // Product-specific - public static let settings = "settings" - public static let profile = "profile" - public static let upgrade = "upgrade" - public static let support = "support" - - // Fallback - public static let home = "home" -} - -// Logger extension -@available(iOS 15.0, *) -extension Logger { - static let deepLink = Logger(subsystem: "com.bytelyst.platform", category: "DeepLink") -} diff --git a/vendor/bytelyst/swift-platform-sdk/Sources/BLFeatureFlagClient.swift b/vendor/bytelyst/swift-platform-sdk/Sources/BLFeatureFlagClient.swift deleted file mode 100644 index 7c89565..0000000 --- a/vendor/bytelyst/swift-platform-sdk/Sources/BLFeatureFlagClient.swift +++ /dev/null @@ -1,86 +0,0 @@ -// ── Feature Flag Client ────────────────────────────────────── -// Generic feature flag polling from platform-service /api/flags/poll. -// Flags cached in memory, re-polled at configurable interval. -// Matches the platform-service flags module API. - -import Foundation - -/// Generic feature flag client for all ByteLyst iOS apps. -/// Polls platform-service and caches flag values in memory. -public final class BLFeatureFlagClient { - - private let config: BLPlatformConfig - private let client: BLPlatformClient - private let pollIntervalSec: TimeInterval - - private var flags: [String: Bool] = [:] - private let flagsLock = NSLock() - private var pollTimer: Timer? - - public init( - config: BLPlatformConfig, - client: BLPlatformClient, - pollIntervalSec: TimeInterval = 5 * 60 - ) { - self.config = config - self.client = client - self.pollIntervalSec = pollIntervalSec - } - - // MARK: - Lifecycle - - /// Start polling for feature flags. - public func start(userId: String? = nil) { - Task { await fetchFlags(userId: userId) } - pollTimer?.invalidate() - pollTimer = Timer.scheduledTimer(withTimeInterval: pollIntervalSec, repeats: true) { [weak self] _ in - guard let self else { return } - Task { await self.fetchFlags(userId: userId) } - } - } - - /// Stop polling. - public func stop() { - pollTimer?.invalidate() - pollTimer = nil - } - - // MARK: - Query - - /// Check if a feature flag is enabled. - public func isEnabled(_ key: String) -> Bool { - flagsLock.lock() - defer { flagsLock.unlock() } - return flags[key] == true - } - - /// Get all current flag values. - public func allFlags() -> [String: Bool] { - flagsLock.lock() - defer { flagsLock.unlock() } - return flags - } - - // MARK: - Fetch - - private struct FlagsResponse: Codable { - let flags: [String: Bool] - } - - private func fetchFlags(userId: String? = nil) async { - var path = "/api/flags/poll?platform=\(config.platform)" - if let userId { path += "&userId=\(userId)" } - - do { - let result = try await client.request( - path: path, - responseType: FlagsResponse.self - ) - flagsLock.lock() - flags = result.flags - flagsLock.unlock() - } catch { - // Keep existing flags on error — silent failure - } - } -} diff --git a/vendor/bytelyst/swift-platform-sdk/Sources/BLFeedbackClient.swift b/vendor/bytelyst/swift-platform-sdk/Sources/BLFeedbackClient.swift deleted file mode 100644 index efd6e16..0000000 --- a/vendor/bytelyst/swift-platform-sdk/Sources/BLFeedbackClient.swift +++ /dev/null @@ -1,269 +0,0 @@ -// ── Feedback Client ───────────────────────────────────── -// Submit user feedback with optional screenshot attachments. -// Part of ByteLystPlatformSDK for all iOS/watchOS/macOS apps. -// -// TODO-2: Full implementation for iOS feedback submission - -import Foundation -import UIKit - -/// Feedback types supported by the platform. -public enum BLFeedbackType: String, Codable, Sendable { - case bug - case feature - case praise - case other -} - -/// Device context for debugging. -public struct BLDeviceContext: Codable, Sendable { - public let osVersion: String - public let appVersion: String - public let deviceModel: String - public let screenResolution: String - public let locale: String - - public init( - osVersion: String, - appVersion: String, - deviceModel: String, - screenResolution: String, - locale: String - ) { - self.osVersion = osVersion - self.appVersion = appVersion - self.deviceModel = deviceModel - self.screenResolution = screenResolution - self.locale = locale - } - - /// Auto-detect current device context. - public static func current() -> BLDeviceContext { - let device = UIDevice.current - let screen = UIScreen.main - let bounds = screen.bounds - let scale = screen.scale - - return BLDeviceContext( - osVersion: device.systemVersion, - appVersion: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown", - deviceModel: device.model, - screenResolution: "\(Int(bounds.width * scale))x\(Int(bounds.height * scale))", - locale: Locale.current.identifier - ) - } -} - -/// Screenshot content types. -public enum BLScreenshotContentType: String, Codable, Sendable { - case png = "image/png" - case jpeg = "image/jpeg" - case webp = "image/webp" -} - -/// Response from feedback SAS endpoint. -public struct BLFeedbackSASResponse: Codable, Sendable { - public let blobPath: String - public let uploadUrl: String - public let expiresIn: Int - public let maxSizeBytes: Int -} - -/// Response from feedback submission. -public struct BLFeedbackResponse: Codable, Sendable { - public let id: String - public let productId: String - public let userId: String - public let type: String - public let title: String - public let status: String - public let createdAt: String - public let screenshotBlobPath: String? -} - -/// Parameters for submitting feedback. -public struct BLFeedbackParams: Sendable { - public let type: BLFeedbackType - public let title: String - public let body: String? - public let screen: String? - public let rating: Int? - public let screenshot: (data: Data, contentType: BLScreenshotContentType)? - public let deviceContext: BLDeviceContext? - - public init( - type: BLFeedbackType, - title: String, - body: String? = nil, - screen: String? = nil, - rating: Int? = nil, - screenshot: (data: Data, contentType: BLScreenshotContentType)? = nil, - deviceContext: BLDeviceContext? = nil - ) { - self.type = type - self.title = title - self.body = body - self.screen = screen - self.rating = rating - self.screenshot = screenshot - self.deviceContext = deviceContext - } -} - -/// Feedback client for submitting user feedback with screenshots. -/// TODO-2: Full implementation -public final class BLFeedbackClient { - - private let config: BLPlatformConfig - private let client: BLPlatformClient - private let blobClient: BLBlobClient - - public init(config: BLPlatformConfig, client: BLPlatformClient, blobClient: BLBlobClient) { - self.config = config - self.client = client - self.blobClient = blobClient - } - - // MARK: - Submit Feedback - - /// Submit feedback with optional screenshot. - /// - /// Flow: - /// 1. If screenshot provided, upload to blob storage - /// 2. Submit feedback with screenshot metadata - /// - /// TODO-2: Full implementation - public func submitFeedback(_ params: BLFeedbackParams) async throws -> BLFeedbackResponse { - var screenshotMeta: (blobPath: String, contentType: String, sizeBytes: Int)? - - // Step 1: Handle screenshot upload if provided - if let screenshot = params.screenshot { - // Get SAS URL for upload - let sas = try await generateSASURL(contentType: screenshot.contentType) - - // Upload screenshot - try await uploadScreenshot(data: screenshot.data, sasURL: sas.uploadUrl, contentType: screenshot.contentType.rawValue) - - screenshotMeta = ( - blobPath: sas.blobPath, - contentType: screenshot.contentType.rawValue, - sizeBytes: screenshot.data.count - ) - } - - // Step 2: Submit feedback - var body: [String: Any] = [ - "type": params.type.rawValue, - "title": params.title, - ] - - if let bodyText = params.body { body["body"] = bodyText } - if let screen = params.screen { body["screen"] = screen } - if let rating = params.rating { body["rating"] = rating } - if let meta = screenshotMeta { - body["screenshotBlobPath"] = meta.blobPath - body["screenshotContentType"] = meta.contentType - body["screenshotSizeBytes"] = meta.sizeBytes - } - if let context = params.deviceContext { - body["deviceContext"] = [ - "osVersion": context.osVersion, - "appVersion": context.appVersion, - "deviceModel": context.deviceModel, - "screenResolution": context.screenResolution, - "locale": context.locale, - ] - } - - throw BLFeedbackError.notImplemented( - "submitFeedback body encoding and API call not yet implemented. " + - "Use client.request(path: \"/api/feedback\", method: \"POST\", body: body)" - ) - } - - /// Capture screenshot and submit feedback in one operation. - /// - /// TODO-2: Implement using UIApplication.shared.windows or UIScreen - public func captureAndSubmit( - type: BLFeedbackType, - title: String, - body: String? = nil - ) async throws -> BLFeedbackResponse { - throw BLFeedbackError.notImplemented( - "captureAndSubmit not yet implemented.\n\n" + - "To implement:\n" + - "1. Use UIGraphicsImageRenderer or UIScreen to capture\n" + - "2. Convert UIImage to Data (PNG/JPEG)\n" + - "3. Call submitFeedback with captured data\n\n" + - "Example:\n" + - "let window = UIApplication.shared.windows.first\n" + - "UIGraphicsBeginImageContext(window.bounds.size)\n" + - "window.drawHierarchy(in: window.bounds, afterScreenUpdates: true)\n" + - "let image = UIGraphicsGetImageFromCurrentImageContext()\n" + - "UIGraphicsEndImageContext()" - ) - } - - // MARK: - Screenshot Capture - - /// Capture current screen as UIImage. - /// - /// TODO-2: Full implementation - public func captureScreen() async throws -> UIImage { - throw BLFeedbackError.notImplemented( - "captureScreen not yet implemented. Use UIScreen or UIApplication.shared.windows" - ) - } - - /// Capture specific UIView as UIImage. - /// - /// TODO-2: Full implementation - public func captureView(_ view: UIView) async throws -> UIImage { - throw BLFeedbackError.notImplemented( - "captureView not yet implemented. Use UIGraphicsImageRenderer or drawHierarchy" - ) - } - - // MARK: - Private - - private func generateSASURL(contentType: BLScreenshotContentType) async throws -> BLFeedbackSASResponse { - let body = ["contentType": contentType.rawValue] - return try await client.request( - path: "/api/feedback/sas", - method: "POST", - body: body, - responseType: BLFeedbackSASResponse.self - ) - } - - private func uploadScreenshot(data: Data, sasURL: String, contentType: String) async throws { - guard let url = URL(string: sasURL) else { - throw BLNetworkError.invalidURL(sasURL) - } - - var request = URLRequest(url: url) - request.httpMethod = "PUT" - request.setValue(contentType, forHTTPHeaderField: "Content-Type") - request.setValue("BlockBlob", forHTTPHeaderField: "x-ms-blob-type") - request.httpBody = data - request.timeoutInterval = 60 - - let (_, response) = try await URLSession.shared.data(for: request) - - guard let http = response as? HTTPURLResponse, - (200...299).contains(http.statusCode) else { - throw BLNetworkError.httpError( - statusCode: (response as? HTTPURLResponse)?.statusCode ?? 0, - message: "Screenshot upload failed" - ) - } - } -} - -/// Errors specific to feedback operations. -public enum BLFeedbackError: Error, Sendable { - case notImplemented(String) - case uploadFailed(String) - case invalidScreenshot - case sizeLimitExceeded(Int, max: Int) -} diff --git a/vendor/bytelyst/swift-platform-sdk/Sources/BLFieldEncrypt.swift b/vendor/bytelyst/swift-platform-sdk/Sources/BLFieldEncrypt.swift deleted file mode 100644 index 0260d61..0000000 --- a/vendor/bytelyst/swift-platform-sdk/Sources/BLFieldEncrypt.swift +++ /dev/null @@ -1,277 +0,0 @@ -// ── Field Encryption ──────────────────────────────────────── -// AES-256-GCM field-level encryption compatible with @bytelyst/field-encrypt (TypeScript). -// Produces and consumes the same EncryptedField JSON structure across all platforms. -// Uses Apple CryptoKit — available on iOS 13+, macOS 10.15+, watchOS 6+. - -import CryptoKit -import Foundation - -// MARK: - EncryptedField Model - -/// Encrypted field structure — wire-compatible with @bytelyst/field-encrypt (TypeScript). -/// All byte arrays are hex-encoded strings for JSON serialization. -public struct BLEncryptedField: Codable, Sendable, Equatable { - /// Sentinel — always `true` for encrypted fields. - public let __encrypted: Bool - /// Schema version (currently 1). - public let v: Int - /// Algorithm identifier. - public let alg: String - /// Ciphertext (hex-encoded). - public let ct: String - /// Initialization vector (hex-encoded, 12 bytes = 24 hex chars). - public let iv: String - /// GCM authentication tag (hex-encoded, 16 bytes = 32 hex chars). - public let tag: String - /// DEK identifier — identifies which key was used for encryption. - public let dekId: String - - public init(ct: String, iv: String, tag: String, dekId: String) { - self.__encrypted = true - self.v = 1 - self.alg = "aes-256-gcm" - self.ct = ct - self.iv = iv - self.tag = tag - self.dekId = dekId - } -} - -// MARK: - BLFieldEncrypt - -/// AES-256-GCM field-level encryption. -/// -/// Produces `BLEncryptedField` objects that are wire-compatible with the -/// TypeScript `@bytelyst/field-encrypt` package. Backends and native clients -/// can encrypt/decrypt the same fields interchangeably. -/// -/// Usage: -/// ```swift -/// let key = BLFieldEncrypt.generateKey() -/// let encrypted = try BLFieldEncrypt.encrypt("sensitive data", key: key, dekId: "dek_user1_notes") -/// let decrypted = try BLFieldEncrypt.decrypt(encrypted, key: key) -/// ``` -public enum BLFieldEncrypt { - - /// AES-256-GCM key size in bytes. - public static let keySize = 32 - - /// GCM nonce (IV) size in bytes. - private static let nonceSize = 12 - - // MARK: - Encrypt - - /// Encrypt a plaintext string with AES-256-GCM. - /// - /// - Parameters: - /// - plaintext: UTF-8 string to encrypt. - /// - key: 32-byte symmetric key. - /// - dekId: DEK identifier stored in the output for key lookup on decrypt. - /// - aad: Optional additional authenticated data (e.g., "userId:context"). - /// - Returns: `BLEncryptedField` with hex-encoded ciphertext, IV, and tag. - /// - Throws: `BLFieldEncryptError` if key size is wrong or encryption fails. - public static func encrypt( - _ plaintext: String, - key: SymmetricKey, - dekId: String, - aad: String? = nil - ) throws -> BLEncryptedField { - guard key.bitCount == keySize * 8 else { - throw BLFieldEncryptError.invalidKeySize(expected: keySize, actual: key.bitCount / 8) - } - - let plaintextData = Data(plaintext.utf8) - let nonce = AES.GCM.Nonce() - - let sealedBox: AES.GCM.SealedBox - if let aad = aad { - sealedBox = try AES.GCM.seal( - plaintextData, - using: key, - nonce: nonce, - authenticating: Data(aad.utf8) - ) - } else { - sealedBox = try AES.GCM.seal(plaintextData, using: key, nonce: nonce) - } - - return BLEncryptedField( - ct: sealedBox.ciphertext.hexString, - iv: Data(sealedBox.nonce).hexString, - tag: sealedBox.tag.hexString, - dekId: dekId - ) - } - - // MARK: - Decrypt - - /// Decrypt a `BLEncryptedField` back to plaintext. - /// - /// - Parameters: - /// - field: Encrypted field object (from Cosmos DB or API response). - /// - key: 32-byte symmetric key (must match the key used to encrypt). - /// - aad: Optional AAD (must match the AAD used during encryption). - /// - Returns: Decrypted UTF-8 string. - /// - Throws: `BLFieldEncryptError` if decryption or authentication fails. - public static func decrypt( - _ field: BLEncryptedField, - key: SymmetricKey, - aad: String? = nil - ) throws -> String { - guard key.bitCount == keySize * 8 else { - throw BLFieldEncryptError.invalidKeySize(expected: keySize, actual: key.bitCount / 8) - } - - guard let ctData = Data(hexString: field.ct), - let ivData = Data(hexString: field.iv), - let tagData = Data(hexString: field.tag) else { - throw BLFieldEncryptError.invalidHexEncoding - } - - let nonce = try AES.GCM.Nonce(data: ivData) - let sealedBox = try AES.GCM.SealedBox(nonce: nonce, ciphertext: ctData, tag: tagData) - - let decryptedData: Data - if let aad = aad { - decryptedData = try AES.GCM.open(sealedBox, using: key, authenticating: Data(aad.utf8)) - } else { - decryptedData = try AES.GCM.open(sealedBox, using: key) - } - - guard let plaintext = String(data: decryptedData, encoding: .utf8) else { - throw BLFieldEncryptError.utf8DecodingFailed - } - - return plaintext - } - - // MARK: - Key Generation - - /// Generate a random 32-byte AES-256 symmetric key. - public static func generateKey() -> SymmetricKey { - SymmetricKey(size: .bits256) - } - - /// Create a `SymmetricKey` from a hex-encoded string (64 hex chars = 32 bytes). - public static func keyFromHex(_ hex: String) throws -> SymmetricKey { - guard let data = Data(hexString: hex), data.count == keySize else { - throw BLFieldEncryptError.invalidKeySize(expected: keySize, actual: hex.count / 2) - } - return SymmetricKey(data: data) - } - - // MARK: - Keychain Key Derivation - - /// Get or create a persistent encryption key in the Keychain. - /// - /// On first call, generates a random 32-byte AES-256 key and stores it - /// as a hex string in the Keychain. On subsequent calls, loads the - /// existing key. This provides a stable per-device DEK for client-side - /// encryption without requiring the backend to provision keys. - /// - /// - Parameters: - /// - service: Keychain service identifier (typically the app's bundle ID). - /// - account: Keychain account key (e.g., `"field_encrypt_dek"`). - /// - Returns: A 32-byte `SymmetricKey` backed by Keychain storage. - /// - Throws: `BLFieldEncryptError.invalidHexEncoding` if stored key is corrupt. - public static func getOrCreateKey(service: String, account: String = "field_encrypt_dek") throws -> SymmetricKey { - if let existingHex = BLKeychain.read(service: service, key: account) { - return try keyFromHex(existingHex) - } - - let newKey = generateKey() - let hex = newKey.withUnsafeBytes { Data($0).hexString } - BLKeychain.save(service: service, key: account, value: hex) - return newKey - } - - /// Load an existing encryption key from the Keychain without creating one. - /// - /// - Parameters: - /// - service: Keychain service identifier. - /// - account: Keychain account key (e.g., `"field_encrypt_dek"`). - /// - Returns: The stored `SymmetricKey`, or `nil` if none exists. - public static func loadKey(service: String, account: String = "field_encrypt_dek") -> SymmetricKey? { - guard let hex = BLKeychain.read(service: service, key: account) else { return nil } - return try? keyFromHex(hex) - } - - /// Delete the stored encryption key from the Keychain. - /// - /// - Parameters: - /// - service: Keychain service identifier. - /// - account: Keychain account key. - /// - Returns: `true` if the key was deleted or didn't exist. - @discardableResult - public static func deleteKey(service: String, account: String = "field_encrypt_dek") -> Bool { - BLKeychain.delete(service: service, key: account) - } - - // MARK: - Type Guard - - /// Check if a JSON value represents an encrypted field. - /// Compatible with the TypeScript `isEncryptedField()` type guard. - public static func isEncrypted(_ value: Any?) -> Bool { - if let dict = value as? [String: Any], - let sentinel = dict["__encrypted"] as? Bool, - sentinel == true, - dict["v"] != nil, - dict["alg"] != nil, - dict["ct"] != nil, - dict["iv"] != nil, - dict["tag"] != nil, - dict["dekId"] != nil { - return true - } - return false - } - - /// Check if a `Codable` value is a `BLEncryptedField`. - public static func isEncrypted(_ field: BLEncryptedField?) -> Bool { - field?.__encrypted == true - } -} - -// MARK: - Errors - -public enum BLFieldEncryptError: LocalizedError { - case invalidKeySize(expected: Int, actual: Int) - case invalidHexEncoding - case utf8DecodingFailed - - public var errorDescription: String? { - switch self { - case .invalidKeySize(let expected, let actual): - return "AES-256-GCM requires a \(expected)-byte key, got \(actual)" - case .invalidHexEncoding: - return "Failed to decode hex-encoded field data" - case .utf8DecodingFailed: - return "Decrypted data is not valid UTF-8" - } - } -} - -// MARK: - Hex Helpers - -extension Data { - /// Hex-encode data to a lowercase string. - var hexString: String { - map { String(format: "%02x", $0) }.joined() - } - - /// Initialize Data from a hex-encoded string. - init?(hexString: String) { - let hex = hexString.lowercased() - guard hex.count.isMultiple(of: 2) else { return nil } - - var data = Data(capacity: hex.count / 2) - var index = hex.startIndex - while index < hex.endIndex { - let nextIndex = hex.index(index, offsetBy: 2) - guard let byte = UInt8(hex[index.. Void - let onTap: () async -> Void - - var body: some View { - HStack(alignment: .top, spacing: 12) { - VStack(alignment: .leading, spacing: 4) { - Text(message.title) - .font(.headline) - - if !message.body.isEmpty { - Text(message.body) - .font(.subheadline) - .foregroundColor(.secondary) - .lineLimit(2) - } - - if message.ctaText != nil { - Text("Tap to open") - .font(.caption) - .foregroundColor(.blue) - } - } - - Spacer() - - if message.dismissible { - Button(action: { Task { await onDismiss() } }) { - Image(systemName: "xmark") - .foregroundColor(.secondary) - .padding(8) - } - } - } - .padding() - .background( - RoundedRectangle(cornerRadius: 12) - .fill(backgroundColor) - .shadow(radius: 2) - ) - .contentShape(Rectangle()) - .onTapGesture { - Task { await onTap() } - } - } - - private var backgroundColor: Color { - switch message.priority { - case .urgent: - return Color.red.opacity(0.1) - case .high: - return Color.orange.opacity(0.1) - default: - return Color(.systemBackground) - } - } -} - -@available(iOS 15.0, *) -public struct BLBroadcastModal: View { - @ObservedObject var client: BLBroadcastClient - @State private var currentMessage: BLInAppMessage? - @State private var isPresented = false - - public init(client: BLBroadcastClient) { - self.client = client - } - - public var body: some View { - EmptyView() - .sheet(isPresented: $isPresented) { - if let message = currentMessage { - ModalContent( - message: message, - onDismiss: { await dismissMessage() }, - onAction: { await handleAction() } - ) - } - } - .task { - startPolling() - } - } - - private func startPolling() { - client.startPolling(interval: 30) { messages in - let modalMessages = messages.filter { - $0.status == .unread && ($0.style == .modal || $0.style == .fullscreen) - } - if let first = modalMessages.first, self.currentMessage == nil { - self.currentMessage = first - self.isPresented = true - } - } - } - - private func dismissMessage() async { - if let message = currentMessage { - try? await client.markDismissed(messageId: message.id) - } - isPresented = false - currentMessage = nil - } - - private func handleAction() async { - if let message = currentMessage { - _ = try? await client.trackClick(messageId: message.id) - if let urlString = message.ctaUrl, let url = URL(string: urlString) { - await UIApplication.shared.open(url) - } - try? await client.markRead(messageId: message.id) - } - isPresented = false - currentMessage = nil - } -} - -@available(iOS 15.0, *) -struct ModalContent: View { - let message: BLInAppMessage - let onDismiss: () async -> Void - let onAction: () async -> Void - - var body: some View { - NavigationView { - ScrollView { - VStack(spacing: 20) { - Text(message.title) - .font(.title2.bold()) - - if !message.body.isEmpty { - Text(message.body) - .font(.body) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - } - - if message.ctaText != nil { - Button(action: { Task { await onAction() } }) { - Text(message.ctaText!) - .font(.headline) - .foregroundColor(.white) - .frame(maxWidth: .infinity) - .padding() - .background(Color.blue) - .cornerRadius(12) - } - } - } - .padding() - } - .navigationBarItems( - trailing: message.dismissible ? Button("Close") { - Task { await onDismiss() } - } : nil - ) - } - } -} diff --git a/vendor/bytelyst/swift-platform-sdk/Sources/BLKeychain.swift b/vendor/bytelyst/swift-platform-sdk/Sources/BLKeychain.swift deleted file mode 100644 index eec56c9..0000000 --- a/vendor/bytelyst/swift-platform-sdk/Sources/BLKeychain.swift +++ /dev/null @@ -1,54 +0,0 @@ -// ── Keychain Helper ─────────────────────────────────────────── -// Generic Keychain CRUD for storing auth tokens securely. -// Service identifier is configurable per product via BLPlatformConfig.bundleId. - -import Foundation -import Security - -public enum BLKeychain { - - /// Save a string value to the Keychain. - @discardableResult - public static func save(service: String, key: String, value: String) -> Bool { - guard let data = value.data(using: .utf8) else { return false } - delete(service: service, key: key) - - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: key, - kSecValueData as String: data, - kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock, - ] - - return SecItemAdd(query as CFDictionary, nil) == errSecSuccess - } - - /// Read a string value from the Keychain. - public static func read(service: String, key: String) -> String? { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: key, - kSecReturnData as String: true, - kSecMatchLimit as String: kSecMatchLimitOne, - ] - - var result: AnyObject? - let status = SecItemCopyMatching(query as CFDictionary, &result) - guard status == errSecSuccess, let data = result as? Data else { return nil } - return String(data: data, encoding: .utf8) - } - - /// Delete a value from the Keychain. - @discardableResult - public static func delete(service: String, key: String) -> Bool { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: key, - ] - let status = SecItemDelete(query as CFDictionary) - return status == errSecSuccess || status == errSecItemNotFound - } -} diff --git a/vendor/bytelyst/swift-platform-sdk/Sources/BLKillSwitchClient.swift b/vendor/bytelyst/swift-platform-sdk/Sources/BLKillSwitchClient.swift deleted file mode 100644 index 736965d..0000000 --- a/vendor/bytelyst/swift-platform-sdk/Sources/BLKillSwitchClient.swift +++ /dev/null @@ -1,60 +0,0 @@ -// ── Kill Switch Client ────────────────────────────────────── -// Checks platform-service kill switch at app launch. -// If the app is disabled server-side, surfaces a maintenance message. -// Fails open — network errors allow the app to run. - -import Foundation - -/// Generic kill switch client for all ByteLyst iOS apps. -/// Checks GET /api/settings/kill-switch?productId=X at launch. -public final class BLKillSwitchClient { - - private let config: BLPlatformConfig - - /// Whether the app is disabled by the server. - public private(set) var isDisabled = false - - /// Maintenance message from the server (empty if not disabled). - public private(set) var maintenanceMessage = "" - - public init(config: BLPlatformConfig) { - self.config = config - } - - /// Check kill switch status. Non-blocking — defaults to enabled on failure (fail open). - public func check() async { - guard let url = URL(string: "\(config.baseURL)/api/settings/kill-switch?productId=\(config.productId)") else { return } - - var request = URLRequest(url: url) - request.timeoutInterval = 5 - request.setValue(config.productId, forHTTPHeaderField: "X-Product-Id") - request.setValue(UUID().uuidString, forHTTPHeaderField: "X-Request-Id") - - do { - let (data, response) = try await URLSession.shared.data(for: request) - guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return } - - struct KillSwitchResponse: Codable { - let enabled: Bool? - let disabled: Bool? - let message: String? - } - - let result = try JSONDecoder().decode(KillSwitchResponse.self, from: data) - - // Support both `enabled: false` and `disabled: true` patterns - if result.disabled == true || result.enabled == false { - isDisabled = true - maintenanceMessage = result.message ?? "\(config.productId) is temporarily unavailable for maintenance." - } - } catch { - // Network error — allow the app to run (fail open) - } - } - - /// Reset the kill switch state (e.g. for retry). - public func reset() { - isDisabled = false - maintenanceMessage = "" - } -} diff --git a/vendor/bytelyst/swift-platform-sdk/Sources/BLLicenseClient.swift b/vendor/bytelyst/swift-platform-sdk/Sources/BLLicenseClient.swift deleted file mode 100644 index f6926f1..0000000 --- a/vendor/bytelyst/swift-platform-sdk/Sources/BLLicenseClient.swift +++ /dev/null @@ -1,104 +0,0 @@ -// ── License Client ────────────────────────────────────────── -// Generic license key activation via platform-service. -// Flow: enter key → POST /api/licenses/activate → receive tokens. -// Product apps configure with BLPlatformConfig. - -import Foundation - -/// License information returned from platform-service. -public struct BLLicenseInfo: Codable, Sendable { - public let key: String - public let plan: String - public let status: String - public let devicesUsed: Int - public let maxDevices: Int - public let expiresAt: String? - - public init(key: String, plan: String, status: String, devicesUsed: Int, maxDevices: Int, expiresAt: String?) { - self.key = key - self.plan = plan - self.status = status - self.devicesUsed = devicesUsed - self.maxDevices = maxDevices - self.expiresAt = expiresAt - } -} - -/// Activation result containing tokens + license info. -public struct BLActivationResult: Sendable { - public let accessToken: String - public let refreshToken: String - public let license: BLLicenseInfo -} - -/// Generic license client for all ByteLyst iOS apps. -/// Handles license key activation and status checking via platform-service. -public final class BLLicenseClient { - - private let config: BLPlatformConfig - private let client: BLPlatformClient - - public init(config: BLPlatformConfig, client: BLPlatformClient) { - self.config = config - self.client = client - } - - // MARK: - Activate - - /// Activate a license key on this device. - /// Returns activation result with tokens and license info. - public func activate(key: String, deviceId: String, deviceName: String) async throws -> BLActivationResult { - let body: [String: String] = [ - "key": key.uppercased().trimmingCharacters(in: .whitespaces), - "deviceId": deviceId, - "deviceName": deviceName, - "platform": config.platform, - ] - - let (data, _) = try await client.rawRequest(path: "/api/licenses/activate", method: "POST", body: body) - - struct ActivateResponse: Codable { - let accessToken: String - let refreshToken: String - let license: LicenseDoc - } - struct LicenseDoc: Codable { - let key: String - let plan: String - let status: String - let deviceIds: [String] - let maxDevices: Int - let expiresAt: String? - let userId: String - } - - let result = try JSONDecoder().decode(ActivateResponse.self, from: data) - - let info = BLLicenseInfo( - key: result.license.key, - plan: result.license.plan, - status: result.license.status, - devicesUsed: result.license.deviceIds.count, - maxDevices: result.license.maxDevices, - expiresAt: result.license.expiresAt - ) - - return BLActivationResult( - accessToken: result.accessToken, - refreshToken: result.refreshToken, - license: info - ) - } - - // MARK: - Status - - /// Check license status without activating. - public func checkStatus(key: String) async throws -> BLLicenseInfo { - let trimmedKey = key.uppercased().trimmingCharacters(in: .whitespaces) - let encodedKey = trimmedKey.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? trimmedKey - return try await client.request( - path: "/api/licenses/status/\(encodedKey)", - responseType: BLLicenseInfo.self - ) - } -} diff --git a/vendor/bytelyst/swift-platform-sdk/Sources/BLPlatformClient.swift b/vendor/bytelyst/swift-platform-sdk/Sources/BLPlatformClient.swift deleted file mode 100644 index 8017f83..0000000 --- a/vendor/bytelyst/swift-platform-sdk/Sources/BLPlatformClient.swift +++ /dev/null @@ -1,234 +0,0 @@ -// ── Platform HTTP Client ───────────────────────────────────── -// Generic URLSession wrapper with auth header injection, x-request-id, -// timeout, and product ID header. Used by all BL* services. - -import Foundation - -public final class BLPlatformClient: @unchecked Sendable { - - public let config: BLPlatformConfig - private let session: URLSession - private let encoder: JSONEncoder - private let decoder: JSONDecoder - - /// Auth token injected by BLAuthClient after login/refresh. - private var _authToken: String? - private let tokenLock = NSLock() - - public var authToken: String? { - get { tokenLock.lock(); defer { tokenLock.unlock() }; return _authToken } - set { tokenLock.lock(); defer { tokenLock.unlock() }; _authToken = newValue } - } - - public init(config: BLPlatformConfig, timeoutSeconds: TimeInterval = 15) { - self.config = config - - let urlConfig = URLSessionConfiguration.default - urlConfig.timeoutIntervalForRequest = timeoutSeconds - urlConfig.waitsForConnectivity = true - session = URLSession(configuration: urlConfig) - - encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 - - decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - } - - /// Test-only initializer accepting a custom URLSessionConfiguration - /// so callers can inject MockURLProtocol for intercepting requests. - public init(config: BLPlatformConfig, sessionConfiguration: URLSessionConfiguration) { - self.config = config - session = URLSession(configuration: sessionConfiguration) - - encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 - - decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - } - - // MARK: - Public Request Methods - - /// Perform an authenticated request and decode the response. - public func request( - path: String, - method: String = "GET", - body: (any Encodable)? = nil, - responseType: T.Type - ) async throws -> T { - let (data, _) = try await rawRequest(path: path, method: method, body: body) - return try decoder.decode(T.self, from: data) - } - - /// Perform an authenticated request, returning raw (Data, HTTPURLResponse). - public func rawRequest( - path: String, - method: String = "GET", - body: (any Encodable)? = nil - ) async throws -> (Data, HTTPURLResponse) { - guard let url = URL(string: "\(config.baseURL)\(path)") else { - throw BLNetworkError.invalidURL(path) - } - - var request = URLRequest(url: url) - request.httpMethod = method - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.setValue(config.productId, forHTTPHeaderField: "X-Product-Id") - request.setValue(UUID().uuidString, forHTTPHeaderField: "X-Request-Id") - - if let token = authToken { - request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - } - - if let body { - request.httpBody = try encoder.encode(body) - } - - let (data, response) = try await session.data(for: request) - - guard let http = response as? HTTPURLResponse else { - throw BLNetworkError.invalidResponse - } - - guard (200...299).contains(http.statusCode) else { - // Try to extract server error message - let message: String? = { - if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let msg = json["message"] as? String { return msg } - return nil - }() - throw BLNetworkError.httpError(statusCode: http.statusCode, message: message) - } - - return (data, http) - } - - /// Perform an authenticated request with pre-encoded JSON body data. - /// Used by passkey methods that need to send raw JSON (e.g. from JSONSerialization). - public func rawRequest( - path: String, - method: String = "GET", - rawBody: Data? = nil - ) async throws -> (Data, HTTPURLResponse) { - guard let url = URL(string: "\(config.baseURL)\(path)") else { - throw BLNetworkError.invalidURL(path) - } - - var request = URLRequest(url: url) - request.httpMethod = method - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.setValue(config.productId, forHTTPHeaderField: "X-Product-Id") - request.setValue(UUID().uuidString, forHTTPHeaderField: "X-Request-Id") - - if let token = authToken { - request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - } - - request.httpBody = rawBody - - let (data, response) = try await session.data(for: request) - - guard let http = response as? HTTPURLResponse else { - throw BLNetworkError.invalidResponse - } - - guard (200...299).contains(http.statusCode) else { - let message: String? = { - if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let msg = json["message"] as? String { return msg } - return nil - }() - throw BLNetworkError.httpError(statusCode: http.statusCode, message: message) - } - - return (data, http) - } - - /// Fire-and-forget POST (used by telemetry — errors silently ignored). - public func fireAndForget(path: String, body: Data) { - guard let url = URL(string: "\(config.baseURL)\(path)") else { return } - - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.httpBody = body - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.setValue(config.productId, forHTTPHeaderField: "X-Product-Id") - request.setValue(UUID().uuidString, forHTTPHeaderField: "X-Request-Id") - request.timeoutInterval = 10 - - if let token = authToken { - request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - } - - session.dataTask(with: request) { _, _, _ in }.resume() - } - - // MARK: - Request Builder - - /// Build an authenticated URLRequest (used by BLBroadcastClient, BLSurveyClient). - public func buildRequest( - path: String, - method: String = "GET", - body: [String: Any]? = nil - ) throws -> URLRequest { - guard let url = URL(string: "\(config.baseURL)\(path)") else { - throw BLNetworkError.invalidURL(path) - } - - var request = URLRequest(url: url) - request.httpMethod = method - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.setValue(config.productId, forHTTPHeaderField: "X-Product-Id") - request.setValue(UUID().uuidString, forHTTPHeaderField: "X-Request-Id") - - if let token = authToken { - request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - } - - if let body { - request.httpBody = try JSONSerialization.data(withJSONObject: body) - } - - return request - } - - // MARK: - Encoder/Decoder Access - - public var jsonEncoder: JSONEncoder { encoder } - public var jsonDecoder: JSONDecoder { decoder } -} - -// MARK: - Errors - -/// Legacy alias used by BLBroadcastClient, BLSurveyClient, BLDeepLinkRouter. -public enum BLPlatformError: LocalizedError { - case requestFailed(String) - - public var errorDescription: String? { - switch self { - case .requestFailed(let msg): return msg - } - } -} - -public enum BLNetworkError: LocalizedError { - case invalidURL(String) - case invalidResponse - case httpError(statusCode: Int, message: String?) - case notAuthenticated - - public var errorDescription: String? { - switch self { - case .invalidURL(let path): return "Invalid URL: \(path)" - case .invalidResponse: return "Invalid server response" - case .httpError(let code, let msg): return msg ?? "Server error (\(code))" - case .notAuthenticated: return "Not signed in" - } - } - - public var statusCode: Int? { - if case .httpError(let code, _) = self { return code } - return nil - } -} diff --git a/vendor/bytelyst/swift-platform-sdk/Sources/BLPlatformConfig.swift b/vendor/bytelyst/swift-platform-sdk/Sources/BLPlatformConfig.swift deleted file mode 100644 index c0c7c61..0000000 --- a/vendor/bytelyst/swift-platform-sdk/Sources/BLPlatformConfig.swift +++ /dev/null @@ -1,64 +0,0 @@ -// ── Platform Configuration ─────────────────────────────────── -// Product-specific config that every BL* service reads from. -// Each app creates ONE config at launch and passes it to all services. - -import Foundation - -/// Configuration for all ByteLyst platform services. -/// Create one instance at app launch and inject into BLTelemetryClient, BLAuthClient, etc. -public struct BLPlatformConfig { - /// Product identifier (e.g. "chronomind", "lysnrai", "peakpulse", "nomgap", "mindlyst"). - public let productId: String - - /// Platform-service base URL (e.g. "https://api.chronomind.app" or "http://localhost:4003/api"). - public let baseURL: String - - /// Platform string sent in telemetry (e.g. "ios", "watchos", "macos"). - public let platform: String - - /// Channel string sent in telemetry (e.g. "native", "mobile_app"). - public let channel: String - - /// Bundle ID used as Keychain service identifier. - public let bundleId: String - - /// App Group ID for sharing data between app and extensions (optional). - public let appGroupId: String? - - public init( - productId: String, - baseURL: String, - platform: String = "ios", - channel: String = "native", - bundleId: String, - appGroupId: String? = nil - ) { - self.productId = productId - self.baseURL = baseURL - self.platform = platform - self.channel = channel - self.bundleId = bundleId - self.appGroupId = appGroupId - } - - /// Convenience: read PLATFORM_SERVICE_URL from Info.plist, fall back to provided default. - public static func fromInfoPlist( - productId: String, - defaultBaseURL: String, - platform: String = "ios", - channel: String = "native", - bundleId: String, - appGroupId: String? = nil - ) -> BLPlatformConfig { - let url = Bundle.main.object(forInfoDictionaryKey: "PLATFORM_SERVICE_URL") as? String - ?? defaultBaseURL - return BLPlatformConfig( - productId: productId, - baseURL: url, - platform: platform, - channel: channel, - bundleId: bundleId, - appGroupId: appGroupId - ) - } -} diff --git a/vendor/bytelyst/swift-platform-sdk/Sources/BLSurveyClient.swift b/vendor/bytelyst/swift-platform-sdk/Sources/BLSurveyClient.swift deleted file mode 100644 index dc31f23..0000000 --- a/vendor/bytelyst/swift-platform-sdk/Sources/BLSurveyClient.swift +++ /dev/null @@ -1,369 +0,0 @@ -// ── Survey Client ──────────────────────────────────────── -// In-app survey client for iOS/watchOS/macOS. -// Part of ByteLystPlatformSDK. - -import Foundation - -/// Survey question types. -public enum BLSurveyQuestionType: String, Codable, Sendable { - case singleChoice = "single_choice" - case multipleChoice = "multiple_choice" - case rating = "rating" - case nps = "nps" - case textShort = "text_short" - case textLong = "text_long" - case dropdown = "dropdown" - case scale = "scale" - case ranking = "ranking" -} - -/// Survey status. -public enum BLSurveyStatus: String, Codable, Sendable { - case draft = "draft" - case active = "active" - case paused = "paused" - case closed = "closed" -} - -/// Represents a survey question option. -public struct BLSurveyOption: Codable, Sendable, Identifiable { - public let id: String - public let text: String - public let emoji: String? -} - -/// Represents a survey question. -public struct BLSurveyQuestion: Codable, Sendable, Identifiable { - public let id: String - public let type: BLSurveyQuestionType - public let text: String - public let description: String? - public let required: Bool - public let options: [BLSurveyOption]? - public let minLength: Int? - public let maxLength: Int? - public let minValue: Int? - public let maxValue: Int? -} - -/// Represents an active survey for display. -public struct BLActiveSurvey: Codable, Sendable, Identifiable { - public let id: String - public let title: String - public let description: String? - public let questions: [BLSurveyQuestion] - public let incentive: BLSurveyIncentive? - public let displayTrigger: BLSurveyTrigger -} - -/// Survey incentive. -public struct BLSurveyIncentive: Codable, Sendable { - public let type: String - public let amount: Int -} - -/// Survey display trigger. -public struct BLSurveyTrigger: Codable, Sendable { - public let type: String - public let seconds: Int? - public let eventName: String? - public let pagePattern: String? -} - -/// Survey answer types. -public enum BLSurveyAnswer: Codable, Sendable { - case singleChoice(optionId: String) - case multipleChoice(optionIds: [String]) - case rating(value: Int) - case nps(value: Int) - case text(value: String) - case ranking(rankedOptionIds: [String]) - - public var type: String { - switch self { - case .singleChoice: return "single_choice" - case .multipleChoice: return "multiple_choice" - case .rating: return "rating" - case .nps: return "nps" - case .text: return "text" - case .ranking: return "ranking" - } - } -} - -/// Represents a survey response. -public struct BLSurveyResponse: Codable, Sendable { - public let id: String - public let surveyId: String - public let userId: String - public var answers: [String: BLSurveyAnswer] - public var currentQuestionIndex: Int - public let startedAt: String - public var completedAt: String? - public var isComplete: Bool - public var incentiveClaimed: Bool - public var incentiveClaimedAt: String? - public let createdAt: String - public let updatedAt: String -} - -/// Survey client for managing in-app surveys. -@available(iOS 15.0, macOS 12.0, watchOS 8.0, *) -public class BLSurveyClient: ObservableObject { - private let platformClient: BLPlatformClient - private var pollTask: Task? - private var cachedResponses: [String: BLSurveyResponse] = [:] - - public init(platformClient: BLPlatformClient) { - self.platformClient = platformClient - } - - /// Get active survey for the current user (if any). - public func getActiveSurvey() async throws -> BLActiveSurvey? { - let request = try platformClient.buildRequest(path: "/surveys/active") - let (data, response) = try await URLSession.shared.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse, - httpResponse.statusCode == 200 else { - throw BLPlatformError.requestFailed(String(data: data, encoding: .utf8) ?? "Unknown error") - } - - let result = try JSONDecoder().decode(ActiveSurveyResponse.self, from: data) - return result.survey - } - - /// Start a survey session. - public func startSurvey(surveyId: String) async throws -> BLSurveyResponse { - let request = try platformClient.buildRequest( - path: "/surveys/\(surveyId)/start", - method: "POST" - ) - let (data, response) = try await URLSession.shared.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse, - httpResponse.statusCode == 200 else { - throw BLPlatformError.requestFailed("Failed to start survey") - } - - let result = try JSONDecoder().decode(StartSurveyResponse.self, from: data) - - // Build and cache the response - let surveyResponse = BLSurveyResponse( - id: result.responseId, - surveyId: surveyId, - userId: "", // Will be filled by server - answers: result.answers, - currentQuestionIndex: result.currentQuestionIndex, - startedAt: result.startedAt, - completedAt: nil, - isComplete: false, - incentiveClaimed: false, - incentiveClaimedAt: nil, - createdAt: result.startedAt, - updatedAt: result.startedAt - ) - - cachedResponses[surveyId] = surveyResponse - return surveyResponse - } - - /// Submit an answer to a survey question. - public func submitAnswer( - surveyId: String, - questionId: String, - answer: BLSurveyAnswer - ) async throws -> BLSurveyResponse { - let request = try platformClient.buildRequest( - path: "/surveys/\(surveyId)/response", - method: "POST", - body: [ - "questionId": questionId, - "answer": [ - "type": answer.type, - "value": encodeAnswerValue(answer) - ] - ] - ) - - let (data, response) = try await URLSession.shared.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse, - httpResponse.statusCode == 200 else { - throw BLPlatformError.requestFailed("Failed to submit answer") - } - - let result = try JSONDecoder().decode(SubmitAnswerResponse.self, from: data) - - // Update cache - if var cached = cachedResponses[surveyId] { - cached.answers = result.answers - cached.currentQuestionIndex = result.currentQuestionIndex - cachedResponses[surveyId] = cached - } - - // Return updated response - return BLSurveyResponse( - id: result.responseId, - surveyId: surveyId, - userId: "", - answers: result.answers, - currentQuestionIndex: result.currentQuestionIndex, - startedAt: "", - completedAt: nil, - isComplete: false, - incentiveClaimed: false, - incentiveClaimedAt: nil, - createdAt: "", - updatedAt: Date().ISO8601Format() - ) - } - - /// Complete a survey. - public func completeSurvey(surveyId: String) async throws -> SurveyCompletionResult { - let request = try platformClient.buildRequest( - path: "/surveys/\(surveyId)/complete", - method: "POST" - ) - let (data, response) = try await URLSession.shared.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse, - httpResponse.statusCode == 200 else { - throw BLPlatformError.requestFailed("Failed to complete survey") - } - - let result = try JSONDecoder().decode(SurveyCompletionResult.self, from: data) - - // Clear cache on completion - cachedResponses.removeValue(forKey: surveyId) - - return result - } - - /// Dismiss a survey (won't show again). - public func dismissSurvey(surveyId: String) async throws { - let request = try platformClient.buildRequest( - path: "/surveys/\(surveyId)/dismiss", - method: "POST" - ) - let (_, response) = try await URLSession.shared.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse, - httpResponse.statusCode == 200 else { - throw BLPlatformError.requestFailed("Failed to dismiss survey") - } - - // Clear cache - cachedResponses.removeValue(forKey: surveyId) - } - - /// Get cached response for a survey. - public func getCachedResponse(surveyId: String) -> BLSurveyResponse? { - return cachedResponses[surveyId] - } - - /// Start polling for eligible surveys. - public func startPolling( - interval: TimeInterval = 60, - onUpdate: @escaping (BLActiveSurvey?) -> Void - ) { - stopPolling() - - pollTask = Task { - while !Task.isCancelled { - do { - let survey = try await getActiveSurvey() - onUpdate(survey) - } catch { - // Silently ignore polling errors - } - - try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) - } - } - } - - /// Stop polling for surveys. - public func stopPolling() { - pollTask?.cancel() - pollTask = nil - } -} - -// MARK: - Survey Completion Result - -public struct SurveyCompletionResult: Codable, Sendable { - public let success: Bool - public let timeSpentSeconds: Int - public let incentiveClaimed: Bool -} - -// MARK: - Response Types - -private struct ActiveSurveyResponse: Codable { - let survey: BLActiveSurvey? -} - -private struct StartSurveyResponse: Codable { - let responseId: String - let startedAt: String - let currentQuestionIndex: Int - let answers: [String: BLSurveyAnswer] -} - -private struct SubmitAnswerResponse: Codable { - let responseId: String - let currentQuestionIndex: Int - let answers: [String: BLSurveyAnswer] -} - -// MARK: - Helper Functions - -private func encodeAnswerValue(_ answer: BLSurveyAnswer) -> AnyCodable { - switch answer { - case .singleChoice(let optionId): - return AnyCodable(optionId) - case .multipleChoice(let optionIds): - return AnyCodable(optionIds) - case .rating(let value), .nps(let value): - return AnyCodable(value) - case .text(let value): - return AnyCodable(value) - case .ranking(let rankedOptionIds): - return AnyCodable(rankedOptionIds) - } -} - -/// Type-erased codable wrapper for encoding heterogeneous types. -private struct AnyCodable: Codable { - private let value: Any - - init(_ value: Any) { - self.value = value - } - - func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - if let string = value as? String { - try container.encode(string) - } else if let int = value as? Int { - try container.encode(int) - } else if let array = value as? [String] { - try container.encode(array) - } else { - try container.encode(String(describing: value)) - } - } - - init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - if let string = try? container.decode(String.self) { - value = string - } else if let int = try? container.decode(Int.self) { - value = int - } else if let array = try? container.decode([String].self) { - value = array - } else { - value = "" - } - } -} diff --git a/vendor/bytelyst/swift-platform-sdk/Sources/BLSurveyUI.swift b/vendor/bytelyst/swift-platform-sdk/Sources/BLSurveyUI.swift deleted file mode 100644 index 57fb887..0000000 --- a/vendor/bytelyst/swift-platform-sdk/Sources/BLSurveyUI.swift +++ /dev/null @@ -1,592 +0,0 @@ -import SwiftUI - -/** - * Survey Modal — SwiftUI component for displaying and completing surveys. - * Part of ByteLystPlatformSDK. - */ -@available(iOS 15.0, *) -public struct BLSurveyModal: View { - @ObservedObject var client: BLSurveyClient - @State private var survey: BLActiveSurvey? - @State private var currentQuestionIndex = 0 - @State private var answers: [String: BLSurveyAnswer] = [:] - @State private var isComplete = false - @State private var showCompletion = false - @State private var isPresented = false - - // Local state for question answers - @State private var selectedOption: String? - @State private var selectedOptions: Set = [] - @State private var ratingValue: Int = 0 - @State private var textAnswer: String = "" - @State private var rankingOrder: [String] = [] - - public init(client: BLSurveyClient) { - self.client = client - } - - public var body: some View { - EmptyView() - .sheet(isPresented: $isPresented) { - surveyContent - } - .task { - await checkForSurvey() - startPolling() - } - } - - @ViewBuilder - private var surveyContent: some View { - if showCompletion { - CompletionView( - survey: survey, - onDismiss: { dismissSurvey() } - ) - } else if let survey = survey, currentQuestionIndex < survey.questions.count { - let question = survey.questions[currentQuestionIndex] - QuestionView( - survey: survey, - question: question, - questionIndex: currentQuestionIndex, - totalQuestions: survey.questions.count, - selectedOption: $selectedOption, - selectedOptions: $selectedOptions, - ratingValue: $ratingValue, - textAnswer: $textAnswer, - rankingOrder: $rankingOrder, - onSubmit: { await submitAnswer(question) }, - onSkip: { await skipQuestion() }, - onDismiss: { dismissSurvey() } - ) - } - } - - private func checkForSurvey() async { - do { - if let activeSurvey = try await client.getActiveSurvey() { - survey = activeSurvey - if !isPresented { - isPresented = true - } - } - } catch { - // Silently ignore - } - } - - private func startPolling() { - client.startPolling(interval: 60) { newSurvey in - if let newSurvey = newSurvey, self.survey == nil { - self.survey = newSurvey - self.isPresented = true - } - } - } - - private func submitAnswer(_ question: BLSurveyQuestion) async { - let answer: BLSurveyAnswer - - switch question.type { - case .singleChoice, .dropdown: - guard let value = selectedOption else { return } - answer = .singleChoice(optionId: value) - case .multipleChoice: - let values = Array(selectedOptions) - answer = .multipleChoice(optionIds: values) - case .rating, .scale: - answer = .rating(value: ratingValue) - case .nps: - answer = .nps(value: ratingValue) - case .textShort, .textLong: - answer = .text(value: textAnswer) - case .ranking: - answer = .ranking(rankedOptionIds: rankingOrder) - } - - do { - let response = try await client.submitAnswer(surveyId: survey!.id, questionId: question.id, answer: answer) - currentQuestionIndex = response.currentQuestionIndex - answers = response.answers - resetQuestionState() - - if currentQuestionIndex >= survey!.questions.count { - await completeSurvey() - } - } catch { - // Silently ignore submit errors - } - } - - private func skipQuestion() async { - let question = survey!.questions[currentQuestionIndex] - if !question.required { - // Skip by advancing without submitting - currentQuestionIndex += 1 - resetQuestionState() - - if currentQuestionIndex >= survey!.questions.count { - await completeSurvey() - } - } - } - - private func completeSurvey() async { - do { - let completion = try await client.completeSurvey(surveyId: survey!.id) - if completion.success { - isComplete = true - showCompletion = true - } - } catch { - // Silently ignore - } - } - - private func dismissSurvey() { - if let survey = survey { - Task { - try? await client.dismissSurvey(surveyId: survey.id) - } - } - isPresented = false - resetSurvey() - } - - private func resetSurvey() { - survey = nil - currentQuestionIndex = 0 - answers = [:] - isComplete = false - showCompletion = false - resetQuestionState() - } - - private func resetQuestionState() { - selectedOption = nil - selectedOptions = [] - ratingValue = 0 - textAnswer = "" - rankingOrder = [] - } -} - -@available(iOS 15.0, *) -struct QuestionView: View { - let survey: BLActiveSurvey - let question: BLSurveyQuestion - let questionIndex: Int - let totalQuestions: Int - - @Binding var selectedOption: String? - @Binding var selectedOptions: Set - @Binding var ratingValue: Int - @Binding var textAnswer: String - @Binding var rankingOrder: [String] - - let onSubmit: () async -> Void - let onSkip: () async -> Void - let onDismiss: () -> Void - - var body: some View { - NavigationView { - ScrollView { - VStack(spacing: 24) { - // Progress bar - ProgressView(value: Double(questionIndex + 1), total: Double(totalQuestions)) - .padding(.horizontal) - - Text("Question \(questionIndex + 1) of \(totalQuestions)") - .font(.caption) - .foregroundColor(.secondary) - - // Question text - VStack(alignment: .leading, spacing: 8) { - Text(question.text) - .font(.title3.bold()) - - if let description = question.description { - Text(description) - .font(.subheadline) - .foregroundColor(.secondary) - } - - if question.required { - Text("Required") - .font(.caption) - .foregroundColor(.red) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal) - - // Question input based on type - questionInput - - Spacer() - - // Action buttons - VStack(spacing: 12) { - Button(action: { Task { await onSubmit() } }) { - Text(isLast ? "Complete" : "Next") - .font(.headline) - .foregroundColor(.white) - .frame(maxWidth: .infinity) - .padding() - .background(canSubmit ? Color.blue : Color.gray) - .cornerRadius(12) - } - .disabled(!canSubmit) - - if !question.required { - Button(action: { Task { await onSkip() } }) { - Text("Skip") - .font(.subheadline) - .foregroundColor(.secondary) - } - } - } - .padding(.horizontal) - } - .padding(.vertical) - } - .navigationTitle(survey.title) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button("Dismiss") { - onDismiss() - } - } - } - } - } - - private var isLast: Bool { - questionIndex == totalQuestions - 1 - } - - private var canSubmit: Bool { - if !question.required { return true } - - switch question.type { - case .singleChoice, .dropdown: - return selectedOption != nil - case .multipleChoice: - return !selectedOptions.isEmpty - case .rating, .scale, .nps: - return ratingValue > 0 - case .textShort, .textLong: - return !textAnswer.isEmpty - case .ranking: - return rankingOrder.count == (question.options?.count ?? 0) - } - } - - @ViewBuilder - private var questionInput: some View { - switch question.type { - case .singleChoice, .dropdown: - SingleChoiceView( - options: question.options ?? [], - selected: $selectedOption - ) - case .multipleChoice: - MultipleChoiceView( - options: question.options ?? [], - selected: $selectedOptions - ) - case .rating, .scale, .nps: - RatingView( - minValue: question.minValue ?? (question.type == .nps ? 0 : 1), - maxValue: question.maxValue ?? (question.type == .nps ? 10 : 5), - rating: $ratingValue - ) - case .textShort, .textLong: - TextAnswerView( - text: $textAnswer, - isLong: question.type == .textLong, - minLength: question.minLength, - maxLength: question.maxLength - ) - case .ranking: - RankingView( - options: question.options ?? [], - order: $rankingOrder - ) - } - } -} - -// MARK: - Question Type Views - -@available(iOS 15.0, *) -struct SingleChoiceView: View { - let options: [BLSurveyOption] - @Binding var selected: String? - - var body: some View { - VStack(spacing: 8) { - ForEach(options) { option in - Button(action: { selected = option.id }) { - HStack { - Text(option.emoji ?? "") - Text(option.text) - .foregroundColor(.primary) - Spacer() - if selected == option.id { - Image(systemName: "checkmark.circle.fill") - .foregroundColor(.blue) - } else { - Image(systemName: "circle") - .foregroundColor(.secondary) - } - } - .padding() - .background( - RoundedRectangle(cornerRadius: 8) - .fill(selected == option.id ? Color.blue.opacity(0.1) : Color(.systemGray6)) - ) - } - } - } - .padding(.horizontal) - } -} - -@available(iOS 15.0, *) -struct MultipleChoiceView: View { - let options: [BLSurveyOption] - @Binding var selected: Set - - var body: some View { - VStack(spacing: 8) { - ForEach(options) { option in - Button(action: { toggleOption(option.id) }) { - HStack { - Text(option.emoji ?? "") - Text(option.text) - .foregroundColor(.primary) - Spacer() - if selected.contains(option.id) { - Image(systemName: "checkmark.square.fill") - .foregroundColor(.blue) - } else { - Image(systemName: "square") - .foregroundColor(.secondary) - } - } - .padding() - .background( - RoundedRectangle(cornerRadius: 8) - .fill(selected.contains(option.id) ? Color.blue.opacity(0.1) : Color(.systemGray6)) - ) - } - } - } - .padding(.horizontal) - } - - private func toggleOption(_ id: String) { - if selected.contains(id) { - selected.remove(id) - } else { - selected.insert(id) - } - } -} - -@available(iOS 15.0, *) -struct RatingView: View { - let minValue: Int - let maxValue: Int - @Binding var rating: Int - - var body: some View { - VStack(spacing: 16) { - HStack(spacing: 8) { - ForEach(minValue...maxValue, id: \.self) { value in - Button(action: { rating = value }) { - Text("\(value)") - .font(.headline) - .frame(width: 44, height: 44) - .background( - Circle() - .fill(rating == value ? Color.blue : Color(.systemGray5)) - ) - .foregroundColor(rating == value ? .white : .primary) - } - } - } - - HStack { - Text("Low") - .font(.caption) - .foregroundColor(.secondary) - Spacer() - Text("High") - .font(.caption) - .foregroundColor(.secondary) - } - .padding(.horizontal, 32) - } - } -} - -@available(iOS 15.0, *) -struct TextAnswerView: View { - @Binding var text: String - let isLong: Bool - let minLength: Int? - let maxLength: Int? - - var body: some View { - VStack { - if isLong { - TextEditor(text: $text) - .frame(minHeight: 120) - .padding(8) - .background(Color(.systemGray6)) - .cornerRadius(8) - } else { - TextField("Your answer", text: $text) - .padding() - .background(Color(.systemGray6)) - .cornerRadius(8) - } - - if let max = maxLength { - Text("\(text.count)/\(max)") - .font(.caption) - .foregroundColor(text.count > max ? .red : .secondary) - } - } - .padding(.horizontal) - } -} - -@available(iOS 15.0, *) -struct RankingView: View { - let options: [BLSurveyOption] - @Binding var order: [String] - - var body: some View { - VStack(spacing: 8) { - ForEach(options) { option in - HStack { - Text("\(order.firstIndex(of: option.id).map { "\($0 + 1)" } ?? "-")") - .font(.caption) - .frame(width: 24, height: 24) - .background( - Circle() - .fill(order.contains(option.id) ? Color.blue : Color(.systemGray5)) - ) - .foregroundColor(order.contains(option.id) ? .white : .secondary) - - Text(option.text) - - Spacer() - - HStack(spacing: 4) { - Button(action: { moveUp(option.id) }) { - Image(systemName: "arrow.up") - } - .disabled(!canMoveUp(option.id)) - - Button(action: { moveDown(option.id) }) { - Image(systemName: "arrow.down") - } - .disabled(!canMoveDown(option.id)) - - Button(action: { addToRanking(option.id) }) { - Image(systemName: "plus") - } - .disabled(order.contains(option.id)) - } - } - .padding() - .background(Color(.systemGray6)) - .cornerRadius(8) - } - } - .padding(.horizontal) - } - - private func canMoveUp(_ id: String) -> Bool { - guard let index = order.firstIndex(of: id), index > 0 else { return false } - return true - } - - private func canMoveDown(_ id: String) -> Bool { - guard let index = order.firstIndex(of: id), index < order.count - 1 else { return false } - return true - } - - private func moveUp(_ id: String) { - guard let index = order.firstIndex(of: id), index > 0 else { return } - order.swapAt(index, index - 1) - } - - private func moveDown(_ id: String) { - guard let index = order.firstIndex(of: id), index < order.count - 1 else { return } - order.swapAt(index, index + 1) - } - - private func addToRanking(_ id: String) { - if !order.contains(id) { - order.append(id) - } - } -} - -@available(iOS 15.0, *) -struct CompletionView: View { - let survey: BLActiveSurvey? - let onDismiss: () -> Void - - var body: some View { - NavigationView { - VStack(spacing: 24) { - Image(systemName: "checkmark.circle.fill") - .font(.system(size: 64)) - .foregroundColor(.green) - - Text("Thank You!") - .font(.title.bold()) - - Text("Your feedback helps us improve.") - .font(.body) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - - if let incentive = survey?.incentive { - HStack { - Image(systemName: "gift.fill") - .foregroundColor(.green) - Text("You've earned \(incentive.amount) \(incentive.type == "pro_days" ? "Pro Days" : "Credits")!") - .font(.headline) - .foregroundColor(.green) - } - .padding() - .background(Color.green.opacity(0.1)) - .cornerRadius(12) - } - - Spacer() - - Button(action: onDismiss) { - Text("Close") - .font(.headline) - .foregroundColor(.white) - .frame(maxWidth: .infinity) - .padding() - .background(Color.blue) - .cornerRadius(12) - } - .padding(.horizontal) - } - .padding() - .navigationBarHidden(true) - } - } -} diff --git a/vendor/bytelyst/swift-platform-sdk/Sources/BLSyncEngine.swift b/vendor/bytelyst/swift-platform-sdk/Sources/BLSyncEngine.swift deleted file mode 100644 index 3bc6a8b..0000000 --- a/vendor/bytelyst/swift-platform-sdk/Sources/BLSyncEngine.swift +++ /dev/null @@ -1,240 +0,0 @@ -// ── Sync Engine ────────────────────────────────────────────── -// Generic offline-first sync engine for ByteLyst iOS apps. -// Provides: offline queue, periodic sync, delta pull, batch push. -// Product apps supply a SyncAdapter with their DTO types and endpoints. -// -// This extracts the generic parts of ChronoMind's PlatformSyncManager -// so every product app gets offline sync without reimplementing the -// queue, timer, error handling, and conflict resolution plumbing. - -import Foundation - -// MARK: - Sync Adapter Protocol - -/// Product apps implement this protocol to define their sync endpoints and DTO types. -public protocol BLSyncAdapter { - associatedtype SyncItem: Codable - - /// Pull remote changes since the given date. Return empty array if no changes. - func pullDelta(since: Date?, client: BLPlatformClient) async throws -> [SyncItem] - - /// Push a batch of offline queue items. Return IDs of successfully synced items. - func pushBatch(_ items: [BLOfflineQueueItem], client: BLPlatformClient) async throws -> BLBatchResult -} - -// MARK: - Offline Queue Item - -/// A queued change waiting to be synced. -public struct BLOfflineQueueItem: Codable { - public let id: String - public let action: BLSyncAction - public let payload: Data? // JSON-encoded product-specific DTO - public let enqueuedAt: Date - - public init(id: String, action: BLSyncAction, payload: Data?, enqueuedAt: Date = Date()) { - self.id = id - self.action = action - self.payload = payload - self.enqueuedAt = enqueuedAt - } -} - -public enum BLSyncAction: String, Codable { - case create - case update - case delete -} - -// MARK: - Sync Results - -public struct BLSyncResult { - public let pulled: [T] - public let syncedIds: [String] - public let conflicts: [BLSyncConflict] - public let error: String? - - public init(pulled: [T], syncedIds: [String] = [], conflicts: [BLSyncConflict] = [], error: String? = nil) { - self.pulled = pulled - self.syncedIds = syncedIds - self.conflicts = conflicts - self.error = error - } -} - -public struct BLSyncConflict: Codable { - public let id: String - public let serverVersion: Int - - public init(id: String, serverVersion: Int) { - self.id = id - self.serverVersion = serverVersion - } -} - -public struct BLBatchResult: Codable { - public let synced: [String] - public let conflicts: [BLSyncConflict] - public let errors: [String] - - public init(synced: [String] = [], conflicts: [BLSyncConflict] = [], errors: [String] = []) { - self.synced = synced - self.conflicts = conflicts - self.errors = errors - } -} - -// MARK: - Sync Engine - -/// Generic sync engine. Product apps create one instance with their SyncAdapter. -public final class BLSyncEngine { - - private let config: BLPlatformConfig - private let client: BLPlatformClient - private let adapter: Adapter - - private let storagePrefix: String - private var syncTask: Task? - private let syncIntervalSec: TimeInterval - - // MARK: - State - - public private(set) var isSyncing = false - public private(set) var lastSyncDate: Date? - public private(set) var pendingChanges: Int = 0 - public private(set) var lastError: String? - - public var syncEnabled: Bool { - didSet { - UserDefaults.standard.set(syncEnabled, forKey: "\(storagePrefix)-sync-enabled") - if syncEnabled { schedulePeriodicSync() } else { cancelPeriodicSync() } - } - } - - /// Called when sync state changes (for UI binding). - public var onStateChanged: (() -> Void)? - - public init( - config: BLPlatformConfig, - client: BLPlatformClient, - adapter: Adapter, - syncIntervalSec: TimeInterval = 60 - ) { - self.config = config - self.client = client - self.adapter = adapter - self.syncIntervalSec = syncIntervalSec - self.storagePrefix = config.productId - - syncEnabled = UserDefaults.standard.bool(forKey: "\(storagePrefix)-sync-enabled") - - if let date = UserDefaults.standard.object(forKey: "\(storagePrefix)-last-sync") as? Date { - lastSyncDate = date - } - - pendingChanges = loadQueueItems().count - - if syncEnabled { - schedulePeriodicSync() - } - } - - // MARK: - Sync - - /// Full delta sync: pull remote changes, push offline queue. - public func sync() async -> BLSyncResult { - guard syncEnabled, client.authToken != nil else { - return BLSyncResult(pulled: [], error: "Not authenticated or sync disabled") - } - - isSyncing = true - lastError = nil - onStateChanged?() - defer { - isSyncing = false - onStateChanged?() - } - - do { - let pulled = try await adapter.pullDelta(since: lastSyncDate, client: client) - let batchResult = try await pushOfflineQueue() - - lastSyncDate = Date() - UserDefaults.standard.set(lastSyncDate, forKey: "\(storagePrefix)-last-sync") - - return BLSyncResult( - pulled: pulled, - syncedIds: batchResult.synced, - conflicts: batchResult.conflicts, - error: nil - ) - } catch { - lastError = error.localizedDescription - return BLSyncResult(pulled: [], error: error.localizedDescription) - } - } - - // MARK: - Offline Queue - - /// Enqueue a change for later sync. - public func enqueueChange(id: String, action: BLSyncAction, payload: Data?) { - var queue = loadQueueItems() - queue.removeAll { $0.id == id } - queue.append(BLOfflineQueueItem(id: id, action: action, payload: payload)) - saveQueue(queue) - pendingChanges = queue.count - onStateChanged?() - } - - /// Enqueue a delete. - public func enqueueDelete(id: String) { - enqueueChange(id: id, action: .delete, payload: nil) - } - - // MARK: - Private - - private func pushOfflineQueue() async throws -> BLBatchResult { - let queue = loadQueueItems() - guard !queue.isEmpty else { - return BLBatchResult() - } - - let result = try await adapter.pushBatch(queue, client: client) - - // Clear synced items - var remaining = loadQueueItems() - remaining.removeAll { result.synced.contains($0.id) } - saveQueue(remaining) - pendingChanges = remaining.count - - return result - } - - private func schedulePeriodicSync() { - cancelPeriodicSync() - syncTask = Task { [weak self] in - while !Task.isCancelled { - try? await Task.sleep(nanoseconds: UInt64((self?.syncIntervalSec ?? 60) * 1_000_000_000)) - guard let self, self.syncEnabled else { break } - _ = await self.sync() - } - } - } - - private func cancelPeriodicSync() { - syncTask?.cancel() - syncTask = nil - } - - // MARK: - Queue Persistence - - private func loadQueueItems() -> [BLOfflineQueueItem] { - guard let data = UserDefaults.standard.data(forKey: "\(storagePrefix)-offline-queue") else { return [] } - return (try? JSONDecoder().decode([BLOfflineQueueItem].self, from: data)) ?? [] - } - - private func saveQueue(_ queue: [BLOfflineQueueItem]) { - if let data = try? JSONEncoder().encode(queue) { - UserDefaults.standard.set(data, forKey: "\(storagePrefix)-offline-queue") - } - } -} diff --git a/vendor/bytelyst/swift-platform-sdk/Sources/BLTelemetryClient.swift b/vendor/bytelyst/swift-platform-sdk/Sources/BLTelemetryClient.swift deleted file mode 100644 index 1d5350c..0000000 --- a/vendor/bytelyst/swift-platform-sdk/Sources/BLTelemetryClient.swift +++ /dev/null @@ -1,214 +0,0 @@ -// ── Telemetry Client ───────────────────────────────────────── -// Generic telemetry event queue + batch flush to platform-service. -// Matches the @bytelyst/telemetry-client TypeScript package interface. -// Product apps configure with BLPlatformConfig; no hardcoded product IDs. - -import Foundation - -/// Telemetry event matching the platform-service /api/telemetry/events schema. -public struct BLTelemetryEvent: Codable, Sendable { - public let id: String - public let productId: String - public let anonymousInstallId: String - public let sessionId: String - public let platform: String - public let channel: String - public let osFamily: String - public let osVersion: String - public let appVersion: String - public let buildNumber: String - public let releaseChannel: String - public let eventType: String - public let module: String - public let eventName: String - public var feature: String? - public var message: String? - public var tags: [String: String]? - public var metrics: [String: Double]? - public let occurredAt: String -} - -/// Generic telemetry client. Queues events in memory and flushes periodically. -/// Thread-safe via NSLock. Fire-and-forget — errors never surface to the user. -public final class BLTelemetryClient { - - private let config: BLPlatformConfig - private let client: BLPlatformClient - - private var queue: [[String: Any]] = [] - private let queueLock = NSLock() - private var flushTimer: Timer? - - private let maxQueue: Int - private let batchSize: Int - private let flushIntervalSec: TimeInterval - - private let installId: String - private var sessionId: String - - private let appVersion: String - private let buildNumber: String - private let releaseChannel: String - private let osVersion: String - - /// Optional extra fields added to every event (e.g. deviceModel, locale, timezone). - public var extraFields: [String: String] = [:] - - public init( - config: BLPlatformConfig, - client: BLPlatformClient, - maxQueue: Int = 200, - batchSize: Int = 50, - flushIntervalSec: TimeInterval = 30, - releaseChannel: String = "beta" - ) { - self.config = config - self.client = client - self.maxQueue = maxQueue - self.batchSize = batchSize - self.flushIntervalSec = flushIntervalSec - self.releaseChannel = releaseChannel - - let bundle = Bundle.main - appVersion = bundle.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0" - buildNumber = bundle.infoDictionary?["CFBundleVersion"] as? String ?? "0" - osVersion = ProcessInfo.processInfo.operatingSystemVersionString - - // Install ID — persisted in UserDefaults (or App Group if configured) - let storageKey = "\(config.productId)-telemetry-install-id" - let defaults: UserDefaults - if let groupId = config.appGroupId, let groupDefaults = UserDefaults(suiteName: groupId) { - defaults = groupDefaults - } else { - defaults = .standard - } - if let existing = defaults.string(forKey: storageKey), !existing.isEmpty { - installId = existing - } else { - let newId = UUID().uuidString - defaults.set(newId, forKey: storageKey) - installId = newId - } - - sessionId = UUID().uuidString - } - - // MARK: - Lifecycle - - /// Start the periodic flush timer. Call on app launch / foreground. - public func start() { - sessionId = UUID().uuidString - guard flushTimer == nil else { return } - flushTimer = Timer.scheduledTimer(withTimeInterval: flushIntervalSec, repeats: true) { [weak self] _ in - self?.flush() - } - } - - /// Stop the flush timer and flush remaining events. Call on app background. - public func stop() { - flush() - flushTimer?.invalidate() - flushTimer = nil - } - - // MARK: - Track - - /// Track a telemetry event. Thread-safe. - public func trackEvent( - _ eventType: String, - module: String, - name: String, - feature: String? = nil, - message: String? = nil, - tags: [String: String]? = nil, - metrics: [String: Double]? = nil, - userId: String? = nil - ) { - var event: [String: Any] = [ - "id": UUID().uuidString, - "productId": config.productId, - "anonymousInstallId": installId, - "sessionId": sessionId, - "platform": config.platform, - "channel": config.channel, - "osFamily": "ios", - "osVersion": osVersion, - "appVersion": appVersion, - "buildNumber": buildNumber, - "releaseChannel": releaseChannel, - "eventType": eventType, - "module": module, - "eventName": name, - "occurredAt": ISO8601DateFormatter().string(from: Date()), - ] - - if let feature { event["feature"] = feature } - if let message { event["message"] = String(message.prefix(512)) } - if let tags { event["tags"] = tags } - if let metrics { event["metrics"] = metrics } - if let userId { event["userId"] = userId } - - for (key, value) in extraFields { - event[key] = value - } - - enqueue(event) - } - - /// Convenience: track a screen view. - public func trackScreen(_ screen: String) { - trackEvent("info", module: "navigation", name: "screen_view", tags: ["screen": screen]) - } - - // MARK: - Flush - - /// Flush all queued events to the server. Thread-safe. - public func flush() { - queueLock.lock() - let events = queue - queue.removeAll() - queueLock.unlock() - - guard !events.isEmpty else { return } - - // Batch into chunks - let chunks = stride(from: 0, to: events.count, by: batchSize).map { - Array(events[$0.. String { installId } - public func getSessionId() -> String { sessionId } - - // MARK: - Private - - private func enqueue(_ event: [String: Any]) { - queueLock.lock() - queue.append(event) - if queue.count > maxQueue { - queue.removeFirst(queue.count - maxQueue) - } - let count = queue.count - queueLock.unlock() - - if count >= batchSize { - flush() - } - } - - private func sendBatch(_ events: [[String: Any]]) { - let body: [String: Any] = [ - "productId": config.productId, - "events": events, - ] - - guard let jsonData = try? JSONSerialization.data(withJSONObject: body) else { return } - client.fireAndForget(path: "/api/telemetry/events", body: jsonData) - } -} diff --git a/vendor/bytelyst/swift-platform-sdk/Sources/ByteLystPlatform.swift b/vendor/bytelyst/swift-platform-sdk/Sources/ByteLystPlatform.swift deleted file mode 100644 index 1e92073..0000000 --- a/vendor/bytelyst/swift-platform-sdk/Sources/ByteLystPlatform.swift +++ /dev/null @@ -1,122 +0,0 @@ -// ── ByteLystPlatform ──────────────────────────────────────── -// Unified entry point for the ByteLyst platform SDK. -// Creates and wires all platform services from a single config. -// -// Usage: -// let platform = ByteLystPlatform(config: .init( -// productId: "peakpulse", -// baseURL: "https://api.peakpulse.app", -// bundleId: "com.saravana.peakpulse" -// )) -// -// platform.start() // Start telemetry + flags + kill switch -// platform.telemetry.trackScreen("home") // Track events -// let isNew = platform.flags.isEnabled("new_feature") -// platform.stop() // Flush + stop timers - -import Foundation - -/// Unified entry point that wires all ByteLyst platform services together. -/// Create one instance at app launch and access services via properties. -public final class ByteLystPlatform { - - /// Platform configuration. - public let config: BLPlatformConfig - - /// HTTP client shared by all services. - public let client: BLPlatformClient - - /// Telemetry event tracking. - public let telemetry: BLTelemetryClient - - /// Feature flag polling. - public let flags: BLFeatureFlagClient - - /// Kill switch checker. - public let killSwitch: BLKillSwitchClient - - /// Crash reporter (MetricKit). Created lazily on MainActor. - public private(set) var crashReporter: BLCrashReporter? - - /// Keychain access (via bundleId as service). - public let keychain: BLKeychainAccessor - - /// Audit logger type (static API — call BLAuditLogger.log()). - public let auditLog: BLAuditLogger.Type = BLAuditLogger.self - - /// Auth client. - public let auth: BLAuthClient - - /// Whether `start()` has been called. - public private(set) var isStarted = false - - public init(config: BLPlatformConfig) { - self.config = config - self.client = BLPlatformClient(config: config) - self.telemetry = BLTelemetryClient(config: config, client: client) - self.flags = BLFeatureFlagClient(config: config, client: client) - self.killSwitch = BLKillSwitchClient(config: config) - self.keychain = BLKeychainAccessor(service: config.bundleId) - self.auth = BLAuthClient(config: config, client: client) - BLAuditLogger.configure(fileName: "\(config.productId)_audit_log.json") - } - - /// Test-only initializer that accepts a custom URLSessionConfiguration. - public init(config: BLPlatformConfig, sessionConfiguration: URLSessionConfiguration) { - self.config = config - self.client = BLPlatformClient(config: config, sessionConfiguration: sessionConfiguration) - self.telemetry = BLTelemetryClient(config: config, client: client) - self.flags = BLFeatureFlagClient(config: config, client: client) - self.killSwitch = BLKillSwitchClient(config: config) - self.keychain = BLKeychainAccessor(service: config.bundleId) - self.auth = BLAuthClient(config: config, client: client) - BLAuditLogger.configure(fileName: "\(config.productId)_audit_log.json") - } - - // MARK: - Lifecycle - - /// Start all services: telemetry flush timer, feature flag polling, kill switch check. - public func start(userId: String? = nil) { - guard !isStarted else { return } - isStarted = true - telemetry.start() - flags.start(userId: userId) - Task { await killSwitch.check() } - Task { @MainActor in - self.crashReporter = BLCrashReporter(productId: config.productId) - } - } - - /// Stop all services: flush telemetry, stop flag polling. - public func stop() { - guard isStarted else { return } - isStarted = false - telemetry.stop() - flags.stop() - } -} - -// MARK: - Keychain Accessor - -/// Convenience wrapper around BLKeychain that binds to a specific service (bundleId). -public struct BLKeychainAccessor { - private let service: String - - public init(service: String) { - self.service = service - } - - @discardableResult - public func save(key: String, value: String) -> Bool { - BLKeychain.save(service: service, key: key, value: value) - } - - public func read(key: String) -> String? { - BLKeychain.read(service: service, key: key) - } - - @discardableResult - public func delete(key: String) -> Bool { - BLKeychain.delete(service: service, key: key) - } -} diff --git a/vendor/bytelyst/swift-platform-sdk/Sources/ByteLystPlatformSDK.swift b/vendor/bytelyst/swift-platform-sdk/Sources/ByteLystPlatformSDK.swift deleted file mode 100644 index 8da8a2a..0000000 --- a/vendor/bytelyst/swift-platform-sdk/Sources/ByteLystPlatformSDK.swift +++ /dev/null @@ -1,34 +0,0 @@ -// ── ByteLystPlatformSDK ────────────────────────────────────── -// Shared Swift platform client for all ByteLyst iOS/watchOS/macOS apps. -// Re-exports all public types via their respective source files. -// -// Usage in product apps: -// import ByteLystPlatformSDK -// -// let config = BLPlatformConfig(productId: "peakpulse", baseURL: "...", bundleId: "com.saravana.peakpulse") -// let client = BLPlatformClient(config: config) -// let telemetry = BLTelemetryClient(config: config, client: client) -// let auth = BLAuthClient(config: config, client: client) -// let flags = BLFeatureFlagClient(config: config, client: client) -// let blob = BLBlobClient(config: config, client: client) -// let license = BLLicenseClient(config: config, client: client) -// let killSwitch = BLKillSwitchClient(config: config) -// let crashReporter = BLCrashReporter(productId: config.productId) -// -// Components (13 source files): -// - BLPlatformConfig — Product-specific config (productId, baseURL, bundleId, appGroupId) -// - BLPlatformClient — Generic HTTP client (auth injection, x-request-id, fire-and-forget) -// - BLKeychain — Keychain CRUD (configurable service string) -// - BLTelemetryClient — Telemetry event queue + batch flush -// - BLAuthClient — Full auth operations (login, register, refresh, password ops) -// - BLFeatureFlagClient — Feature flag polling from /api/flags/poll -// - BLSyncEngine — Generic offline-first sync with BLSyncAdapter protocol -// - BLBlobClient — Azure Blob Storage upload via SAS tokens -// - BLKillSwitchClient — Kill switch check from platform-service -// - BLLicenseClient — License key activation via platform-service -// - BLBiometricAuth — Face ID / Touch ID wrapper -// - BLCrashReporter — MetricKit crash and performance reporting -// - BLAuditLogger — Local rotating JSON audit log - -// All types are exported via their respective files. -// This file exists for module-level documentation only. diff --git a/vendor/bytelyst/swift-platform-sdk/Tests/BLAuthClientSmartAuthTests.swift b/vendor/bytelyst/swift-platform-sdk/Tests/BLAuthClientSmartAuthTests.swift deleted file mode 100644 index c9ef73c..0000000 --- a/vendor/bytelyst/swift-platform-sdk/Tests/BLAuthClientSmartAuthTests.swift +++ /dev/null @@ -1,196 +0,0 @@ -// ── BLAuthClient SmartAuth v2 Tests ───────────────────────── -// Tests for social login, MFA verify methods. -// Uses URLProtocol mocking to intercept network requests. - -import XCTest -@testable import ByteLystPlatformSDK - -// MARK: - Mock URL Protocol - -private class MockURLProtocol: URLProtocol { - static var mockResponses: [String: (Data, Int)] = [:] - - override class func canInit(with request: URLRequest) -> Bool { true } - override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } - - override func startLoading() { - let path = request.url?.path ?? "" - let method = request.httpMethod ?? "GET" - let key = "\(method) \(path)" - - if let (data, statusCode) = MockURLProtocol.mockResponses[key] { - let response = HTTPURLResponse( - url: request.url!, - statusCode: statusCode, - httpVersion: nil, - headerFields: ["Content-Type": "application/json"] - )! - client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) - client?.urlProtocol(self, didLoad: data) - } else { - let response = HTTPURLResponse( - url: request.url!, - statusCode: 404, - httpVersion: nil, - headerFields: nil - )! - client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) - client?.urlProtocol(self, didLoad: Data()) - } - client?.urlProtocolDidFinishLoading(self) - } - - override func stopLoading() {} -} - -// MARK: - Tests - -final class BLAuthClientSmartAuthTests: XCTestCase { - - private var config: BLPlatformConfig! - private var client: BLPlatformClient! - private var authClient: BLAuthClient! - - override func setUp() { - super.setUp() - config = BLPlatformConfig( - productId: "testapp", - baseURL: "http://localhost:4003", - platform: "ios", - channel: "test", - bundleId: "com.test.smartauth" - ) - - // Configure URLSession with mock protocol wired into BLPlatformClient - let sessionConfig = URLSessionConfiguration.ephemeral - sessionConfig.protocolClasses = [MockURLProtocol.self] - - client = BLPlatformClient(config: config, sessionConfiguration: sessionConfig) - authClient = BLAuthClient(config: config, client: client) - - MockURLProtocol.mockResponses.removeAll() - } - - override func tearDown() { - MockURLProtocol.mockResponses.removeAll() - // Clean up keychain - BLKeychain.delete(service: "com.test.smartauth", key: "access_token") - BLKeychain.delete(service: "com.test.smartauth", key: "refresh_token") - super.tearDown() - } - - // MARK: - Test 1: Swift Social Login (Google) - - func testLoginWithGoogleCallsCorrectEndpoint() async throws { - // Mock a successful token response for Google OAuth - let tokenJSON = """ - {"accessToken":"at_123","refreshToken":"rt_456","user":{"id":"u1","email":"test@google.com","displayName":"Test User","plan":"free","role":"user"}} - """ - MockURLProtocol.mockResponses["POST /api/auth/oauth/google"] = (tokenJSON.data(using: .utf8)!, 200) - - let user = try await authClient.loginWithGoogle(idToken: "mock_google_id_token") - XCTAssertEqual(user.id, "u1") - XCTAssertEqual(user.email, "test@google.com") - XCTAssertEqual(user.displayName, "Test User") - } - - func testLoginWithGoogleDetectsMfaChallenge() async { - // Mock an MFA challenge response for Google OAuth - let mfaJSON = """ - {"mfaRequired":true,"mfaChallenge":"ch_google_123","methods":["totp"]} - """ - MockURLProtocol.mockResponses["POST /api/auth/oauth/google"] = (mfaJSON.data(using: .utf8)!, 200) - - do { - _ = try await authClient.loginWithGoogle(idToken: "mock_google_id_token") - XCTFail("Should have thrown BLAuthError.mfaRequired") - } catch let error as BLAuthError { - if case .mfaRequired(let challenge) = error { - XCTAssertEqual(challenge.mfaChallenge, "ch_google_123") - XCTAssertEqual(challenge.methods, ["totp"]) - } else { - XCTFail("Expected mfaRequired error") - } - } - } - - // MARK: - Test 2: Swift MFA Verify - - func testVerifyMfaCallsCorrectEndpoint() async throws { - // Mock a successful MFA verification response - let tokenJSON = """ - {"accessToken":"at_mfa","refreshToken":"rt_mfa","user":{"id":"u2","email":"mfa@test.com","displayName":"MFA User"}} - """ - MockURLProtocol.mockResponses["POST /api/auth/mfa/verify"] = (tokenJSON.data(using: .utf8)!, 200) - - let user = try await authClient.verifyMfa( - challengeToken: "mock_challenge_token", - code: "123456", - method: "totp" - ) - XCTAssertEqual(user.id, "u2") - XCTAssertEqual(user.email, "mfa@test.com") - } - - // MARK: - Type Verification Tests - - func testSmartAuthTypesAreDecodable() throws { - // BLMfaChallenge - let challengeJSON = """ - {"mfaRequired":true,"mfaChallenge":"ch_abc123","methods":["totp"]} - """ - let challenge = try JSONDecoder().decode(BLMfaChallenge.self, from: challengeJSON.data(using: .utf8)!) - XCTAssertTrue(challenge.mfaRequired) - XCTAssertEqual(challenge.mfaChallenge, "ch_abc123") - XCTAssertEqual(challenge.methods, ["totp"]) - - // BLMfaStatus - let statusJSON = """ - {"mfaEnabled":true,"methods":["totp"],"recoveryCodesRemaining":6} - """ - let status = try JSONDecoder().decode(BLMfaStatus.self, from: statusJSON.data(using: .utf8)!) - XCTAssertTrue(status.mfaEnabled) - XCTAssertEqual(status.recoveryCodesRemaining, 6) - - // BLAuthProvider - let providerJSON = """ - {"provider":"google","email":"test@test.com","linkedAt":"2026-01-01T00:00:00Z","lastUsedAt":null} - """ - let provider = try JSONDecoder().decode(BLAuthProvider.self, from: providerJSON.data(using: .utf8)!) - XCTAssertEqual(provider.provider, "google") - XCTAssertNil(provider.lastUsedAt) - - // BLDevice - let deviceJSON = """ - {"id":"dev_1","name":"iPhone 16","platform":"ios","trustLevel":"trusted","trustExpiresAt":"2026-06-01T00:00:00Z","lastLoginAt":"2026-03-01T00:00:00Z"} - """ - let device = try JSONDecoder().decode(BLDevice.self, from: deviceJSON.data(using: .utf8)!) - XCTAssertEqual(device.trustLevel, "trusted") - XCTAssertEqual(device.platform, "ios") - - // BLLoginEvent - let eventJSON = """ - {"id":"evt_1","eventType":"login_success","method":"google","ip":"1.2.3.4","geo":{"country":"US","city":"SF"},"riskScore":15,"createdAt":"2026-03-01T00:00:00Z"} - """ - let event = try JSONDecoder().decode(BLLoginEvent.self, from: eventJSON.data(using: .utf8)!) - XCTAssertEqual(event.riskScore, 15) - XCTAssertEqual(event.geo?.city, "SF") - } - - func testAuthStateIncludesMfaRequired() { - let challenge = BLMfaChallenge(mfaRequired: true, mfaChallenge: "ch_test", methods: ["totp"]) - let state = BLAuthState.mfaRequired(challenge) - - if case .mfaRequired(let c) = state { - XCTAssertEqual(c.mfaChallenge, "ch_test") - } else { - XCTFail("Expected mfaRequired state") - } - } - - func testBLAuthErrorMfaRequired() { - let challenge = BLMfaChallenge(mfaRequired: true, mfaChallenge: "ch_test", methods: ["totp", "recovery"]) - let error = BLAuthError.mfaRequired(challenge) - XCTAssertEqual(error.localizedDescription, "Multi-factor authentication required") - } -} diff --git a/vendor/bytelyst/swift-platform-sdk/Tests/BLFeatureFlagClientTests.swift b/vendor/bytelyst/swift-platform-sdk/Tests/BLFeatureFlagClientTests.swift deleted file mode 100644 index 4f63401..0000000 --- a/vendor/bytelyst/swift-platform-sdk/Tests/BLFeatureFlagClientTests.swift +++ /dev/null @@ -1,31 +0,0 @@ -import XCTest -@testable import ByteLystPlatformSDK - -final class BLFeatureFlagClientTests: XCTestCase { - - private func makeClient() -> BLFeatureFlagClient { - let config = BLPlatformConfig( - productId: "testapp", - baseURL: "http://localhost:4003", - bundleId: "com.bytelyst.test" - ) - let platformClient = BLPlatformClient(config: config) - return BLFeatureFlagClient(config: config, client: platformClient) - } - - func testDefaultFlagsEmpty() { - let client = makeClient() - XCTAssertEqual(client.allFlags().count, 0) - } - - func testIsEnabledReturnsFalseForUnknownKey() { - let client = makeClient() - XCTAssertFalse(client.isEnabled("nonexistent_flag")) - } - - func testStopDoesNotCrash() { - let client = makeClient() - client.stop() - client.stop() // Double stop - } -} diff --git a/vendor/bytelyst/swift-platform-sdk/Tests/BLFieldEncryptTests.swift b/vendor/bytelyst/swift-platform-sdk/Tests/BLFieldEncryptTests.swift deleted file mode 100644 index 5180c17..0000000 --- a/vendor/bytelyst/swift-platform-sdk/Tests/BLFieldEncryptTests.swift +++ /dev/null @@ -1,186 +0,0 @@ -import XCTest -import CryptoKit -@testable import ByteLystPlatformSDK - -final class BLFieldEncryptTests: XCTestCase { - - private var key: SymmetricKey! - private let dekId = "dek_user1_test" - - override func setUp() { - super.setUp() - key = BLFieldEncrypt.generateKey() - } - - // MARK: - Encrypt / Decrypt Roundtrip - - func testEncryptDecryptRoundtrip() throws { - let plaintext = "Hello, World!" - let encrypted = try BLFieldEncrypt.encrypt(plaintext, key: key, dekId: dekId) - let decrypted = try BLFieldEncrypt.decrypt(encrypted, key: key) - XCTAssertEqual(decrypted, plaintext) - } - - func testEncryptDecryptEmptyString() throws { - let plaintext = "" - let encrypted = try BLFieldEncrypt.encrypt(plaintext, key: key, dekId: dekId) - let decrypted = try BLFieldEncrypt.decrypt(encrypted, key: key) - XCTAssertEqual(decrypted, plaintext) - } - - func testEncryptDecryptUnicode() throws { - let plaintext = "こんにちは世界 🌍 مرحبا Ñoño" - let encrypted = try BLFieldEncrypt.encrypt(plaintext, key: key, dekId: dekId) - let decrypted = try BLFieldEncrypt.decrypt(encrypted, key: key) - XCTAssertEqual(decrypted, plaintext) - } - - func testEncryptDecryptLargePayload() throws { - let plaintext = String(repeating: "A", count: 100_000) - let encrypted = try BLFieldEncrypt.encrypt(plaintext, key: key, dekId: dekId) - let decrypted = try BLFieldEncrypt.decrypt(encrypted, key: key) - XCTAssertEqual(decrypted, plaintext) - } - - // MARK: - EncryptedField Structure - - func testEncryptedFieldHasCorrectSentinel() throws { - let encrypted = try BLFieldEncrypt.encrypt("test", key: key, dekId: dekId) - XCTAssertTrue(encrypted.__encrypted) - XCTAssertEqual(encrypted.v, 1) - XCTAssertEqual(encrypted.alg, "aes-256-gcm") - XCTAssertEqual(encrypted.dekId, dekId) - } - - func testEncryptedFieldHasCorrectHexLengths() throws { - let encrypted = try BLFieldEncrypt.encrypt("test", key: key, dekId: dekId) - // IV: 12 bytes = 24 hex chars - XCTAssertEqual(encrypted.iv.count, 24) - // Tag: 16 bytes = 32 hex chars - XCTAssertEqual(encrypted.tag.count, 32) - // ct should be non-empty hex - XCTAssertFalse(encrypted.ct.isEmpty) - } - - func testEachEncryptionProducesUniqueIV() throws { - let a = try BLFieldEncrypt.encrypt("same", key: key, dekId: dekId) - let b = try BLFieldEncrypt.encrypt("same", key: key, dekId: dekId) - XCTAssertNotEqual(a.iv, b.iv, "Each encryption should use a unique IV") - XCTAssertNotEqual(a.ct, b.ct, "Ciphertext should differ with different IVs") - } - - // MARK: - AAD (Additional Authenticated Data) - - func testEncryptDecryptWithAAD() throws { - let plaintext = "secret data" - let aad = "user123:notes" - let encrypted = try BLFieldEncrypt.encrypt(plaintext, key: key, dekId: dekId, aad: aad) - let decrypted = try BLFieldEncrypt.decrypt(encrypted, key: key, aad: aad) - XCTAssertEqual(decrypted, plaintext) - } - - func testDecryptWithWrongAADFails() throws { - let encrypted = try BLFieldEncrypt.encrypt("secret", key: key, dekId: dekId, aad: "correct") - XCTAssertThrowsError(try BLFieldEncrypt.decrypt(encrypted, key: key, aad: "wrong")) - } - - func testDecryptWithMissingAADFails() throws { - let encrypted = try BLFieldEncrypt.encrypt("secret", key: key, dekId: dekId, aad: "some-aad") - XCTAssertThrowsError(try BLFieldEncrypt.decrypt(encrypted, key: key)) - } - - // MARK: - Wrong Key - - func testDecryptWithWrongKeyFails() throws { - let encrypted = try BLFieldEncrypt.encrypt("secret", key: key, dekId: dekId) - let wrongKey = BLFieldEncrypt.generateKey() - XCTAssertThrowsError(try BLFieldEncrypt.decrypt(encrypted, key: wrongKey)) - } - - // MARK: - Key Size Validation - - func testEncryptRejectsShortKey() { - let shortKey = SymmetricKey(size: .bits128) - XCTAssertThrowsError(try BLFieldEncrypt.encrypt("test", key: shortKey, dekId: dekId)) { error in - guard let encError = error as? BLFieldEncryptError, - case .invalidKeySize = encError else { - XCTFail("Expected invalidKeySize error") - return - } - } - } - - // MARK: - Key from Hex - - func testKeyFromHex() throws { - let hex = String(repeating: "ab", count: 32) // 64 hex chars = 32 bytes - let key = try BLFieldEncrypt.keyFromHex(hex) - let encrypted = try BLFieldEncrypt.encrypt("test", key: key, dekId: dekId) - let decrypted = try BLFieldEncrypt.decrypt(encrypted, key: key) - XCTAssertEqual(decrypted, "test") - } - - func testKeyFromHexRejectsInvalidLength() { - XCTAssertThrowsError(try BLFieldEncrypt.keyFromHex("aabb")) - } - - // MARK: - isEncrypted Type Guard - - func testIsEncryptedWithValidField() throws { - let encrypted = try BLFieldEncrypt.encrypt("test", key: key, dekId: dekId) - XCTAssertTrue(BLFieldEncrypt.isEncrypted(encrypted)) - } - - func testIsEncryptedWithNil() { - XCTAssertFalse(BLFieldEncrypt.isEncrypted(nil as BLEncryptedField?)) - } - - func testIsEncryptedWithDictionary() { - let dict: [String: Any] = [ - "__encrypted": true, - "v": 1, - "alg": "aes-256-gcm", - "ct": "abcd", - "iv": "1234", - "tag": "5678", - "dekId": "dek_test", - ] - XCTAssertTrue(BLFieldEncrypt.isEncrypted(dict)) - } - - func testIsEncryptedWithPlainString() { - XCTAssertFalse(BLFieldEncrypt.isEncrypted("just a string" as Any)) - } - - func testIsEncryptedWithIncompleteDict() { - let dict: [String: Any] = ["__encrypted": true, "v": 1] - XCTAssertFalse(BLFieldEncrypt.isEncrypted(dict)) - } - - // MARK: - JSON Codable Roundtrip - - func testEncryptedFieldCodableRoundtrip() throws { - let encrypted = try BLFieldEncrypt.encrypt("codable test", key: key, dekId: dekId) - let encoder = JSONEncoder() - let data = try encoder.encode(encrypted) - let decoder = JSONDecoder() - let decoded = try decoder.decode(BLEncryptedField.self, from: data) - let decrypted = try BLFieldEncrypt.decrypt(decoded, key: key) - XCTAssertEqual(decrypted, "codable test") - } - - // MARK: - Hex Helpers - - func testDataHexRoundtrip() { - let original = Data([0x00, 0x0f, 0xff, 0xab, 0xcd]) - let hex = original.hexString - XCTAssertEqual(hex, "000fffabcd") - let restored = Data(hexString: hex) - XCTAssertEqual(restored, original) - } - - func testDataHexInvalidString() { - XCTAssertNil(Data(hexString: "xyz")) - XCTAssertNil(Data(hexString: "a")) // odd length - } -} diff --git a/vendor/bytelyst/swift-platform-sdk/Tests/BLKeychainTests.swift b/vendor/bytelyst/swift-platform-sdk/Tests/BLKeychainTests.swift deleted file mode 100644 index 046f09a..0000000 --- a/vendor/bytelyst/swift-platform-sdk/Tests/BLKeychainTests.swift +++ /dev/null @@ -1,33 +0,0 @@ -import XCTest -@testable import ByteLystPlatformSDK - -final class BLKeychainTests: XCTestCase { - - private let service = "com.bytelyst.test" - - override func tearDown() { - BLKeychain.delete(service: service, key: "test_key") - super.tearDown() - } - - func testSaveAndRead() { - BLKeychain.save(service: service, key: "test_key", value: "hello") - XCTAssertEqual(BLKeychain.read(service: service, key: "test_key"), "hello") - } - - func testReadNonExistent() { - XCTAssertNil(BLKeychain.read(service: service, key: "nonexistent")) - } - - func testDelete() { - BLKeychain.save(service: service, key: "test_key", value: "hello") - BLKeychain.delete(service: service, key: "test_key") - XCTAssertNil(BLKeychain.read(service: service, key: "test_key")) - } - - func testOverwrite() { - BLKeychain.save(service: service, key: "test_key", value: "first") - BLKeychain.save(service: service, key: "test_key", value: "second") - XCTAssertEqual(BLKeychain.read(service: service, key: "test_key"), "second") - } -} diff --git a/vendor/bytelyst/swift-platform-sdk/Tests/BLKillSwitchClientTests.swift b/vendor/bytelyst/swift-platform-sdk/Tests/BLKillSwitchClientTests.swift deleted file mode 100644 index e018b0c..0000000 --- a/vendor/bytelyst/swift-platform-sdk/Tests/BLKillSwitchClientTests.swift +++ /dev/null @@ -1,27 +0,0 @@ -import XCTest -@testable import ByteLystPlatformSDK - -final class BLKillSwitchClientTests: XCTestCase { - - private func makeConfig() -> BLPlatformConfig { - BLPlatformConfig( - productId: "testapp", - baseURL: "http://localhost:4003", - bundleId: "com.bytelyst.test" - ) - } - - func testDefaultState() { - let client = BLKillSwitchClient(config: makeConfig()) - XCTAssertFalse(client.isDisabled) - XCTAssertEqual(client.maintenanceMessage, "") - } - - func testReset() { - let client = BLKillSwitchClient(config: makeConfig()) - // Manually test reset clears any hypothetical state - client.reset() - XCTAssertFalse(client.isDisabled) - XCTAssertEqual(client.maintenanceMessage, "") - } -} diff --git a/vendor/bytelyst/swift-platform-sdk/Tests/BLPlatformConfigTests.swift b/vendor/bytelyst/swift-platform-sdk/Tests/BLPlatformConfigTests.swift deleted file mode 100644 index f199d7c..0000000 --- a/vendor/bytelyst/swift-platform-sdk/Tests/BLPlatformConfigTests.swift +++ /dev/null @@ -1,34 +0,0 @@ -import XCTest -@testable import ByteLystPlatformSDK - -final class BLPlatformConfigTests: XCTestCase { - - func testInitWithDefaults() { - let config = BLPlatformConfig( - productId: "testapp", - baseURL: "http://localhost:4003", - bundleId: "com.bytelyst.testapp" - ) - XCTAssertEqual(config.productId, "testapp") - XCTAssertEqual(config.baseURL, "http://localhost:4003") - XCTAssertEqual(config.platform, "ios") - XCTAssertEqual(config.channel, "native") - XCTAssertEqual(config.bundleId, "com.bytelyst.testapp") - XCTAssertNil(config.appGroupId) - } - - func testInitWithCustomValues() { - let config = BLPlatformConfig( - productId: "peakpulse", - baseURL: "https://api.peakpulse.app", - platform: "watchos", - channel: "companion", - bundleId: "com.saravana.peakpulse", - appGroupId: "group.com.saravana.peakpulse" - ) - XCTAssertEqual(config.productId, "peakpulse") - XCTAssertEqual(config.platform, "watchos") - XCTAssertEqual(config.channel, "companion") - XCTAssertEqual(config.appGroupId, "group.com.saravana.peakpulse") - } -} diff --git a/vendor/bytelyst/swift-platform-sdk/Tests/BLTelemetryClientTests.swift b/vendor/bytelyst/swift-platform-sdk/Tests/BLTelemetryClientTests.swift deleted file mode 100644 index dc2d534..0000000 --- a/vendor/bytelyst/swift-platform-sdk/Tests/BLTelemetryClientTests.swift +++ /dev/null @@ -1,65 +0,0 @@ -import XCTest -@testable import ByteLystPlatformSDK - -final class BLTelemetryClientTests: XCTestCase { - - private func makeClient() -> BLTelemetryClient { - let config = BLPlatformConfig( - productId: "testapp", - baseURL: "http://localhost:4003", - bundleId: "com.bytelyst.test" - ) - let platformClient = BLPlatformClient(config: config) - return BLTelemetryClient(config: config, client: platformClient) - } - - func testInstallIdIsStable() { - let client = makeClient() - let id1 = client.getInstallId() - let id2 = client.getInstallId() - XCTAssertEqual(id1, id2) - XCTAssertFalse(id1.isEmpty) - } - - func testSessionIdIsNotEmpty() { - let client = makeClient() - XCTAssertFalse(client.getSessionId().isEmpty) - } - - func testStartGeneratesNewSessionId() { - let client = makeClient() - let sessionBefore = client.getSessionId() - client.start() - let sessionAfter = client.getSessionId() - // start() rotates session ID - XCTAssertNotEqual(sessionBefore, sessionAfter) - client.stop() - } - - func testStopDoesNotCrash() { - let client = makeClient() - client.stop() // Stop without start - client.start() - client.stop() - client.stop() // Double stop - } - - func testTrackEventDoesNotCrash() { - let client = makeClient() - client.trackEvent("info", module: "test", name: "unit_test") - client.trackEvent("error", module: "test", name: "fail", message: "test error") - client.trackEvent("info", module: "test", name: "tagged", tags: ["key": "value"]) - client.trackEvent("info", module: "test", name: "measured", metrics: ["duration": 1.5]) - } - - func testTrackScreenDoesNotCrash() { - let client = makeClient() - client.trackScreen("home") - client.trackScreen("settings") - } - - func testFlushDoesNotCrashWhenEmpty() { - let client = makeClient() - client.flush() // Empty queue - } -} diff --git a/vendor/bytelyst/swift-platform-sdk/Tests/ByteLystPlatformTests.swift b/vendor/bytelyst/swift-platform-sdk/Tests/ByteLystPlatformTests.swift deleted file mode 100644 index 065c6fd..0000000 --- a/vendor/bytelyst/swift-platform-sdk/Tests/ByteLystPlatformTests.swift +++ /dev/null @@ -1,67 +0,0 @@ -import XCTest -@testable import ByteLystPlatformSDK - -final class ByteLystPlatformTests: XCTestCase { - - private func makeConfig() -> BLPlatformConfig { - BLPlatformConfig( - productId: "testapp", - baseURL: "http://localhost:4003/api", - platform: "ios", - channel: "native", - bundleId: "com.bytelyst.test" - ) - } - - func testInitCreatesAllServices() { - let platform = ByteLystPlatform(config: makeConfig()) - XCTAssertEqual(platform.config.productId, "testapp") - XCTAssertNotNil(platform.client) - XCTAssertNotNil(platform.telemetry) - XCTAssertNotNil(platform.flags) - XCTAssertNotNil(platform.killSwitch) - XCTAssertNotNil(platform.crashReporter) - XCTAssertNotNil(platform.keychain) - XCTAssertNotNil(platform.auditLog) - XCTAssertNotNil(platform.auth) - } - - func testStartSetsIsStarted() { - let platform = ByteLystPlatform(config: makeConfig()) - XCTAssertFalse(platform.isStarted) - platform.start() - XCTAssertTrue(platform.isStarted) - } - - func testStopClearsIsStarted() { - let platform = ByteLystPlatform(config: makeConfig()) - platform.start() - XCTAssertTrue(platform.isStarted) - platform.stop() - XCTAssertFalse(platform.isStarted) - } - - func testDoubleStartIsIdempotent() { - let platform = ByteLystPlatform(config: makeConfig()) - platform.start() - platform.start() // Should not crash or double-start - XCTAssertTrue(platform.isStarted) - platform.stop() - } - - func testDoubleStopIsIdempotent() { - let platform = ByteLystPlatform(config: makeConfig()) - platform.start() - platform.stop() - platform.stop() // Should not crash - XCTAssertFalse(platform.isStarted) - } - - func testKeychainAccessor() { - let accessor = BLKeychainAccessor(service: "com.bytelyst.test.accessor") - accessor.save(key: "test_key", value: "hello") - XCTAssertEqual(accessor.read(key: "test_key"), "hello") - accessor.delete(key: "test_key") - XCTAssertNil(accessor.read(key: "test_key")) - } -} diff --git a/vendor/bytelyst/sync/package.json b/vendor/bytelyst/sync/package.json deleted file mode 100644 index a205f63..0000000 --- a/vendor/bytelyst/sync/package.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "name": "@bytelyst/sync", - "version": "0.1.5", - "description": "Offline-first sync engine with configurable storage adapters and conflict resolution", - "type": "module", - "main": "./dist/index.js", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks", - "test:watch": "vitest", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "@bytelyst/api-client": "workspace:*", - "@bytelyst/telemetry-client": "workspace:*" - }, - "devDependencies": { - "@types/node": "^22.12.0", - "typescript": "^5.7.3", - "vitest": "^3.0.5" - }, - "peerDependencies": { - "@bytelyst/api-client": "workspace:*" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/sync/src/engine.ts b/vendor/bytelyst/sync/src/engine.ts deleted file mode 100644 index 277e2e0..0000000 --- a/vendor/bytelyst/sync/src/engine.ts +++ /dev/null @@ -1,603 +0,0 @@ -/** - * Sync Engine — Core implementation - * - * Offline-first sync with: - * - Queue persistence via pluggable StorageAdapter - * - Deduplication (collapse updates to same entity + id) - * - Exponential backoff retry (configurable base/max delay) - * - Conflict detection via HTTP 409 + configurable resolution strategies - * - Connectivity detection with auto-flush on reconnect - * - Telemetry integration for sync success/failure/conflict tracking - * - onPull callback so consumers merge pulled data into local store - * - * @module @bytelyst/sync/engine - */ - -import type { - SyncEngine, - SyncEngineConfig, - SyncItem, - SyncResult, - SyncStatus, - SyncStatusInfo, - SyncStatusCallback, - EntityName, - SyncOperation, - ConflictStrategy, - Conflict, -} from './types.js'; - -// ───────────────────────────────────────────────────────────────────────────── -// Constants -// ───────────────────────────────────────────────────────────────────────────── - -const DEFAULT_MAX_RETRIES = 5; -const DEFAULT_RETRY_BASE_DELAY_MS = 1000; -const DEFAULT_RETRY_MAX_DELAY_MS = 30_000; -const QUEUE_KEY = 'queue'; -const LAST_SYNC_KEY = 'lastSync'; - -// ───────────────────────────────────────────────────────────────────────────── -// HTTP 409 Conflict Error -// ───────────────────────────────────────────────────────────────────────────── - -export class SyncConflictError extends Error { - constructor(public remoteData: unknown) { - super('Sync conflict: server has newer version'); - this.name = 'SyncConflictError'; - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// Helpers -// ───────────────────────────────────────────────────────────────────────────── - -/** Compute exponential backoff with jitter: base * 2^attempt + random jitter */ -export function computeBackoff(attempt: number, baseMs: number, maxMs: number): number { - const delay = Math.min(baseMs * Math.pow(2, attempt), maxMs); - const jitter = delay * 0.1 * Math.random(); - return delay + jitter; -} - -function sleep(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)); -} - -// ───────────────────────────────────────────────────────────────────────────── -// Sync Engine Implementation -// ───────────────────────────────────────────────────────────────────────────── - -export class SyncEngineImpl implements SyncEngine { - private config: Required< - Pick - > & - SyncEngineConfig; - private status: SyncStatus = 'idle'; - private queueLength = 0; - private lastSyncAt?: string; - private lastError?: string; - private statusListeners: Set = new Set(); - private onlineHandler: (() => void) | null = null; - private offlineHandler: (() => void) | null = null; - private destroyed = false; - - constructor(config: SyncEngineConfig) { - this.config = { - maxRetries: DEFAULT_MAX_RETRIES, - retryBaseDelayMs: DEFAULT_RETRY_BASE_DELAY_MS, - retryMaxDelayMs: DEFAULT_RETRY_MAX_DELAY_MS, - ...config, - }; - this.setupConnectivityDetection(); - } - - // ─────────────────────────────────────────────────────────────────────────── - // Core Operations - // ─────────────────────────────────────────────────────────────────────────── - - async push( - entity: EntityName, - data: unknown, - operation: SyncOperation = 'create' - ): Promise { - const item: SyncItem = { - id: this.generateId(), - entity, - operation, - data, - timestamp: new Date().toISOString(), - retryCount: 0, - }; - - const existingQueue = await this.getQueue(); - const dedupKey = this.getDedupKey(entity, data); - const existingIndex = existingQueue.findIndex( - i => this.getDedupKey(i.entity, i.data) === dedupKey && i.operation === operation - ); - - if (existingIndex >= 0) { - existingQueue[existingIndex] = item; - } else { - existingQueue.push(item); - } - - await this.saveQueue(existingQueue); - this.notifyStatus(); - } - - async delete(entity: EntityName, id: string): Promise { - // Also remove any pending create/update for this entity+id - const queue = await this.getQueue(); - const dedupKey = `${entity}:${id}`; - const filtered = queue.filter(i => this.getDedupKey(i.entity, i.data) !== dedupKey); - - const item: SyncItem = { - id: this.generateId(), - entity, - operation: 'delete', - data: { id }, - timestamp: new Date().toISOString(), - retryCount: 0, - }; - filtered.push(item); - - await this.saveQueue(filtered); - this.notifyStatus(); - } - - async pull(): Promise { - const result = this.emptyResult(); - this.setStatus('syncing'); - - try { - for (const [entityName, entityConfig] of Object.entries(this.config.entities)) { - try { - const count = await this.pullEntity(entityName, entityConfig.endpoint); - result.pulled += count; - } catch (error) { - result.errors++; - this.trackTelemetry('sync_pull_error', entityName, error); - } - } - - result.timestamp = new Date().toISOString(); - await this.setLastSyncTime(result.timestamp); - this.lastSyncAt = result.timestamp; - this.setStatus(result.errors > 0 ? 'error' : 'idle'); - } catch (error) { - result.success = false; - this.setStatus('error', error instanceof Error ? error.message : 'Unknown error'); - } - - return result; - } - - async fullSync(): Promise { - if (!this.isOnline()) { - this.setStatus('offline'); - return { ...this.emptyResult(), success: false }; - } - - const pushResult = await this.pushQueue(); - const pullResult = await this.pull(); - - const combined: SyncResult = { - success: pushResult.success && pullResult.success, - pushed: pushResult.pushed, - pulled: pullResult.pulled, - conflicts: pushResult.conflicts + pullResult.conflicts, - errors: pushResult.errors + pullResult.errors, - timestamp: new Date().toISOString(), - }; - - this.trackTelemetry('sync_complete', '*', undefined, { - pushed: combined.pushed, - pulled: combined.pulled, - conflicts: combined.conflicts, - errors: combined.errors, - }); - - return combined; - } - - // ─────────────────────────────────────────────────────────────────────────── - // Queue Management - // ─────────────────────────────────────────────────────────────────────────── - - private async getQueue(): Promise { - const queue = await this.config.storage.getItem(QUEUE_KEY); - return queue || []; - } - - private async saveQueue(queue: SyncItem[]): Promise { - await this.config.storage.setItem(QUEUE_KEY, queue); - this.queueLength = queue.length; - } - - private async pushQueue(): Promise { - const queue = await this.getQueue(); - const result = this.emptyResult(); - - if (queue.length === 0) return result; - - this.setStatus('syncing'); - const remaining: SyncItem[] = []; - - for (const item of queue) { - try { - await this.pushItemWithRetry(item); - result.pushed++; - } catch (error) { - if (error instanceof SyncConflictError) { - result.conflicts++; - const resolved = await this.handleConflict(item, error.remoteData); - if (resolved) { - result.pushed++; - } else { - result.errors++; - } - } else if (item.retryCount < this.config.maxRetries) { - item.retryCount++; - item.lastError = error instanceof Error ? error.message : String(error); - remaining.push(item); - } else { - result.errors++; - this.trackTelemetry('sync_push_dropped', item.entity, error); - } - } - } - - await this.saveQueue(remaining); - - if (result.errors > 0) { - result.success = false; - } - - return result; - } - - private async pushItemWithRetry(item: SyncItem): Promise { - const entityConfig = this.config.entities[item.entity]; - if (!entityConfig) { - throw new Error(`Unknown entity: ${item.entity}`); - } - - const dataId = (item.data as { id?: string })?.id; - const path = - (item.operation === 'delete' || item.operation === 'update') && dataId - ? `${entityConfig.endpoint}/${dataId}` - : entityConfig.endpoint; - - const method = - item.operation === 'delete' ? 'DELETE' : item.operation === 'update' ? 'PATCH' : 'POST'; - - const headers: Record = {}; - if (method !== 'DELETE') { - headers['Content-Type'] = 'application/json'; - } - - // Attempt with exponential backoff on transient failures - let lastError: unknown; - const maxAttempts = Math.max(1, this.config.maxRetries - item.retryCount); - - for (let attempt = 0; attempt < maxAttempts; attempt++) { - try { - await this.config.apiClient.fetch(path, { - method, - headers, - body: method !== 'DELETE' ? JSON.stringify(item.data) : undefined, - }); - // Success — track and return - this.trackTelemetry('sync_push_success', item.entity); - return; - } catch (error) { - lastError = error; - - // Check for conflict (409) — don't retry, let caller handle - if (error instanceof SyncConflictError) { - throw error; - } - if (this.isConflictError(error)) { - throw new SyncConflictError(undefined); - } - - // Non-retriable errors — throw immediately - if (this.isNonRetriable(error)) { - throw error; - } - - // Transient error — backoff and retry - if (attempt < maxAttempts - 1) { - const delay = computeBackoff( - attempt, - this.config.retryBaseDelayMs, - this.config.retryMaxDelayMs - ); - await sleep(delay); - } - } - } - - throw lastError; - } - - private async pullEntity(entityName: string, endpoint: string): Promise { - const lastSync = await this.getLastSyncTime(); - const path = lastSync ? `${endpoint}?since=${encodeURIComponent(lastSync)}` : endpoint; - - const response = await this.config.apiClient.safeFetch<{ items: unknown[] }>(path); - - if (response.error || !response.data) { - return 0; - } - - const items = response.data.items ?? []; - - if (items.length > 0 && this.config.onPull) { - await this.config.onPull(entityName, items); - } - - return items.length; - } - - // ─────────────────────────────────────────────────────────────────────────── - // Conflict Resolution - // ─────────────────────────────────────────────────────────────────────────── - - private async handleConflict(item: SyncItem, remoteData: unknown): Promise { - const entityConfig = this.config.entities[item.entity]; - if (!entityConfig) return false; - - const strategy = entityConfig.conflictStrategy; - this.trackTelemetry('sync_conflict', item.entity, undefined, { strategy }); - - try { - const winner = await this.resolveConflict(item, remoteData, strategy); - - if (winner === remoteData) { - // Server wins — nothing to push, consumer gets remote via onPull - if (this.config.onPull) { - await this.config.onPull(item.entity, [remoteData]); - } - return true; - } - - // Client data wins — re-push with force - const dataId = (winner as { id?: string })?.id; - const endpoint = entityConfig.endpoint; - const path = dataId ? `${endpoint}/${dataId}` : endpoint; - await this.config.apiClient.fetch(path, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(winner), - }); - return true; - } catch { - return false; - } - } - - private async resolveConflict( - item: SyncItem, - remoteData: unknown, - strategy: ConflictStrategy - ): Promise { - switch (strategy) { - case 'server-wins': - return remoteData; - - case 'client-wins': - return item.data; - - case 'last-write-wins': { - const localTime = new Date(item.timestamp).getTime(); - const remoteTime = new Date( - (remoteData as { updatedAt?: string })?.updatedAt ?? '1970-01-01' - ).getTime(); - return localTime > remoteTime ? item.data : remoteData; - } - - case 'manual': { - if (this.config.onConflict) { - const conflict: Conflict = { - entity: item.entity, - localItem: item, - remoteData, - }; - return await this.config.onConflict(conflict); - } - // No handler — fall back to server-wins - return remoteData; - } - - default: - return remoteData; - } - } - - // ─────────────────────────────────────────────────────────────────────────── - // Connectivity Detection - // ─────────────────────────────────────────────────────────────────────────── - - private setupConnectivityDetection(): void { - if (typeof globalThis === 'undefined') return; - const win = typeof window !== 'undefined' ? window : undefined; - if (!win?.addEventListener) return; - - this.onlineHandler = () => { - this.setStatus('idle'); - void this.flush(); - }; - this.offlineHandler = () => { - this.setStatus('offline'); - }; - - win.addEventListener('online', this.onlineHandler); - win.addEventListener('offline', this.offlineHandler); - } - - private isOnline(): boolean { - if (typeof navigator !== 'undefined' && typeof navigator.onLine === 'boolean') { - return navigator.onLine; - } - return true; // Assume online in non-browser environments (Node.js, SSR) - } - - async flush(): Promise { - if (this.destroyed || this.status === 'syncing') return; - const result = await this.pushQueue(); - if (result.success && result.errors === 0) { - this.setStatus('idle'); - } - } - - destroy(): void { - this.destroyed = true; - const win = typeof window !== 'undefined' ? window : undefined; - if (win) { - if (this.onlineHandler) win.removeEventListener('online', this.onlineHandler); - if (this.offlineHandler) win.removeEventListener('offline', this.offlineHandler); - } - this.statusListeners.clear(); - } - - // ─────────────────────────────────────────────────────────────────────────── - // Status & Monitoring - // ─────────────────────────────────────────────────────────────────────────── - - getQueueLength(): number { - return this.queueLength; - } - - getStatus(): SyncStatusInfo { - return { - status: this.status, - queueLength: this.queueLength, - lastSyncAt: this.lastSyncAt, - lastError: this.lastError, - }; - } - - onStatusChange(callback: SyncStatusCallback): () => void { - this.statusListeners.add(callback); - return () => this.statusListeners.delete(callback); - } - - private setStatus(status: SyncStatus, error?: string): void { - this.status = status; - if (error) this.lastError = error; - this.notifyStatus(); - } - - private notifyStatus(): void { - const info: SyncStatusInfo = { - status: this.status, - queueLength: this.queueLength, - lastSyncAt: this.lastSyncAt, - lastError: this.lastError, - }; - this.statusListeners.forEach(cb => cb(info)); - } - - // ─────────────────────────────────────────────────────────────────────────── - // Utility - // ─────────────────────────────────────────────────────────────────────────── - - async clearQueue(): Promise { - await this.saveQueue([]); - this.notifyStatus(); - } - - async reprocessFailed(): Promise { - const queue = await this.getQueue(); - const reset = queue.map(item => ({ - ...item, - retryCount: 0, - lastError: undefined, - })); - await this.saveQueue(reset); - await this.flush(); - } - - private async getLastSyncTime(): Promise { - return (await this.config.storage.getItem(LAST_SYNC_KEY)) || undefined; - } - - private async setLastSyncTime(timestamp: string): Promise { - await this.config.storage.setItem(LAST_SYNC_KEY, timestamp); - } - - private generateId(): string { - return `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; - } - - private getDedupKey(entity: string, data: unknown): string { - const id = (data as { id?: string })?.id; - return id ? `${entity}:${id}` : `${entity}:${JSON.stringify(data)}`; - } - - private emptyResult(): SyncResult { - return { - success: true, - pushed: 0, - pulled: 0, - conflicts: 0, - errors: 0, - timestamp: new Date().toISOString(), - }; - } - - // ─────────────────────────────────────────────────────────────────────────── - // Error Classification - // ─────────────────────────────────────────────────────────────────────────── - - private isConflictError(error: unknown): boolean { - if (error instanceof SyncConflictError) return true; - const msg = error instanceof Error ? error.message : String(error); - return msg.includes('409') || msg.includes('conflict'); - } - - private isNonRetriable(error: unknown): boolean { - const msg = error instanceof Error ? error.message : String(error); - // 4xx errors (except 408, 429) are non-retriable - return /\b(400|401|403|404|405|406|410|422)\b/.test(msg); - } - - private extractRemoteData(error: unknown): unknown { - if (error instanceof SyncConflictError) return error.remoteData; - return undefined; - } - - // ─────────────────────────────────────────────────────────────────────────── - // Telemetry - // ─────────────────────────────────────────────────────────────────────────── - - private trackTelemetry( - eventName: string, - entity: string, - error?: unknown, - extra?: Record - ): void { - if (!this.config.telemetryClient) return; - try { - this.config.telemetryClient.trackEvent('sync', 'sync-engine', eventName, { - tags: { - productId: this.config.productId, - entity, - ...(error ? { error: error instanceof Error ? error.message : String(error) } : {}), - }, - metrics: extra as Record | undefined, - }); - } catch { - // Telemetry should never break sync - } - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// Factory Function -// ───────────────────────────────────────────────────────────────────────────── - -export function createSyncEngine(config: SyncEngineConfig): SyncEngine { - return new SyncEngineImpl(config); -} diff --git a/vendor/bytelyst/sync/src/index.ts b/vendor/bytelyst/sync/src/index.ts deleted file mode 100644 index e49e961..0000000 --- a/vendor/bytelyst/sync/src/index.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * @bytelyst/sync - * - * Offline-first sync engine with configurable storage adapters and conflict resolution. - * - * @example - * ```typescript - * import { createSyncEngine, LocalStorageAdapter } from '@bytelyst/sync'; - * import { createApiClient } from '@bytelyst/api-client'; - * - * const sync = createSyncEngine({ - * productId: 'myapp', - * entities: { - * tasks: { - * endpoint: '/api/tasks', - * partitionKey: 'userId', - * conflictStrategy: 'server-wins', - * }, - * }, - * storage: new LocalStorageAdapter(), - * apiClient: createApiClient({ baseURL: 'https://api.example.com' }), - * }); - * - * // Push changes - * await sync.push('tasks', { title: 'New Task' }); - * - * // Sync with server - * const result = await sync.fullSync(); - * console.log(`Pushed: ${result.pushed}, Pulled: ${result.pulled}`); - * ``` - */ - -export { createSyncEngine, SyncEngineImpl, SyncConflictError, computeBackoff } from './engine.js'; - -export { LocalStorageAdapter, InMemoryAdapter, MMKVAdapter, type MMKVInstance } from './storage.js'; - -export type { - SyncEngine, - SyncEngineConfig, - EntityName, - EntityConfig, - ConflictStrategy, - SyncStatus, - SyncOperation, - SyncItem, - SyncResult, - SyncStatusInfo, - SyncStatusCallback, - PullHandler, - StorageAdapter, - Conflict, -} from './types.js'; diff --git a/vendor/bytelyst/sync/src/storage.ts b/vendor/bytelyst/sync/src/storage.ts deleted file mode 100644 index eb902f0..0000000 --- a/vendor/bytelyst/sync/src/storage.ts +++ /dev/null @@ -1,127 +0,0 @@ -/** - * Storage Adapters - * - * @module @bytelyst/sync/storage - */ - -import type { StorageAdapter } from './types.js'; - -// ───────────────────────────────────────────────────────────────────────────── -// LocalStorage Adapter (Web) -// ───────────────────────────────────────────────────────────────────────────── - -export class LocalStorageAdapter implements StorageAdapter { - private prefix: string; - - constructor(prefix = 'bytelyst:sync:') { - this.prefix = prefix; - } - - getItem(key: string): T | null { - if (typeof localStorage === 'undefined') return null; - const value = localStorage.getItem(this.prefix + key); - if (!value) return null; - try { - return JSON.parse(value) as T; - } catch { - return null; - } - } - - setItem(key: string, value: T): void { - if (typeof localStorage === 'undefined') return; - localStorage.setItem(this.prefix + key, JSON.stringify(value)); - } - - removeItem(key: string): void { - if (typeof localStorage === 'undefined') return; - localStorage.removeItem(this.prefix + key); - } - - keys(): string[] { - if (typeof localStorage === 'undefined') return []; - const keys: string[] = []; - for (let i = 0; i < localStorage.length; i++) { - const key = localStorage.key(i); - if (key && key.startsWith(this.prefix)) { - keys.push(key.slice(this.prefix.length)); - } - } - return keys; - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// In-Memory Adapter (Testing / SSR) -// ───────────────────────────────────────────────────────────────────────────── - -export class InMemoryAdapter implements StorageAdapter { - private store = new Map(); - - getItem(key: string): T | null { - const value = this.store.get(key); - return value !== undefined ? (value as T) : null; - } - - setItem(key: string, value: T): void { - this.store.set(key, value); - } - - removeItem(key: string): void { - this.store.delete(key); - } - - keys(): string[] { - return Array.from(this.store.keys()); - } - - clear(): void { - this.store.clear(); - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// MMKV Adapter Interface (React Native) -// ───────────────────────────────────────────────────────────────────────────── - -export interface MMKVInstance { - getString(key: string): string | undefined; - set(key: string, value: string): void; - delete(key: string): void; - getAllKeys(): string[]; -} - -export class MMKVAdapter implements StorageAdapter { - private mmkv: MMKVInstance; - private prefix: string; - - constructor(mmkv: MMKVInstance, prefix = 'sync:') { - this.mmkv = mmkv; - this.prefix = prefix; - } - - getItem(key: string): T | null { - const value = this.mmkv.getString(this.prefix + key); - if (!value) return null; - try { - return JSON.parse(value) as T; - } catch { - return null; - } - } - - setItem(key: string, value: T): void { - this.mmkv.set(this.prefix + key, JSON.stringify(value)); - } - - removeItem(key: string): void { - this.mmkv.delete(this.prefix + key); - } - - keys(): string[] { - return this.mmkv - .getAllKeys() - .filter(k => k.startsWith(this.prefix)) - .map(k => k.slice(this.prefix.length)); - } -} diff --git a/vendor/bytelyst/sync/src/sync.test.ts b/vendor/bytelyst/sync/src/sync.test.ts deleted file mode 100644 index ce876b0..0000000 --- a/vendor/bytelyst/sync/src/sync.test.ts +++ /dev/null @@ -1,608 +0,0 @@ -/** - * Sync Engine Tests — 25+ tests - * - * Covers: queue persistence, retry with backoff, conflict resolution (all 4 - * strategies), deduplication, connectivity, onPull callback, telemetry, - * delete consolidation, multiple entities, status monitoring, destroy. - * - * @module @bytelyst/sync/sync.test - */ - -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { createSyncEngine, InMemoryAdapter, computeBackoff, SyncConflictError } from './index.js'; -import type { SyncStatusInfo, SyncEngineConfig, EntityConfig } from './types.js'; -import type { ApiClient, ApiResult } from '@bytelyst/api-client'; -import type { TelemetryClient } from '@bytelyst/telemetry-client'; - -// ───────────────────────────────────────────────────────────────────────────── -// Helpers -// ───────────────────────────────────────────────────────────────────────────── - -interface MockApiClient extends ApiClient { - getRequests(): { path: string; options?: RequestInit }[]; - setFetchBehavior(fn: (path: string, options?: RequestInit) => unknown): void; - setSafeFetchBehavior(fn: (path: string) => unknown): void; -} - -function createMockApiClient(): MockApiClient { - const requests: { path: string; options?: RequestInit }[] = []; - let fetchBehavior: ((path: string, options?: RequestInit) => unknown) | null = null; - let safeFetchBehavior: ((path: string) => unknown) | null = null; - - return { - fetch: async (path: string, options?: RequestInit): Promise => { - requests.push({ path, options }); - if (fetchBehavior) return fetchBehavior(path, options) as T; - return {} as T; - }, - safeFetch: async (path: string, options?: RequestInit): Promise> => { - requests.push({ path, options }); - if (safeFetchBehavior) return safeFetchBehavior(path) as ApiResult; - return { data: { items: [] } as unknown as T, error: null }; - }, - getRequests: () => requests, - setFetchBehavior: fn => { - fetchBehavior = fn; - }, - setSafeFetchBehavior: fn => { - safeFetchBehavior = fn; - }, - }; -} - -function createMockTelemetry(): TelemetryClient & { events: { eventName: string }[] } { - const events: { eventName: string }[] = []; - return { - init: vi.fn(), - trackEvent: (eventType: string, module: string, eventName: string) => { - events.push({ eventName }); - }, - flush: vi.fn(), - shutdown: vi.fn(), - getInstallId: () => 'test-install', - getSessionId: () => 'test-session', - events, - }; -} - -const TASKS_ENTITY: EntityConfig = { - endpoint: '/tasks', - partitionKey: 'userId', - conflictStrategy: 'server-wins', -}; - -function makeConfig( - storage: InMemoryAdapter, - apiClient: MockApiClient, - overrides?: Partial -): SyncEngineConfig { - return { - productId: 'test', - entities: { tasks: TASKS_ENTITY }, - storage, - apiClient, - maxRetries: 3, - retryBaseDelayMs: 1, // fast for tests - retryMaxDelayMs: 10, - ...overrides, - }; -} - -// ───────────────────────────────────────────────────────────────────────────── -// Storage Adapter Tests -// ───────────────────────────────────────────────────────────────────────────── - -describe('InMemoryAdapter', () => { - it('stores and retrieves items', () => { - const s = new InMemoryAdapter(); - s.setItem('k', { x: 1 }); - expect(s.getItem<{ x: number }>('k')).toEqual({ x: 1 }); - }); - - it('returns null for missing keys', () => { - expect(new InMemoryAdapter().getItem('nope')).toBeNull(); - }); - - it('lists all keys', () => { - const s = new InMemoryAdapter(); - s.setItem('a', 1); - s.setItem('b', 2); - expect(s.keys()).toEqual(expect.arrayContaining(['a', 'b'])); - }); - - it('removes items', () => { - const s = new InMemoryAdapter(); - s.setItem('a', 1); - s.removeItem('a'); - expect(s.getItem('a')).toBeNull(); - }); - - it('clears all items', () => { - const s = new InMemoryAdapter(); - s.setItem('a', 1); - s.setItem('b', 2); - s.clear(); - expect(s.keys()).toHaveLength(0); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// computeBackoff -// ───────────────────────────────────────────────────────────────────────────── - -describe('computeBackoff', () => { - it('returns increasing delays', () => { - const d0 = computeBackoff(0, 1000, 30000); - const d1 = computeBackoff(1, 1000, 30000); - const d2 = computeBackoff(2, 1000, 30000); - // d0 ~ 1000, d1 ~ 2000, d2 ~ 4000 (+ jitter) - expect(d0).toBeLessThan(d1); - expect(d1).toBeLessThan(d2); - }); - - it('caps at maxMs', () => { - const d = computeBackoff(20, 1000, 5000); - expect(d).toBeLessThanOrEqual(5500); // 5000 + 10% jitter max - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// Sync Engine — Core Operations -// ───────────────────────────────────────────────────────────────────────────── - -describe('Sync Engine', () => { - let storage: InMemoryAdapter; - let apiClient: MockApiClient; - - beforeEach(() => { - storage = new InMemoryAdapter(); - apiClient = createMockApiClient(); - }); - - // ─── Creation ────────────────────────────────────────────────────────── - - it('creates engine with all interface methods', () => { - const engine = createSyncEngine(makeConfig(storage, apiClient)); - expect(engine.push).toBeTypeOf('function'); - expect(engine.delete).toBeTypeOf('function'); - expect(engine.pull).toBeTypeOf('function'); - expect(engine.fullSync).toBeTypeOf('function'); - expect(engine.getQueueLength).toBeTypeOf('function'); - expect(engine.getStatus).toBeTypeOf('function'); - expect(engine.onStatusChange).toBeTypeOf('function'); - expect(engine.clearQueue).toBeTypeOf('function'); - expect(engine.reprocessFailed).toBeTypeOf('function'); - expect(engine.flush).toBeTypeOf('function'); - expect(engine.destroy).toBeTypeOf('function'); - }); - - // ─── Queue Persistence ───────────────────────────────────────────────── - - it('persists queue across engine instances (simulated restart)', async () => { - const engine1 = createSyncEngine(makeConfig(storage, apiClient)); - await engine1.push('tasks', { id: 't1', title: 'persist me' }); - engine1.destroy(); - - // "Restart" — new engine, same storage - const engine2 = createSyncEngine(makeConfig(storage, apiClient)); - const result = await engine2.fullSync(); - expect(result.pushed).toBe(1); - - const reqs = apiClient.getRequests(); - const postReq = reqs.find(r => r.options?.method === 'POST'); - expect(postReq).toBeDefined(); - expect(postReq!.path).toBe('/tasks'); - }); - - // ─── Push & Deduplication ────────────────────────────────────────────── - - it('deduplicates updates to same entity+id', async () => { - const engine = createSyncEngine(makeConfig(storage, apiClient)); - await engine.push('tasks', { id: '1', title: 'v1' }, 'update'); - await engine.push('tasks', { id: '1', title: 'v2' }, 'update'); - expect(engine.getQueueLength()).toBe(1); - - const result = await engine.fullSync(); - expect(result.pushed).toBe(1); - - // The last value should be sent - const patchReq = apiClient.getRequests().find(r => r.options?.method === 'PATCH'); - expect(patchReq).toBeDefined(); - expect(patchReq!.path).toBe('/tasks/1'); - const body = JSON.parse(patchReq!.options!.body as string); - expect(body.title).toBe('v2'); - }); - - it('does not deduplicate different operations on same id', async () => { - const engine = createSyncEngine(makeConfig(storage, apiClient)); - await engine.push('tasks', { id: '1', title: 'create' }, 'create'); - await engine.push('tasks', { id: '1', title: 'update' }, 'update'); - expect(engine.getQueueLength()).toBe(2); - }); - - it('does not deduplicate items without id', async () => { - const engine = createSyncEngine(makeConfig(storage, apiClient)); - await engine.push('tasks', { title: 'Task A' }); - await engine.push('tasks', { title: 'Task B' }); - expect(engine.getQueueLength()).toBe(2); - }); - - // ─── Delete ──────────────────────────────────────────────────────────── - - it('delete removes pending create/update for same entity+id', async () => { - const engine = createSyncEngine(makeConfig(storage, apiClient)); - await engine.push('tasks', { id: 'x', title: 'created' }); - await engine.push('tasks', { id: 'x', title: 'updated' }, 'update'); - // Now delete should collapse the above - await engine.delete('tasks', 'x'); - expect(engine.getQueueLength()).toBe(1); - - const result = await engine.fullSync(); - expect(result.pushed).toBe(1); - const delReq = apiClient.getRequests().find(r => r.options?.method === 'DELETE'); - expect(delReq).toBeDefined(); - expect(delReq!.path).toBe('/tasks/x'); - }); - - // ─── Pull + onPull Callback ──────────────────────────────────────────── - - it('invokes onPull with pulled items', async () => { - const pulled: { entity: string; items: unknown[] }[] = []; - apiClient.setSafeFetchBehavior(() => ({ - data: { items: [{ id: 'r1', title: 'Remote Task' }] }, - error: null, - })); - - const engine = createSyncEngine( - makeConfig(storage, apiClient, { - onPull: (entity, items) => { - pulled.push({ entity, items }); - }, - }) - ); - - const result = await engine.pull(); - expect(result.pulled).toBe(1); - expect(pulled).toHaveLength(1); - expect(pulled[0].entity).toBe('tasks'); - expect(pulled[0].items).toHaveLength(1); - }); - - it('pull appends ?since= parameter after first sync', async () => { - const engine = createSyncEngine(makeConfig(storage, apiClient)); - - await engine.pull(); // first pull — no since - const firstReq = apiClient.getRequests().find(r => r.path.startsWith('/tasks')); - expect(firstReq!.path).toBe('/tasks'); - - await engine.pull(); // second pull — should have since= - const allReqs = apiClient.getRequests().filter(r => r.path.startsWith('/tasks')); - const secondReq = allReqs[allReqs.length - 1]; - expect(secondReq.path).toContain('?since='); - }); - - // ─── fullSync ────────────────────────────────────────────────────────── - - it('fullSync pushes then pulls', async () => { - const engine = createSyncEngine(makeConfig(storage, apiClient)); - await engine.push('tasks', { id: 't1', title: 'local' }); - - const result = await engine.fullSync(); - expect(result.pushed).toBe(1); - expect(engine.getQueueLength()).toBe(0); - expect(engine.getStatus().lastSyncAt).toBeTruthy(); - }); - - // ─── Retry with Backoff ──────────────────────────────────────────────── - - it('retries on transient errors and keeps item in queue', async () => { - let callCount = 0; - apiClient.setFetchBehavior(() => { - callCount++; - throw new Error('500 Internal Server Error'); - }); - - const engine = createSyncEngine(makeConfig(storage, apiClient, { maxRetries: 3 })); - await engine.push('tasks', { id: 'fail', title: 'will fail' }); - - await engine.flush(); - - // Item should still be in queue with incremented retryCount - expect(engine.getQueueLength()).toBe(1); - // Multiple fetch attempts were made (backoff retries within pushItemWithRetry) - expect(callCount).toBeGreaterThan(1); - }); - - it('drops item after exceeding maxRetries', async () => { - apiClient.setFetchBehavior(() => { - throw new Error('500'); - }); - - const engine = createSyncEngine(makeConfig(storage, apiClient, { maxRetries: 1 })); - await engine.push('tasks', { id: 'drop', title: 'drop me' }); - - // First flush: pushItemWithRetry exhausts attempts, pushQueue increments retryCount - await engine.flush(); - // Second flush: retryCount >= maxRetries → dropped - await engine.flush(); - - expect(engine.getQueueLength()).toBe(0); - }); - - // ─── Conflict Resolution ─────────────────────────────────────────────── - - it('server-wins: accepts remote data on conflict', async () => { - const pulled: unknown[][] = []; - apiClient.setFetchBehavior(() => { - throw new SyncConflictError({ id: 'c1', title: 'Server Version' }); - }); - - const engine = createSyncEngine( - makeConfig(storage, apiClient, { - entities: { - tasks: { endpoint: '/tasks', partitionKey: 'userId', conflictStrategy: 'server-wins' }, - }, - onPull: (_entity, items) => { - pulled.push(items); - }, - }) - ); - - await engine.push('tasks', { id: 'c1', title: 'Client Version' }); - const result = await engine.fullSync(); - - expect(result.conflicts).toBe(1); - // server-wins: onPull should have been called with remote data - expect(pulled.length).toBeGreaterThanOrEqual(1); - }); - - it('client-wins: re-pushes local data on conflict', async () => { - let callIdx = 0; - apiClient.setFetchBehavior(() => { - callIdx++; - if (callIdx === 1) { - throw new SyncConflictError({ id: 'c2', title: 'Server' }); - } - return {}; // Second call (PUT) succeeds - }); - - const engine = createSyncEngine( - makeConfig(storage, apiClient, { - entities: { - tasks: { endpoint: '/tasks', partitionKey: 'userId', conflictStrategy: 'client-wins' }, - }, - }) - ); - - await engine.push('tasks', { id: 'c2', title: 'Client' }); - const result = await engine.fullSync(); - - expect(result.conflicts).toBe(1); - expect(result.pushed).toBe(1); // conflict resolved → counted as pushed - // Should have made a PUT request with client data - const putReq = apiClient.getRequests().find(r => r.options?.method === 'PUT'); - expect(putReq).toBeDefined(); - }); - - it('last-write-wins: picks newer timestamp', async () => { - const pulled: unknown[][] = []; - const oldDate = '2020-01-01T00:00:00.000Z'; - apiClient.setFetchBehavior(() => { - throw new SyncConflictError({ id: 'c3', title: 'Server', updatedAt: oldDate }); - }); - - const engine = createSyncEngine( - makeConfig(storage, apiClient, { - entities: { - tasks: { - endpoint: '/tasks', - partitionKey: 'userId', - conflictStrategy: 'last-write-wins', - }, - }, - onPull: (_entity, items) => { - pulled.push(items); - }, - }) - ); - - // Client push will have a newer timestamp than 2020 - await engine.push('tasks', { id: 'c3', title: 'Client Newer' }); - const result = await engine.fullSync(); - expect(result.conflicts).toBe(1); - // Client is newer → should NOT have called onPull with server data - // Instead it should have re-pushed (PUT) - const putReq = apiClient.getRequests().find(r => r.options?.method === 'PUT'); - expect(putReq).toBeDefined(); - }); - - it('manual: calls onConflict handler', async () => { - apiClient.setFetchBehavior((_path, options) => { - const method = options?.method; - if (method === 'POST') { - throw new SyncConflictError({ id: 'c4', title: 'Server' }); - } - return {}; - }); - - const onConflict = vi.fn().mockResolvedValue({ id: 'c4', title: 'Merged' }); - - const engine = createSyncEngine( - makeConfig(storage, apiClient, { - entities: { - tasks: { endpoint: '/tasks', partitionKey: 'userId', conflictStrategy: 'manual' }, - }, - onConflict, - }) - ); - - await engine.push('tasks', { id: 'c4', title: 'Client' }); - const result = await engine.fullSync(); - - expect(result.conflicts).toBe(1); - expect(onConflict).toHaveBeenCalledTimes(1); - expect(onConflict.mock.calls[0][0]).toMatchObject({ - entity: 'tasks', - remoteData: { id: 'c4', title: 'Server' }, - }); - }); - - // ─── Multiple Entities ───────────────────────────────────────────────── - - it('handles multiple entity types', async () => { - const pulled: { entity: string; items: unknown[] }[] = []; - apiClient.setSafeFetchBehavior(path => { - if (path.startsWith('/tasks')) { - return { data: { items: [{ id: 't1' }] }, error: null }; - } - if (path.startsWith('/notes')) { - return { data: { items: [{ id: 'n1' }, { id: 'n2' }] }, error: null }; - } - return { data: { items: [] }, error: null }; - }); - - const engine = createSyncEngine( - makeConfig(storage, apiClient, { - entities: { - tasks: { endpoint: '/tasks', partitionKey: 'userId', conflictStrategy: 'server-wins' }, - notes: { endpoint: '/notes', partitionKey: 'userId', conflictStrategy: 'client-wins' }, - }, - onPull: (entity, items) => { - pulled.push({ entity, items }); - }, - }) - ); - - await engine.push('tasks', { id: 't-new', title: 'Task' }); - await engine.push('notes', { id: 'n-new', body: 'Note' }); - - const result = await engine.fullSync(); - expect(result.pushed).toBe(2); - expect(result.pulled).toBe(3); // 1 task + 2 notes - expect(pulled).toHaveLength(2); - }); - - // ─── Status Monitoring ───────────────────────────────────────────────── - - it('returns correct initial status', () => { - const engine = createSyncEngine(makeConfig(storage, apiClient)); - const status = engine.getStatus(); - expect(status.status).toBe('idle'); - expect(status.queueLength).toBe(0); - expect(status.lastSyncAt).toBeUndefined(); - }); - - it('notifies listeners on status changes', async () => { - const engine = createSyncEngine(makeConfig(storage, apiClient)); - const statuses: SyncStatusInfo[] = []; - engine.onStatusChange(s => statuses.push({ ...s })); - - await engine.push('tasks', { id: 'x', title: 'X' }); - await engine.fullSync(); - - const statusNames = statuses.map(s => s.status); - expect(statusNames).toContain('syncing'); - expect(statusNames).toContain('idle'); - }); - - it('unsubscribe stops notifications', async () => { - const engine = createSyncEngine(makeConfig(storage, apiClient)); - const statuses: string[] = []; - const unsub = engine.onStatusChange(s => statuses.push(s.status)); - - await engine.push('tasks', { title: 'A' }); - const countBefore = statuses.length; - - unsub(); - await engine.push('tasks', { title: 'B' }); - expect(statuses.length).toBe(countBefore); - }); - - // ─── clearQueue ──────────────────────────────────────────────────────── - - it('clearQueue empties the queue', async () => { - const engine = createSyncEngine(makeConfig(storage, apiClient)); - await engine.push('tasks', { title: 'A' }); - await engine.push('tasks', { title: 'B' }); - expect(engine.getQueueLength()).toBe(2); - - await engine.clearQueue(); - expect(engine.getQueueLength()).toBe(0); - - const result = await engine.fullSync(); - expect(result.pushed).toBe(0); - }); - - // ─── reprocessFailed ─────────────────────────────────────────────────── - - it('reprocessFailed resets retry counts and re-flushes', async () => { - let failCount = 0; - apiClient.setFetchBehavior(() => { - failCount++; - if (failCount <= 2) throw new Error('transient'); - return {}; - }); - - const engine = createSyncEngine(makeConfig(storage, apiClient, { maxRetries: 1 })); - await engine.push('tasks', { id: 'rp', title: 'reprocess' }); - await engine.flush(); // fails, item stays in queue - - expect(engine.getQueueLength()).toBe(1); - - // Now make API succeed and reprocess - apiClient.setFetchBehavior(() => ({})); - await engine.reprocessFailed(); - expect(engine.getQueueLength()).toBe(0); - }); - - // ─── Telemetry Integration ───────────────────────────────────────────── - - it('tracks sync events via telemetry client', async () => { - const telemetry = createMockTelemetry(); - const engine = createSyncEngine( - makeConfig(storage, apiClient, { - telemetryClient: telemetry, - }) - ); - - await engine.push('tasks', { id: 't1', title: 'test' }); - await engine.fullSync(); - - const eventNames = telemetry.events.map(e => e.eventName); - expect(eventNames).toContain('sync_push_success'); - expect(eventNames).toContain('sync_complete'); - }); - - it('telemetry tracks push errors', async () => { - const telemetry = createMockTelemetry(); - apiClient.setFetchBehavior(() => { - throw new Error('400 Bad Request'); - }); - - const engine = createSyncEngine( - makeConfig(storage, apiClient, { - telemetryClient: telemetry, - maxRetries: 1, - }) - ); - - await engine.push('tasks', { id: 'bad', title: 'fail' }); - await engine.flush(); - await engine.flush(); // second flush drops item - - const eventNames = telemetry.events.map(e => e.eventName); - expect(eventNames).toContain('sync_push_dropped'); - }); - - // ─── Destroy ─────────────────────────────────────────────────────────── - - it('destroy prevents further flush', async () => { - const engine = createSyncEngine(makeConfig(storage, apiClient)); - await engine.push('tasks', { title: 'orphan' }); - engine.destroy(); - - await engine.flush(); // should be no-op after destroy - // Item still in queue (flush was no-op) - expect(engine.getQueueLength()).toBe(1); - }); -}); diff --git a/vendor/bytelyst/sync/src/types.ts b/vendor/bytelyst/sync/src/types.ts deleted file mode 100644 index e8fc60d..0000000 --- a/vendor/bytelyst/sync/src/types.ts +++ /dev/null @@ -1,129 +0,0 @@ -/** - * Sync Engine Types - * - * @module @bytelyst/sync/types - */ - -import type { ApiClient } from '@bytelyst/api-client'; -import type { TelemetryClient } from '@bytelyst/telemetry-client'; - -// ───────────────────────────────────────────────────────────────────────────── -// Core Types -// ───────────────────────────────────────────────────────────────────────────── - -export type EntityName = string; - -export type ConflictStrategy = 'server-wins' | 'client-wins' | 'last-write-wins' | 'manual'; - -export type SyncStatus = 'idle' | 'syncing' | 'offline' | 'error'; - -export type SyncOperation = 'create' | 'update' | 'delete'; - -export interface EntityConfig { - /** REST endpoint path, e.g. '/api/timers' */ - endpoint: string; - /** Cosmos partition key field name (for reference) */ - partitionKey: string; - /** Conflict resolution strategy */ - conflictStrategy: ConflictStrategy; -} - -/** - * Callback invoked when items are pulled from the server. - * Consumer is responsible for merging pulled data into their local store. - */ -export type PullHandler = (entity: EntityName, items: unknown[]) => void | Promise; - -export interface SyncEngineConfig { - productId: string; - entities: Record; - storage: StorageAdapter; - apiClient: ApiClient; - telemetryClient?: TelemetryClient; - /** Called when items are pulled from server — consumer merges into local store */ - onPull?: PullHandler; - /** Called for 'manual' conflict strategy. Return the winning data. */ - onConflict?: (conflict: Conflict) => Promise | unknown; - /** Max retry attempts before dropping an item. Default: 5. */ - maxRetries?: number; - /** Base delay in ms for exponential backoff. Default: 1000. */ - retryBaseDelayMs?: number; - /** Maximum backoff delay in ms. Default: 30000. */ - retryMaxDelayMs?: number; -} - -export interface SyncItem { - id: string; - entity: EntityName; - operation: SyncOperation; - data: unknown; - timestamp: string; - retryCount: number; - lastError?: string; -} - -export interface SyncResult { - success: boolean; - pushed: number; - pulled: number; - conflicts: number; - errors: number; - timestamp: string; -} - -export interface SyncStatusInfo { - status: SyncStatus; - queueLength: number; - lastSyncAt?: string; - lastError?: string; -} - -export type SyncStatusCallback = (status: SyncStatusInfo) => void; - -export interface Conflict { - entity: EntityName; - localItem: SyncItem; - remoteData: unknown; -} - -// ───────────────────────────────────────────────────────────────────────────── -// Storage Adapter Interface -// ───────────────────────────────────────────────────────────────────────────── - -export interface StorageAdapter { - getItem(key: string): Promise | T | null; - setItem(key: string, value: T): Promise | void; - removeItem(key: string): Promise | void; - keys(): Promise | string[]; -} - -// ───────────────────────────────────────────────────────────────────────────── -// Sync Engine Interface -// ───────────────────────────────────────────────────────────────────────────── - -export interface SyncEngine { - /** Queue a create/update for later push. Deduplicates by entity + data.id. */ - push(entity: EntityName, data: unknown, operation?: SyncOperation): Promise; - /** Queue a delete for later push. */ - delete(entity: EntityName, id: string): Promise; - /** Pull remote changes for all entities. Invokes onPull callback. */ - pull(): Promise; - /** Push queued items, then pull remote changes. */ - fullSync(): Promise; - - /** Current number of items in the offline queue. */ - getQueueLength(): number; - /** Current sync status snapshot. */ - getStatus(): SyncStatusInfo; - /** Subscribe to status changes. Returns unsubscribe function. */ - onStatusChange(callback: SyncStatusCallback): () => void; - - /** Remove all items from the offline queue. */ - clearQueue(): Promise; - /** Reset retry counts on all failed items and re-flush. */ - reprocessFailed(): Promise; - /** Manually trigger a flush of the push queue. */ - flush(): Promise; - /** Tear down connectivity listeners. */ - destroy(): void; -} diff --git a/vendor/bytelyst/sync/tsconfig.json b/vendor/bytelyst/sync/tsconfig.json deleted file mode 100644 index 2118468..0000000 --- a/vendor/bytelyst/sync/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "./dist", - "rootDir": "./src", - "declaration": true, - "declarationMap": true, - "lib": ["ES2022", "DOM", "DOM.Iterable"] - }, - "include": ["src/**/*"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/vendor/bytelyst/sync/vitest.config.ts b/vendor/bytelyst/sync/vitest.config.ts deleted file mode 100644 index 5e0718b..0000000 --- a/vendor/bytelyst/sync/vitest.config.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - test: { - globals: true, - environment: 'node', - pool: 'forks', - include: ['src/**/*.test.ts'], - coverage: { - reporter: ['text', 'json', 'html'], - include: ['src/**/*.ts'], - exclude: ['src/**/*.test.ts', 'src/**/index.ts'], - }, - }, -}); diff --git a/vendor/bytelyst/telemetry-client/package.json b/vendor/bytelyst/telemetry-client/package.json deleted file mode 100644 index 0ee6c1d..0000000 --- a/vendor/bytelyst/telemetry-client/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "@bytelyst/telemetry-client", - "version": "0.1.5", - "type": "module", - "description": "Browser/React Native-safe telemetry client for platform-service", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/telemetry-client/src/__tests__/telemetry-client.test.ts b/vendor/bytelyst/telemetry-client/src/__tests__/telemetry-client.test.ts deleted file mode 100644 index 33cc1d8..0000000 --- a/vendor/bytelyst/telemetry-client/src/__tests__/telemetry-client.test.ts +++ /dev/null @@ -1,255 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { createTelemetryClient } from '../client.js'; -import type { TelemetryStorage } from '../types.js'; - -function createMockStorage(): TelemetryStorage & { store: Map } { - const store = new Map(); - return { - store, - getItem: (key: string) => store.get(key) ?? null, - setItem: (key: string, value: string) => store.set(key, value), - }; -} - -describe('@bytelyst/telemetry-client', () => { - let storage: ReturnType; - - beforeEach(() => { - storage = createMockStorage(); - vi.restoreAllMocks(); - vi.useFakeTimers(); - globalThis.fetch = vi.fn().mockResolvedValue({ ok: true }); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - describe('createTelemetryClient', () => { - it('creates a client with all expected methods', () => { - const client = createTelemetryClient({ - productId: 'testapp', - baseUrl: 'http://localhost:4003/api', - platform: 'web', - channel: 'pwa', - storage, - }); - - expect(client.init).toBeTypeOf('function'); - expect(client.trackEvent).toBeTypeOf('function'); - expect(client.flush).toBeTypeOf('function'); - expect(client.shutdown).toBeTypeOf('function'); - expect(client.getInstallId).toBeTypeOf('function'); - expect(client.getSessionId).toBeTypeOf('function'); - }); - }); - - describe('install ID', () => { - it('generates and persists install ID', () => { - const client = createTelemetryClient({ - productId: 'testapp', - baseUrl: 'http://localhost:4003/api', - platform: 'web', - channel: 'pwa', - storage, - }); - - const id = client.getInstallId(); - expect(id).toBeTruthy(); - expect(storage.store.get('testapp_telemetry_install_id')).toBe(id); - }); - - it('reuses persisted install ID', () => { - storage.store.set('testapp_telemetry_install_id', 'existing-id'); - - const client = createTelemetryClient({ - productId: 'testapp', - baseUrl: 'http://localhost:4003/api', - platform: 'web', - channel: 'pwa', - storage, - }); - - expect(client.getInstallId()).toBe('existing-id'); - }); - }); - - describe('init', () => { - it('generates a session ID on init', () => { - const client = createTelemetryClient({ - productId: 'testapp', - baseUrl: 'http://localhost:4003/api', - platform: 'web', - channel: 'pwa', - storage, - }); - - expect(client.getSessionId()).toBe(''); - client.init(); - expect(client.getSessionId()).toBeTruthy(); - }); - - it('tracks session_started event on init', () => { - const client = createTelemetryClient({ - productId: 'testapp', - baseUrl: 'http://localhost:4003/api', - platform: 'web', - channel: 'pwa', - storage, - }); - - client.init(); - // Flush to see the queued event - client.flush(); - - expect(globalThis.fetch).toHaveBeenCalled(); - const body = JSON.parse((globalThis.fetch as ReturnType).mock.calls[0][1].body); - expect(body.productId).toBe('testapp'); - expect(body.events).toHaveLength(1); - expect(body.events[0].eventName).toBe('session_started'); - expect(body.events[0].platform).toBe('web'); - expect(body.events[0].channel).toBe('pwa'); - }); - }); - - describe('trackEvent', () => { - it('queues events and flushes via fetch', () => { - const client = createTelemetryClient({ - productId: 'chronomind', - baseUrl: 'http://localhost:4003/api', - platform: 'web', - channel: 'pwa', - transport: 'fetch', - storage, - }); - - client.trackEvent('info', 'timer', 'timer_created', { - feature: 'countdown', - tags: { type: 'alarm' }, - metrics: { duration: 300 }, - }); - - client.flush(); - - expect(globalThis.fetch).toHaveBeenCalledOnce(); - const [url, opts] = (globalThis.fetch as ReturnType).mock.calls[0]; - expect(url).toBe('http://localhost:4003/api/telemetry/events'); - expect(opts.method).toBe('POST'); - expect(opts.headers['x-product-id']).toBe('chronomind'); - - const body = JSON.parse(opts.body); - expect(body.events).toHaveLength(1); - const ev = body.events[0]; - expect(ev.eventType).toBe('info'); - expect(ev.module).toBe('timer'); - expect(ev.eventName).toBe('timer_created'); - expect(ev.feature).toBe('countdown'); - expect(ev.tags.type).toBe('alarm'); - expect(ev.metrics.duration).toBe(300); - expect(ev.occurredAt).toBeTruthy(); - }); - - it('auto-flushes when queue reaches maxQueue', () => { - const client = createTelemetryClient({ - productId: 'testapp', - baseUrl: 'http://localhost:4003/api', - platform: 'mobile', - channel: 'react_native', - transport: 'fetch', - maxQueue: 3, - storage, - }); - - // Don't init to avoid session_started - client.trackEvent('info', 'a', 'one'); - client.trackEvent('info', 'b', 'two'); - expect(globalThis.fetch).not.toHaveBeenCalled(); - - client.trackEvent('info', 'c', 'three'); - expect(globalThis.fetch).toHaveBeenCalledOnce(); - }); - }); - - describe('flush', () => { - it('does nothing when queue is empty', () => { - const client = createTelemetryClient({ - productId: 'testapp', - baseUrl: 'http://localhost:4003/api', - platform: 'web', - channel: 'pwa', - storage, - }); - - client.flush(); - expect(globalThis.fetch).not.toHaveBeenCalled(); - }); - }); - - describe('periodic flush', () => { - it('flushes on interval', () => { - const client = createTelemetryClient({ - productId: 'testapp', - baseUrl: 'http://localhost:4003/api', - platform: 'web', - channel: 'pwa', - flushIntervalMs: 10_000, - storage, - }); - - client.init(); - (globalThis.fetch as ReturnType).mockClear(); - - client.trackEvent('info', 'test', 'event1'); - - expect(globalThis.fetch).not.toHaveBeenCalled(); - - vi.advanceTimersByTime(10_000); - - expect(globalThis.fetch).toHaveBeenCalledOnce(); - }); - }); - - describe('shutdown', () => { - it('flushes remaining events and stops timer', () => { - const client = createTelemetryClient({ - productId: 'testapp', - baseUrl: 'http://localhost:4003/api', - platform: 'web', - channel: 'pwa', - storage, - }); - - client.init(); - (globalThis.fetch as ReturnType).mockClear(); - - client.trackEvent('info', 'test', 'event1'); - client.shutdown(); - - expect(globalThis.fetch).toHaveBeenCalledOnce(); - - // After shutdown, periodic flush should not fire - (globalThis.fetch as ReturnType).mockClear(); - client.trackEvent('info', 'test', 'event2'); - vi.advanceTimersByTime(60_000); - expect(globalThis.fetch).not.toHaveBeenCalled(); - }); - }); - - describe('userId passthrough', () => { - it('includes userId in event when provided', () => { - const client = createTelemetryClient({ - productId: 'testapp', - baseUrl: 'http://localhost:4003/api', - platform: 'web', - channel: 'pwa', - storage, - }); - - client.trackEvent('info', 'auth', 'login', { userId: 'user-123' }); - client.flush(); - - const body = JSON.parse((globalThis.fetch as ReturnType).mock.calls[0][1].body); - expect(body.events[0].userId).toBe('user-123'); - }); - }); -}); diff --git a/vendor/bytelyst/telemetry-client/src/client.ts b/vendor/bytelyst/telemetry-client/src/client.ts deleted file mode 100644 index 13f91e2..0000000 --- a/vendor/bytelyst/telemetry-client/src/client.ts +++ /dev/null @@ -1,236 +0,0 @@ -/** - * Browser/React Native-safe telemetry client for platform-service. - * - * Replaces hand-rolled telemetry clients in ChronoMind web, NomGap, and LysnrAI user-dashboard. - * No Node.js dependencies — uses globalThis.fetch and configurable storage. - * - * @example - * ```ts - * import { createTelemetryClient } from '@bytelyst/telemetry-client'; - * - * const telemetry = createTelemetryClient({ - * productId: 'chronomind', - * baseUrl: 'http://localhost:4003/api', - * platform: 'web', - * channel: 'pwa', - * transport: 'beacon', - * }); - * - * telemetry.init(); - * telemetry.trackEvent('info', 'timer', 'timer_created'); - * ``` - */ - -import type { - TelemetryClient, - TelemetryClientConfig, - TelemetryEvent, - TelemetryStorage, -} from './types.js'; - -// ── UUID helper (browser + RN safe) ────────────────────────────── - -function uuid(): string { - if (typeof globalThis.crypto?.randomUUID === 'function') { - return globalThis.crypto.randomUUID(); - } - return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { - const r = (Math.random() * 16) | 0; - return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16); - }); -} - -// ── Noop storage ───────────────────────────────────────────────── - -const noopStorage: TelemetryStorage = { - getItem: () => null, - setItem: () => {}, -}; - -function getDefaultStorage(): TelemetryStorage { - if ( - typeof globalThis.localStorage !== 'undefined' && - typeof globalThis.localStorage?.getItem === 'function' - ) { - return globalThis.localStorage; - } - return noopStorage; -} - -// ── Factory ────────────────────────────────────────────────────── - -export function createTelemetryClient(config: TelemetryClientConfig): TelemetryClient { - const { - productId, - baseUrl, - endpoint = '/telemetry/events', - platform, - channel, - transport = 'fetch', - maxQueue = 50, - flushIntervalMs = 30_000, - appVersion = '0.0.0', - buildNumber = '0', - releaseChannel = 'dev', - osFamily = 'other', - osVersion = '', - } = config; - - const storage = config.storage ?? getDefaultStorage(); - const INSTALL_KEY = `${productId}_telemetry_install_id`; - - let queue: TelemetryEvent[] = []; - let sessionId = ''; - let installId = ''; - let flushTimer: ReturnType | null = null; - - function getInstallId(): string { - if (installId) return installId; - const stored = storage.getItem(INSTALL_KEY); - if (stored) { - installId = stored; - return installId; - } - installId = uuid(); - storage.setItem(INSTALL_KEY, installId); - return installId; - } - - function getSessionId(): string { - return sessionId; - } - - function flushViaBeacon(): void { - if (queue.length === 0) return; - const events = [...queue]; - queue = []; - - const body = JSON.stringify({ productId, events }); - const url = `${baseUrl}${endpoint}`; - - try { - const sent = typeof navigator?.sendBeacon === 'function' && navigator.sendBeacon(url, body); - if (!sent) { - // Fallback to fetch - globalThis - .fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-product-id': productId, - 'x-request-id': uuid(), - }, - body, - keepalive: true, - }) - .catch(() => {}); - } - } catch { - // Silently ignore telemetry failures - } - } - - function flushViaFetch(): void { - if (queue.length === 0) return; - const events = [...queue]; - queue = []; - - const body = JSON.stringify({ productId, events }); - const url = `${baseUrl}${endpoint}`; - - globalThis - .fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-product-id': productId, - 'x-request-id': uuid(), - }, - body, - }) - .catch(() => {}); - } - - function flush(): void { - if (transport === 'beacon') { - flushViaBeacon(); - } else { - flushViaFetch(); - } - } - - function trackEvent( - eventType: string, - module: string, - eventName: string, - extra?: { - feature?: string; - message?: string; - tags?: Record; - metrics?: Record; - userId?: string; - } - ): void { - const event: TelemetryEvent = { - id: uuid(), - productId, - anonymousInstallId: getInstallId(), - sessionId, - platform, - channel, - osFamily, - osVersion, - appVersion, - buildNumber, - releaseChannel, - eventType, - module, - eventName, - ...extra, - occurredAt: new Date().toISOString(), - }; - - queue.push(event); - - if (queue.length >= maxQueue) { - flush(); - } - } - - function init(): void { - sessionId = uuid(); - getInstallId(); - - // Auto-flush on visibility change (web only) - if (typeof document !== 'undefined') { - document.addEventListener('visibilitychange', () => { - if (document.visibilityState === 'hidden') { - flush(); - } - }); - } - - // Periodic flush - if (flushTimer) clearInterval(flushTimer); - flushTimer = setInterval(flush, flushIntervalMs); - - trackEvent('info', 'app_lifecycle', 'session_started'); - } - - function shutdown(): void { - flush(); - if (flushTimer) { - clearInterval(flushTimer); - flushTimer = null; - } - } - - return { - init, - trackEvent, - flush, - shutdown, - getInstallId, - getSessionId, - }; -} diff --git a/vendor/bytelyst/telemetry-client/src/index.ts b/vendor/bytelyst/telemetry-client/src/index.ts deleted file mode 100644 index ee02c77..0000000 --- a/vendor/bytelyst/telemetry-client/src/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { createTelemetryClient } from './client.js'; -export { createWebTelemetry, type WebTelemetryConfig } from './web.js'; -export type { - TelemetryClient, - TelemetryClientConfig, - TelemetryEvent, - TelemetryStorage, -} from './types.js'; diff --git a/vendor/bytelyst/telemetry-client/src/types.ts b/vendor/bytelyst/telemetry-client/src/types.ts deleted file mode 100644 index 519b99c..0000000 --- a/vendor/bytelyst/telemetry-client/src/types.ts +++ /dev/null @@ -1,107 +0,0 @@ -/** - * Types for @bytelyst/telemetry-client. - * Browser/React Native-safe — no Node.js dependencies. - */ - -export interface TelemetryClientConfig { - /** Product identifier (e.g. 'chronomind', 'nomgap', 'lysnrai'). */ - productId: string; - - /** Platform-service base URL or telemetry ingest endpoint base. */ - baseUrl: string; - - /** Endpoint path appended to baseUrl. Default: '/telemetry/events'. */ - endpoint?: string; - - /** Platform identifier (e.g. 'web', 'mobile', 'desktop'). */ - platform: string; - - /** Channel identifier (e.g. 'pwa', 'react_native', 'web_app'). */ - channel: string; - - /** Transport: 'beacon' uses sendBeacon (web), 'fetch' uses fetch (RN/fallback). Default: 'fetch'. */ - transport?: 'beacon' | 'fetch'; - - /** Max events to queue before auto-flush. Default: 50. */ - maxQueue?: number; - - /** Flush interval in milliseconds. Default: 30000. */ - flushIntervalMs?: number; - - /** App version string. Default: '0.0.0'. */ - appVersion?: string; - - /** Build number. Default: '0'. */ - buildNumber?: string; - - /** Release channel. Default: 'dev'. */ - releaseChannel?: string; - - /** OS family. Default: 'other'. */ - osFamily?: string; - - /** OS version. Default: ''. */ - osVersion?: string; - - /** Storage adapter for install ID persistence. Uses localStorage by default. */ - storage?: TelemetryStorage; -} - -export interface TelemetryStorage { - getItem(key: string): string | null; - setItem(key: string, value: string): void; -} - -export interface TelemetryEvent { - id: string; - productId: string; - userId?: string; - anonymousInstallId: string; - sessionId: string; - platform: string; - channel: string; - osFamily: string; - osVersion: string; - appVersion: string; - buildNumber: string; - releaseChannel: string; - eventType: string; - module: string; - eventName: string; - feature?: string; - message?: string; - tags?: Record; - metrics?: Record; - occurredAt: string; -} - -export interface TelemetryClient { - /** Initialize the telemetry client and start periodic flushing. */ - init(): void; - - /** Track a telemetry event. */ - trackEvent( - eventType: string, - module: string, - eventName: string, - extra?: { - feature?: string; - message?: string; - tags?: Record; - metrics?: Record; - userId?: string; - } - ): void; - - /** Flush all queued events immediately. */ - flush(): void; - - /** Stop the periodic flush timer and flush remaining events. */ - shutdown(): void; - - /** Get the anonymous install ID. */ - getInstallId(): string; - - /** Get the current session ID. */ - getSessionId(): string; -} diff --git a/vendor/bytelyst/telemetry-client/src/web.ts b/vendor/bytelyst/telemetry-client/src/web.ts deleted file mode 100644 index 47468d9..0000000 --- a/vendor/bytelyst/telemetry-client/src/web.ts +++ /dev/null @@ -1,81 +0,0 @@ -/** - * Convenience factory for web dashboard telemetry. - * - * Eliminates ~30 lines of boilerplate per web app by wrapping - * createTelemetryClient() with sensible web defaults. - * - * @example - * ```ts - * import { createWebTelemetry } from '@bytelyst/telemetry-client'; - * - * const { client, init, trackPageView } = createWebTelemetry({ - * productId: 'nomgap', - * channel: 'nomgap_web', - * }); - * export { client as telemetryClient, init as initTelemetry, trackPageView }; - * ``` - */ - -import { createTelemetryClient } from './client.js'; -import type { TelemetryClient } from './types.js'; - -export interface WebTelemetryConfig { - /** Product identifier (e.g. 'nomgap', 'chronomind'). */ - productId: string; - /** Channel identifier (e.g. 'nomgap_web', 'pwa'). */ - channel: string; - /** Platform-service base URL. Default: 'http://localhost:4003/api'. */ - baseUrl?: string; - /** Telemetry ingest endpoint path. Default: '/telemetry/events'. */ - endpoint?: string; - /** Transport: 'beacon' or 'fetch'. Default: 'fetch'. */ - transport?: 'beacon' | 'fetch'; - /** App version string. Default: '0.1.0'. */ - appVersion?: string; - /** Build number. Default: '1'. */ - buildNumber?: string; - /** Release channel. Default: 'dev'. */ - releaseChannel?: string; - /** OS family. Default: 'other'. */ - osFamily?: string; -} - -export interface WebTelemetry { - /** The underlying telemetry client instance. */ - client: TelemetryClient; - /** Initialize telemetry and track app_initialized event. Idempotent. */ - init(): TelemetryClient; - /** Track a page view event. */ - trackPageView(page: string): void; -} - -export function createWebTelemetry(config: WebTelemetryConfig): WebTelemetry { - let initialized = false; - - const client = createTelemetryClient({ - productId: config.productId, - baseUrl: config.baseUrl ?? 'http://localhost:4003/api', - endpoint: config.endpoint ?? '/telemetry/events', - platform: 'web', - channel: config.channel, - transport: config.transport ?? 'fetch', - appVersion: config.appVersion ?? '0.1.0', - buildNumber: config.buildNumber ?? '1', - releaseChannel: config.releaseChannel ?? 'dev', - osFamily: config.osFamily ?? 'other', - }); - - function init(): TelemetryClient { - if (initialized) return client; - client.init(); - client.trackEvent('info', 'app_shell', 'web_app_initialized'); - initialized = true; - return client; - } - - function trackPageView(page: string): void { - client.trackEvent('info', 'navigation', 'page_view', { feature: page }); - } - - return { client, init, trackPageView }; -} diff --git a/vendor/bytelyst/telemetry-client/tsconfig.json b/vendor/bytelyst/telemetry-client/tsconfig.json deleted file mode 100644 index 318c075..0000000 --- a/vendor/bytelyst/telemetry-client/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src", - "lib": ["ES2022", "DOM"] - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/vendor/bytelyst/testing/package.json b/vendor/bytelyst/testing/package.json deleted file mode 100644 index 0e819bc..0000000 --- a/vendor/bytelyst/testing/package.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "name": "@bytelyst/testing", - "version": "0.1.5", - "type": "module", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "pretest": "pnpm --dir ../.. --filter @bytelyst/fastify-core build", - "build": "tsc", - "test": "vitest run --pool forks" - }, - "devDependencies": { - "@bytelyst/fastify-core": "workspace:*", - "fastify": "^5.2.1" - }, - "peerDependencies": { - "vitest": ">=3.0.0", - "fastify": ">=5.0.0" - }, - "peerDependenciesMeta": { - "fastify": { - "optional": true - } - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/testing/src/__tests__/testing.test.ts b/vendor/bytelyst/testing/src/__tests__/testing.test.ts deleted file mode 100644 index 4112315..0000000 --- a/vendor/bytelyst/testing/src/__tests__/testing.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - createCosmosMocks, - TEST_JWT_SECRET, - TEST_USERS, - createTestTokenPayload, - injectGet, - expectHealthOk, -} from '../index.js'; -import { createServiceApp } from '@bytelyst/fastify-core'; - -describe('createCosmosMocks', () => { - it('returns all mock objects', () => { - const mocks = createCosmosMocks(); - expect(mocks.mockContainer).toBeDefined(); - expect(mocks.mockContainer.id).toBe('test-container'); - expect(mocks.mockDatabase).toBeDefined(); - expect(mocks.mockDatabases).toBeDefined(); - expect(mocks.MockCosmosClient).toBeDefined(); - expect(typeof mocks.resetMocks).toBe('function'); - }); - - it('MockCosmosClient returns database', () => { - const { MockCosmosClient, mockDatabase } = createCosmosMocks(); - const client = new MockCosmosClient('endpoint', 'key'); - expect(client.database('test-db')).toBe(mockDatabase); - }); - - it('mockContainer.items has CRUD methods', () => { - const { mockContainer } = createCosmosMocks(); - expect(typeof mockContainer.items.create).toBe('function'); - expect(typeof mockContainer.items.query).toBe('function'); - expect(typeof mockContainer.items.upsert).toBe('function'); - expect(typeof mockContainer.item).toBe('function'); - }); - - it('resetMocks clears all mock state', () => { - const { mockContainer, resetMocks } = createCosmosMocks(); - mockContainer.items.create({ id: '1' }); - expect(mockContainer.items.create).toHaveBeenCalledOnce(); - resetMocks(); - expect(mockContainer.items.create).not.toHaveBeenCalled(); - }); -}); - -describe('auth fixtures', () => { - it('TEST_JWT_SECRET is a non-empty string', () => { - expect(TEST_JWT_SECRET).toBeTruthy(); - expect(TEST_JWT_SECRET.length).toBeGreaterThanOrEqual(16); - }); - - it('TEST_USERS has admin, viewer, and user', () => { - expect(TEST_USERS.admin.email).toBe('admin@test.com'); - expect(TEST_USERS.viewer.role).toBe('viewer'); - expect(TEST_USERS.user.productId).toBe('lysnrai'); - }); - - it('createTestTokenPayload returns valid shape', () => { - const payload = createTestTokenPayload('admin'); - expect(payload.sub).toBe('user-admin-001'); - expect(payload.email).toBe('admin@test.com'); - expect(payload.role).toBe('super_admin'); - expect(payload.iss).toBe('test'); - expect(payload.exp).toBeGreaterThan(payload.iat); - }); - - it('createTestTokenPayload defaults to admin', () => { - const payload = createTestTokenPayload(); - expect(payload.sub).toBe('user-admin-001'); - }); -}); - -describe('fastify helpers', () => { - it('injectGet + expectHealthOk work with a real fastify-core app', async () => { - const app = await createServiceApp({ - name: 'test-svc', - version: '1.0.0', - logger: false, - }); - - const res = await injectGet(app, '/health'); - expect(res.statusCode).toBe(200); - expectHealthOk(res, 'test-svc'); - - await app.close(); - }); - - it('expectHealthOk throws on wrong service name', async () => { - const app = await createServiceApp({ - name: 'real-svc', - version: '1.0.0', - logger: false, - }); - - const res = await injectGet(app, '/health'); - expect(() => expectHealthOk(res, 'wrong-name')).toThrow('Expected service "wrong-name"'); - - await app.close(); - }); -}); diff --git a/vendor/bytelyst/testing/src/auth-fixtures.ts b/vendor/bytelyst/testing/src/auth-fixtures.ts deleted file mode 100644 index aaa94ad..0000000 --- a/vendor/bytelyst/testing/src/auth-fixtures.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * Auth test fixtures — JWT tokens, user payloads, and password helpers. - * - * Usage: - * ```ts - * import { TEST_USERS, TEST_JWT_SECRET, createTestToken } from '@bytelyst/testing'; - * ``` - */ - -/** Test JWT secret — NEVER use in production */ -export const TEST_JWT_SECRET = 'test-jwt-secret-32-chars-long!!'; - -/** Pre-built test user payloads */ -export const TEST_USERS = { - admin: { - id: 'user-admin-001', - email: 'admin@test.com', - name: 'Test Admin', - role: 'super_admin', - productId: 'lysnrai', - }, - viewer: { - id: 'user-viewer-001', - email: 'viewer@test.com', - name: 'Test Viewer', - role: 'viewer', - productId: 'lysnrai', - }, - user: { - id: 'user-basic-001', - email: 'user@test.com', - name: 'Test User', - role: 'user', - productId: 'lysnrai', - }, -} as const; - -export type TestUserKey = keyof typeof TEST_USERS; - -/** - * Create a minimal JWT-like token payload for testing (not cryptographically signed). - * For actual JWT creation, use `@bytelyst/auth` createJwtUtils(). - */ -export function createTestTokenPayload(userKey: TestUserKey = 'admin') { - const user = TEST_USERS[userKey]; - return { - sub: user.id, - email: user.email, - role: user.role, - productId: user.productId, - iss: 'test', - iat: Math.floor(Date.now() / 1000), - exp: Math.floor(Date.now() / 1000) + 3600, - }; -} diff --git a/vendor/bytelyst/testing/src/cosmos-mocks.ts b/vendor/bytelyst/testing/src/cosmos-mocks.ts deleted file mode 100644 index 86dd3f3..0000000 --- a/vendor/bytelyst/testing/src/cosmos-mocks.ts +++ /dev/null @@ -1,87 +0,0 @@ -/** - * Shared Cosmos DB mock factories for Vitest. - * - * Usage: - * ```ts - * import { createCosmosMocks } from '@bytelyst/testing'; - * const { mockContainer, mockDatabase, MockCosmosClient, resetMocks } = createCosmosMocks(); - * vi.mock('@azure/cosmos', () => ({ CosmosClient: MockCosmosClient })); - * ``` - */ - -import { vi } from 'vitest'; - -export interface MockItem { - id: string; - [key: string]: unknown; -} - -export interface CosmosMocks { - mockContainer: { - id: string; - items: { - create: ReturnType; - query: ReturnType; - upsert: ReturnType; - }; - item: ReturnType; - }; - mockDatabase: { - container: ReturnType; - containers: { createIfNotExists: ReturnType }; - }; - mockDatabases: { - createIfNotExists: ReturnType; - }; - MockCosmosClient: ReturnType; - resetMocks: () => void; -} - -/** - * Create a full set of Cosmos DB mocks for unit testing. - * - * Returns mock objects for container, database, and the CosmosClient constructor, - * plus a `resetMocks()` function to clear all mock state between tests. - */ -export function createCosmosMocks(): CosmosMocks { - const mockItem = { - read: vi.fn().mockResolvedValue({ resource: undefined }), - replace: vi.fn().mockResolvedValue({ resource: undefined }), - delete: vi.fn().mockResolvedValue({}), - patch: vi.fn().mockResolvedValue({ resource: undefined }), - }; - - const mockContainer = { - id: 'test-container', - items: { - create: vi.fn().mockResolvedValue({ resource: {} }), - query: vi.fn().mockReturnValue({ - fetchAll: vi.fn().mockResolvedValue({ resources: [] }), - }), - upsert: vi.fn().mockResolvedValue({ resource: {} }), - }, - item: vi.fn().mockReturnValue(mockItem), - }; - - const mockDatabase = { - container: vi.fn().mockReturnValue(mockContainer), - containers: { - createIfNotExists: vi.fn().mockResolvedValue({ container: mockContainer }), - }, - }; - - const mockDatabases = { - createIfNotExists: vi.fn().mockResolvedValue({ database: mockDatabase }), - }; - - const MockCosmosClient = vi.fn().mockImplementation(() => ({ - database: vi.fn().mockReturnValue(mockDatabase), - databases: mockDatabases, - })); - - function resetMocks() { - vi.clearAllMocks(); - } - - return { mockContainer, mockDatabase, mockDatabases, MockCosmosClient, resetMocks }; -} diff --git a/vendor/bytelyst/testing/src/fastify-helpers.ts b/vendor/bytelyst/testing/src/fastify-helpers.ts deleted file mode 100644 index 6673e55..0000000 --- a/vendor/bytelyst/testing/src/fastify-helpers.ts +++ /dev/null @@ -1,80 +0,0 @@ -/** - * Fastify test helpers — inject wrappers and assertion utilities. - * - * Usage: - * ```ts - * import { injectGet, injectPost, expectHealthOk } from '@bytelyst/testing'; - * const res = await injectGet(app, '/health'); - * expectHealthOk(res, 'platform-service'); - * ``` - */ - -import type { FastifyInstance } from 'fastify'; - -interface InjectResult { - statusCode: number; - payload: string; - headers: Record; - json: () => unknown; -} - -/** Inject a GET request into a Fastify instance */ -export async function injectGet( - app: FastifyInstance, - url: string, - headers?: Record -): Promise { - return app.inject({ method: 'GET', url, headers }) as unknown as Promise; -} - -/** Inject a POST request with JSON body into a Fastify instance */ -export async function injectPost( - app: FastifyInstance, - url: string, - body: unknown, - headers?: Record -): Promise { - return app.inject({ - method: 'POST', - url, - payload: body as string, - headers: { 'content-type': 'application/json', ...headers }, - }) as unknown as Promise; -} - -/** Inject a PATCH request with JSON body into a Fastify instance */ -export async function injectPatch( - app: FastifyInstance, - url: string, - body: unknown, - headers?: Record -): Promise { - return app.inject({ - method: 'PATCH', - url, - payload: body as string, - headers: { 'content-type': 'application/json', ...headers }, - }) as unknown as Promise; -} - -/** Inject a DELETE request into a Fastify instance */ -export async function injectDelete( - app: FastifyInstance, - url: string, - headers?: Record -): Promise { - return app.inject({ method: 'DELETE', url, headers }) as unknown as Promise; -} - -/** Assert a health check response has the correct shape */ -export function expectHealthOk(res: InjectResult, serviceName: string): void { - const body = JSON.parse(res.payload); - if (res.statusCode !== 200) throw new Error(`Expected 200, got ${res.statusCode}`); - if (body.status !== 'ok') throw new Error(`Expected status "ok", got "${body.status}"`); - if (body.service !== serviceName) - throw new Error(`Expected service "${serviceName}", got "${body.service}"`); - if (!body.timestamp) throw new Error('Missing timestamp in health response'); - if (!body.requestId) throw new Error('Missing requestId in health response'); -} - -export type { InjectResult }; diff --git a/vendor/bytelyst/testing/src/index.ts b/vendor/bytelyst/testing/src/index.ts deleted file mode 100644 index d7fcb05..0000000 --- a/vendor/bytelyst/testing/src/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -export { createCosmosMocks, type CosmosMocks, type MockItem } from './cosmos-mocks.js'; -export { - TEST_JWT_SECRET, - TEST_USERS, - createTestTokenPayload, - type TestUserKey, -} from './auth-fixtures.js'; -export { - injectGet, - injectPost, - injectPatch, - injectDelete, - expectHealthOk, - type InjectResult, -} from './fastify-helpers.js'; diff --git a/vendor/bytelyst/testing/tsconfig.json b/vendor/bytelyst/testing/tsconfig.json deleted file mode 100644 index c17685d..0000000 --- a/vendor/bytelyst/testing/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "src/**/*.test.ts"] -} diff --git a/vendor/bytelyst/time-references/package.json b/vendor/bytelyst/time-references/package.json deleted file mode 100644 index 78f132a..0000000 --- a/vendor/bytelyst/time-references/package.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "@bytelyst/time-references", - "version": "0.1.5", - "type": "module", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "scripts": { - "build": "tsc" - }, - "devDependencies": { - "typescript": "^5.7.3" - } -} diff --git a/vendor/bytelyst/time-references/src/client.test.ts b/vendor/bytelyst/time-references/src/client.test.ts deleted file mode 100644 index db45379..0000000 --- a/vendor/bytelyst/time-references/src/client.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { describe, it, expect, afterEach } from 'vitest'; -import { - getTimeReference, - getEpisodeComparison, - getEncouragingMessage, - registerReferences, - clearCustomReferences, -} from './client.js'; - -describe('getTimeReference', () => { - it('should return a reference for short duration', () => { - const ref = getTimeReference(0.1); - expect(ref.text.length).toBeGreaterThan(0); - expect(ref.emoji.length).toBeGreaterThan(0); - expect(['media', 'activity', 'travel', 'nature']).toContain(ref.category); - }); - - it('should return a reference for medium duration', () => { - const ref = getTimeReference(1.5); - expect(ref.text.length).toBeGreaterThan(0); - }); - - it('should return a reference for long duration', () => { - const ref = getTimeReference(16); - expect(ref.text.length).toBeGreaterThan(0); - }); - - it('should return a reference for very long duration', () => { - const ref = getTimeReference(72); - expect(ref.text.length).toBeGreaterThan(0); - }); - - it('should handle zero hours', () => { - const ref = getTimeReference(0); - expect(ref.text.length).toBeGreaterThan(0); - }); - - it('should handle negative as zero', () => { - const ref = getTimeReference(-5); - expect(ref.text.length).toBeGreaterThan(0); - }); -}); - -describe('getEpisodeComparison', () => { - it('should return episode count for default show', () => { - const result = getEpisodeComparison(1); - expect(result).toContain('The Office'); - expect(result).toContain('episodes'); - }); - - it('should return custom show name', () => { - const result = getEpisodeComparison(2, 'Friends', 25); - expect(result).toContain('Friends'); - }); - - it('should handle less than one episode', () => { - const result = getEpisodeComparison(0.1); - expect(result).toContain('Less than one episode'); - }); - - it('should handle exactly one episode', () => { - const result = getEpisodeComparison(22 / 60); - expect(result).toContain('1 episode'); - }); -}); - -describe('getEncouragingMessage', () => { - it('should return message for each time bracket', () => { - expect(getEncouragingMessage(0.5).length).toBeGreaterThan(0); - expect(getEncouragingMessage(2).length).toBeGreaterThan(0); - expect(getEncouragingMessage(6).length).toBeGreaterThan(0); - expect(getEncouragingMessage(12).length).toBeGreaterThan(0); - expect(getEncouragingMessage(20).length).toBeGreaterThan(0); - expect(getEncouragingMessage(30).length).toBeGreaterThan(0); - }); -}); - -describe('registerReferences', () => { - afterEach(() => { - clearCustomReferences(); - }); - - it('should allow custom references', () => { - registerReferences([ - { - minHours: 0, - maxHours: 0.1, - references: [{ text: 'Custom micro reference', emoji: '⚡', category: 'activity' }], - }, - ]); - const ref = getTimeReference(0.05); - expect(ref.text).toBe('Custom micro reference'); - }); - - it('should clear custom references', () => { - registerReferences([ - { - minHours: 0, - maxHours: 0.1, - references: [{ text: 'Will be cleared', emoji: '🧹', category: 'activity' }], - }, - ]); - clearCustomReferences(); - const ref = getTimeReference(0.05); - expect(ref.text).not.toBe('Will be cleared'); - }); -}); diff --git a/vendor/bytelyst/time-references/src/client.ts b/vendor/bytelyst/time-references/src/client.ts deleted file mode 100644 index af153b7..0000000 --- a/vendor/bytelyst/time-references/src/client.ts +++ /dev/null @@ -1,159 +0,0 @@ -/** - * Familiar duration references for time-blindness aids. - * - * "About as long as a movie", "3 episodes of The Office". - * Pure client-side TS — no backend dependency. - */ - -import type { TimeRangeEntry, TimeReference } from './types.js'; - -const DEFAULT_DATABASE: TimeRangeEntry[] = [ - { - minHours: 0, - maxHours: 0.25, - references: [ - { text: 'About as long as a coffee break', emoji: '☕', category: 'activity' }, - { text: 'Like listening to a few songs', emoji: '🎵', category: 'media' }, - ], - }, - { - minHours: 0.25, - maxHours: 0.5, - references: [ - { text: 'About as long as a TV episode', emoji: '📺', category: 'media' }, - { text: 'Like a short walk around the block', emoji: '🚶', category: 'activity' }, - ], - }, - { - minHours: 0.5, - maxHours: 1, - references: [ - { text: 'About as long as a yoga class', emoji: '🧘', category: 'activity' }, - { text: 'Like watching 2 episodes of The Office', emoji: '📺', category: 'media' }, - ], - }, - { - minHours: 1, - maxHours: 2, - references: [ - { text: 'About as long as a movie', emoji: '🎬', category: 'media' }, - { text: 'Like a nice bike ride', emoji: '🚴', category: 'activity' }, - ], - }, - { - minHours: 2, - maxHours: 4, - references: [ - { text: 'About as long as a road trip to the next city', emoji: '🚗', category: 'travel' }, - { text: 'Like binge-watching a short series', emoji: '📺', category: 'media' }, - ], - }, - { - minHours: 4, - maxHours: 8, - references: [ - { text: 'About as long as a work day', emoji: '💼', category: 'activity' }, - { text: 'Like a full night of sleep', emoji: '😴', category: 'nature' }, - ], - }, - { - minHours: 8, - maxHours: 12, - references: [ - { text: 'About as long as a cross-country flight', emoji: '✈️', category: 'travel' }, - { text: 'Like sunrise to sunset in winter', emoji: '🌅', category: 'nature' }, - ], - }, - { - minHours: 12, - maxHours: 16, - references: [ - { text: 'About as long as daylight hours in summer', emoji: '☀️', category: 'nature' }, - { - text: 'Like watching the entire Lord of the Rings trilogy (extended)', - emoji: '🧙', - category: 'media', - }, - ], - }, - { - minHours: 16, - maxHours: 24, - references: [ - { text: 'Almost a full day — impressive!', emoji: '🌍', category: 'nature' }, - { text: 'Like a full day of hiking', emoji: '🥾', category: 'activity' }, - ], - }, - { - minHours: 24, - maxHours: 48, - references: [ - { text: 'More than a full day!', emoji: '🏆', category: 'nature' }, - { text: 'Like a weekend camping trip', emoji: '⛺', category: 'activity' }, - ], - }, - { - minHours: 48, - maxHours: Infinity, - references: [ - { text: 'An extraordinary duration — you are incredible!', emoji: '🌟', category: 'nature' }, - ], - }, -]; - -const FALLBACK: TimeReference = { - text: 'A meaningful amount of time', - emoji: '⏳', - category: 'nature', -}; - -const customRegistry: TimeRangeEntry[] = []; - -function findReferences(hours: number, custom: TimeRangeEntry[]): TimeReference[] { - for (const entry of custom) { - if (hours >= entry.minHours && hours < entry.maxHours) { - return entry.references; - } - } - for (const entry of DEFAULT_DATABASE) { - if (hours >= entry.minHours && hours < entry.maxHours) { - return entry.references; - } - } - return [FALLBACK]; -} - -export function getTimeReference(elapsedHours: number): TimeReference { - const refs = findReferences(Math.max(0, elapsedHours), customRegistry); - const index = Math.floor(Math.random() * refs.length); - return refs[index]; -} - -export function getEpisodeComparison( - elapsedHours: number, - showName = 'The Office', - episodeMins = 22 -): string { - const totalMins = elapsedHours * 60; - const episodes = Math.round(totalMins / episodeMins); - if (episodes <= 0) return `Less than one episode of ${showName}`; - if (episodes === 1) return `About 1 episode of ${showName}`; - return `About ${episodes} episodes of ${showName}`; -} - -export function getEncouragingMessage(elapsedHours: number): string { - if (elapsedHours < 1) return 'Every minute counts — great start!'; - if (elapsedHours < 4) return 'You are building momentum — keep going!'; - if (elapsedHours < 8) return 'Impressive dedication — halfway through the day!'; - if (elapsedHours < 16) return 'Amazing endurance — you are a champion!'; - if (elapsedHours < 24) return 'Nearly a full day — extraordinary commitment!'; - return 'Beyond a full day — you are truly remarkable!'; -} - -export function registerReferences(entries: TimeRangeEntry[]): void { - customRegistry.push(...entries); -} - -export function clearCustomReferences(): void { - customRegistry.length = 0; -} diff --git a/vendor/bytelyst/time-references/src/index.ts b/vendor/bytelyst/time-references/src/index.ts deleted file mode 100644 index 56d383e..0000000 --- a/vendor/bytelyst/time-references/src/index.ts +++ /dev/null @@ -1,54 +0,0 @@ -export interface TimeReference { - emoji: string; - text: string; -} - -export function getTimeReference(hours: number): TimeReference { - if (hours < 1) { - return { emoji: "⏱️", text: "A quick meditation" }; - } - if (hours < 4) { - return { emoji: "🎬", text: "A movie marathon" }; - } - if (hours < 8) { - return { emoji: "✈️", text: "A cross-country flight" }; - } - if (hours < 12) { - return { emoji: "🌙", text: "A full night's sleep" }; - } - if (hours < 16) { - return { emoji: "🏔️", text: "A day hike" }; - } - if (hours < 24) { - return { emoji: "🌍", text: "A day trip abroad" }; - } - if (hours < 36) { - return { emoji: "🚂", text: "A train across the country" }; - } - if (hours < 48) { - return { emoji: "⛵", text: "A weekend sailing trip" }; - } - return { emoji: "🏕️", text: "A multi-day adventure" }; -} - -export function getEncouragingMessage(hours: number): string { - if (hours < 4) { - return "Great start! Your body is beginning to adjust."; - } - if (hours < 8) { - return "You're doing well. Insulin is dropping."; - } - if (hours < 12) { - return "Halfway through a standard fast. Fat burning is ramping up!"; - } - if (hours < 16) { - return "You've passed 12 hours. Autophagy is beginning."; - } - if (hours < 24) { - return "Deep into your fast. Your body is thriving."; - } - if (hours < 36) { - return "Incredible discipline! Growth hormone is surging."; - } - return "Extraordinary commitment. Your body is deeply healing."; -} diff --git a/vendor/bytelyst/time-references/src/types.ts b/vendor/bytelyst/time-references/src/types.ts deleted file mode 100644 index 3295480..0000000 --- a/vendor/bytelyst/time-references/src/types.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Types for @bytelyst/time-references. - * Pure client-side TS — no backend dependency. - */ - -export interface TimeReference { - text: string; - emoji: string; - category: 'media' | 'activity' | 'travel' | 'nature'; -} - -export interface TimeRangeEntry { - minHours: number; - maxHours: number; - references: TimeReference[]; -} diff --git a/vendor/bytelyst/time-references/tsconfig.json b/vendor/bytelyst/time-references/tsconfig.json deleted file mode 100644 index 8c5e8c2..0000000 --- a/vendor/bytelyst/time-references/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "outDir": "dist", - "rootDir": "src", - "declaration": true, - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true - }, - "include": ["src"] -} diff --git a/vendor/bytelyst/ui/.storybook/main.ts b/vendor/bytelyst/ui/.storybook/main.ts deleted file mode 100644 index a01fb35..0000000 --- a/vendor/bytelyst/ui/.storybook/main.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { StorybookConfig } from '@storybook/react-vite'; - -const config: StorybookConfig = { - stories: ['../src/**/*.stories.@(ts|tsx)'], - addons: ['@storybook/addon-essentials', '@storybook/addon-a11y'], - framework: { - name: '@storybook/react-vite', - options: {}, - }, -}; - -export default config; diff --git a/vendor/bytelyst/ui/.storybook/preview.ts b/vendor/bytelyst/ui/.storybook/preview.ts deleted file mode 100644 index f7c4aa2..0000000 --- a/vendor/bytelyst/ui/.storybook/preview.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { Preview } from '@storybook/react'; - -const preview: Preview = { - parameters: { - backgrounds: { - default: 'dark', - values: [ - { name: 'dark', value: '#06070A' }, - { name: 'elevated', value: '#0E1118' }, - { name: 'light', value: '#F8F9FC' }, - ], - }, - }, -}; - -export default preview; diff --git a/vendor/bytelyst/ui/README.md b/vendor/bytelyst/ui/README.md deleted file mode 100644 index 2437fda..0000000 --- a/vendor/bytelyst/ui/README.md +++ /dev/null @@ -1,239 +0,0 @@ -# @bytelyst/ui - -Shared component library for the ByteLyst ecosystem. Built with Radix UI primitives, Lucide icons, and CSS custom properties from `@bytelyst/design-tokens`. - -## Install - -```bash -pnpm add @bytelyst/ui -``` - -Peer dependencies: `react`, `react-dom`. - -## Components (15) - -### Button - -5 variants (`primary`, `secondary`, `ghost`, `destructive`, `outline`), 3 sizes, loading state with spinner. - -```tsx -import { Button } from '@bytelyst/ui'; - - - - -``` - -### Input - -Text input with error/success states. Supports `aria-invalid` automatically when `error` is truthy. - -```tsx -import { Input } from '@bytelyst/ui'; - - - -``` - -### Textarea - -Auto-resize textarea with error states. - -```tsx -import { Textarea } from '@bytelyst/ui'; - -