Repository: cartesiancs/vessel Branch: main Commit: eb91955c98e1 Files: 575 Total size: 1.8 MB Directory structure: gitextract_1sirbdjr/ ├── .cargo/ │ └── config.toml ├── .github/ │ ├── GIT.md │ └── workflows/ │ ├── build.yml │ └── release-macos.yml ├── .gitignore ├── .prettierrc ├── CODE_OF_CONDUCT.md ├── CODE_RULE.md ├── CONTRIBUTING.md ├── Cargo.toml ├── Dockerfile ├── LICENSE ├── README.md ├── SECURITY.md ├── apps/ │ ├── capsule/ │ │ ├── Cargo.toml │ │ ├── Dockerfile │ │ ├── docker-compose.yml │ │ ├── src/ │ │ │ ├── api/ │ │ │ │ ├── auth.rs │ │ │ │ ├── chat.rs │ │ │ │ ├── key.rs │ │ │ │ ├── mod.rs │ │ │ │ └── routes.rs │ │ │ ├── config.rs │ │ │ ├── crypto/ │ │ │ │ ├── encryption.rs │ │ │ │ ├── keypair.rs │ │ │ │ └── mod.rs │ │ │ ├── error.rs │ │ │ ├── main.rs │ │ │ ├── services/ │ │ │ │ ├── jwt.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── openai.rs │ │ │ │ └── usage.rs │ │ │ └── types/ │ │ │ ├── decrypted.rs │ │ │ ├── encrypted.rs │ │ │ └── mod.rs │ │ └── tests/ │ │ ├── api.test.mjs │ │ ├── crypto.mjs │ │ └── package.json │ ├── client/ │ │ ├── .gitignore │ │ ├── README.md │ │ ├── components.json │ │ ├── eslint.config.js │ │ ├── index.html │ │ ├── package.json │ │ ├── src/ │ │ │ ├── App.tsx │ │ │ ├── app/ │ │ │ │ ├── pageWrapper/ │ │ │ │ │ └── page-wrapper.tsx │ │ │ │ └── providers/ │ │ │ │ └── theme-provider.tsx │ │ │ ├── components/ │ │ │ │ ├── icon/ │ │ │ │ │ └── Logo.tsx │ │ │ │ └── ui/ │ │ │ │ ├── alert-dialog.tsx │ │ │ │ ├── alert.tsx │ │ │ │ ├── avatar.tsx │ │ │ │ ├── badge.tsx │ │ │ │ ├── breadcrumb.tsx │ │ │ │ ├── button.tsx │ │ │ │ ├── card.tsx │ │ │ │ ├── command.tsx │ │ │ │ ├── context-menu.tsx │ │ │ │ ├── dialog.tsx │ │ │ │ ├── dropdown-menu.tsx │ │ │ │ ├── input.tsx │ │ │ │ ├── label.tsx │ │ │ │ ├── navigation-menu.tsx │ │ │ │ ├── popover.tsx │ │ │ │ ├── resizable.tsx │ │ │ │ ├── scroll-area.tsx │ │ │ │ ├── select.tsx │ │ │ │ ├── separator.tsx │ │ │ │ ├── sheet.tsx │ │ │ │ ├── sidebar.tsx │ │ │ │ ├── skeleton.tsx │ │ │ │ ├── sonner.tsx │ │ │ │ ├── switch.tsx │ │ │ │ ├── table.tsx │ │ │ │ ├── tabs.tsx │ │ │ │ ├── textarea.tsx │ │ │ │ └── tooltip.tsx │ │ │ ├── contexts/ │ │ │ │ └── SupabaseAuthContext.tsx │ │ │ ├── entities/ │ │ │ │ ├── configurations/ │ │ │ │ │ ├── api.ts │ │ │ │ │ ├── codeService.ts │ │ │ │ │ ├── store.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── custom-nodes/ │ │ │ │ │ ├── api.ts │ │ │ │ │ ├── presets.ts │ │ │ │ │ ├── store.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── device/ │ │ │ │ │ ├── api.ts │ │ │ │ │ ├── store.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── device-token/ │ │ │ │ │ ├── api.ts │ │ │ │ │ ├── store.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── dynamic-dashboard/ │ │ │ │ │ ├── api.ts │ │ │ │ │ ├── interaction.ts │ │ │ │ │ ├── layoutResolve.ts │ │ │ │ │ └── store.ts │ │ │ │ ├── entity/ │ │ │ │ │ ├── api.ts │ │ │ │ │ ├── store.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── file/ │ │ │ │ │ ├── api.ts │ │ │ │ │ ├── store.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── flow/ │ │ │ │ │ ├── api.ts │ │ │ │ │ ├── store.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── ha/ │ │ │ │ │ ├── api.ts │ │ │ │ │ ├── store.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── integrations/ │ │ │ │ │ ├── api.ts │ │ │ │ │ ├── store.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── log/ │ │ │ │ │ ├── api.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── map/ │ │ │ │ │ ├── api.ts │ │ │ │ │ ├── store.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── permission/ │ │ │ │ │ ├── api.ts │ │ │ │ │ ├── store.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── recording/ │ │ │ │ │ ├── api.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── store.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── role/ │ │ │ │ │ ├── api.ts │ │ │ │ │ ├── store.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── stat/ │ │ │ │ │ ├── api.ts │ │ │ │ │ ├── store.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── tunnel/ │ │ │ │ │ ├── api.ts │ │ │ │ │ ├── store.ts │ │ │ │ │ └── types.ts │ │ │ │ └── user/ │ │ │ │ ├── api.ts │ │ │ │ ├── store.ts │ │ │ │ └── types.ts │ │ │ ├── features/ │ │ │ │ ├── account-switcher/ │ │ │ │ │ └── index.tsx │ │ │ │ ├── auth/ │ │ │ │ │ ├── AuthInterceptor.tsx │ │ │ │ │ ├── DefaultAdminPasswordDialog.tsx │ │ │ │ │ ├── api.ts │ │ │ │ │ ├── hook.ts │ │ │ │ │ └── index.tsx │ │ │ │ ├── code/ │ │ │ │ │ ├── CreateItemDialog.tsx │ │ │ │ │ ├── FileEditor.tsx │ │ │ │ │ └── FileTree.tsx │ │ │ │ ├── configurations/ │ │ │ │ │ ├── ConfigurationActionButton.tsx │ │ │ │ │ ├── ConfigurationCreate.tsx │ │ │ │ │ └── ConfigurationCreateButton.tsx │ │ │ │ ├── darkmode/ │ │ │ │ │ └── mode-toggle.tsx │ │ │ │ ├── dashboard-swipe/ │ │ │ │ │ ├── DashboardSwipeHeader.tsx │ │ │ │ │ └── DashboardSwipeLayout.tsx │ │ │ │ ├── device/ │ │ │ │ │ ├── DeviceCreateButton.tsx │ │ │ │ │ ├── DeviceDeleteButton.tsx │ │ │ │ │ ├── DeviceKeyButton.tsx │ │ │ │ │ └── DeviceUpdateButton.tsx │ │ │ │ ├── device-token/ │ │ │ │ │ └── DeviceTokenManager.tsx │ │ │ │ ├── dynamic-dashboard/ │ │ │ │ │ ├── GroupCanvas.tsx │ │ │ │ │ ├── events/ │ │ │ │ │ │ ├── dispatcher.test.ts │ │ │ │ │ │ └── dispatcher.ts │ │ │ │ │ └── panels/ │ │ │ │ │ ├── ButtonPanel.tsx │ │ │ │ │ ├── FlowPanel.tsx │ │ │ │ │ └── MapPanel.tsx │ │ │ │ ├── entity/ │ │ │ │ │ ├── AllEntities.tsx │ │ │ │ │ ├── AnalyzeMenuItem.tsx │ │ │ │ │ ├── Card.tsx │ │ │ │ │ ├── EntityCreateButton.tsx │ │ │ │ │ ├── EntityDeleteButton.tsx │ │ │ │ │ ├── EntityUpdateButton.tsx │ │ │ │ │ ├── SelectPlatforms.tsx │ │ │ │ │ ├── SelectTypes.tsx │ │ │ │ │ ├── StateHistorySheet.tsx │ │ │ │ │ └── useEntitiesData.ts │ │ │ │ ├── error/ │ │ │ │ │ └── index.tsx │ │ │ │ ├── flow/ │ │ │ │ │ ├── AddCustomNode.tsx │ │ │ │ │ ├── Flow.tsx │ │ │ │ │ ├── Graph.tsx │ │ │ │ │ ├── Options.tsx │ │ │ │ │ ├── OptionsVariation.tsx │ │ │ │ │ ├── RunFlow.tsx │ │ │ │ │ ├── SelectedItemActions.tsx │ │ │ │ │ ├── flow-chat/ │ │ │ │ │ │ ├── buildSystemPrompt.ts │ │ │ │ │ │ ├── executeToolCalls.ts │ │ │ │ │ │ ├── flowTools.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── flowNode.ts │ │ │ │ │ ├── flowTypes.ts │ │ │ │ │ ├── flowUtils.ts │ │ │ │ │ └── nodes/ │ │ │ │ │ ├── ButtonNode.tsx │ │ │ │ │ ├── CalcNode.tsx │ │ │ │ │ ├── HttpNode.tsx │ │ │ │ │ ├── IntervalNode.tsx │ │ │ │ │ ├── LogicNode.tsx │ │ │ │ │ ├── LoopNode.tsx │ │ │ │ │ ├── MQTTNode.tsx │ │ │ │ │ ├── NumberNode.tsx │ │ │ │ │ ├── ProcessingNode.tsx │ │ │ │ │ ├── TitleNode.tsx │ │ │ │ │ └── VarNode.tsx │ │ │ │ ├── flow-log/ │ │ │ │ │ └── FlowLog.tsx │ │ │ │ ├── footer/ │ │ │ │ │ └── index.tsx │ │ │ │ ├── gps/ │ │ │ │ │ └── parseGps.ts │ │ │ │ ├── ha/ │ │ │ │ │ ├── HaEntitiesTable.tsx │ │ │ │ │ ├── HaStatBlock.tsx │ │ │ │ │ └── index.tsx │ │ │ │ ├── integration/ │ │ │ │ │ ├── HA.tsx │ │ │ │ │ ├── Integration.tsx │ │ │ │ │ ├── ROS.tsx │ │ │ │ │ ├── SDR.tsx │ │ │ │ │ ├── constants.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── json/ │ │ │ │ │ └── JsonEditor.tsx │ │ │ │ ├── llm-chat/ │ │ │ │ │ ├── ChatInput.tsx │ │ │ │ │ ├── ChatMessage.tsx │ │ │ │ │ ├── ChatMessages.tsx │ │ │ │ │ ├── ChatPanel.tsx │ │ │ │ │ ├── ChatPanelContainer.tsx │ │ │ │ │ ├── ChatPanelMobile.tsx │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── store.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ └── useChatKeyboard.ts │ │ │ │ ├── log/ │ │ │ │ │ └── index.tsx │ │ │ │ ├── map/ │ │ │ │ │ ├── CurrentLocationMarker.tsx │ │ │ │ │ ├── MapViewPersistence.tsx │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── style.css │ │ │ │ ├── map-draw/ │ │ │ │ │ ├── FeatureDetailsPanel.tsx │ │ │ │ │ ├── FeatureDrawingPreview.tsx │ │ │ │ │ ├── FeatureEditor.tsx │ │ │ │ │ ├── FeatureRenderer.tsx │ │ │ │ │ ├── LayerDialog.tsx │ │ │ │ │ ├── LayerSidebar.tsx │ │ │ │ │ ├── MapEvents.tsx │ │ │ │ │ └── MapToolbar.tsx │ │ │ │ ├── map-entity/ │ │ │ │ │ ├── EntityDetailsPanel.tsx │ │ │ │ │ ├── render.tsx │ │ │ │ │ └── store.ts │ │ │ │ ├── recording/ │ │ │ │ │ ├── RecordingButton.tsx │ │ │ │ │ ├── RecordingsList.tsx │ │ │ │ │ ├── VideoPlaybackDialog.tsx │ │ │ │ │ ├── components/ │ │ │ │ │ │ ├── AudioWaveformPlayer.tsx │ │ │ │ │ │ ├── FrameTimeline.tsx │ │ │ │ │ │ ├── PlaybackControls.tsx │ │ │ │ │ │ ├── TimeRuler.tsx │ │ │ │ │ │ ├── VideoControlBar.tsx │ │ │ │ │ │ └── WaveformCanvas.tsx │ │ │ │ │ ├── hooks/ │ │ │ │ │ │ ├── useAudioWaveform.ts │ │ │ │ │ │ ├── useMediaPlayback.ts │ │ │ │ │ │ └── useVideoFrames.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── role/ │ │ │ │ │ ├── RoleDialogs.tsx │ │ │ │ │ └── RoleForm.tsx │ │ │ │ ├── ros2/ │ │ │ │ │ └── Ros2Dashboard.tsx │ │ │ │ ├── rtc/ │ │ │ │ │ ├── AudioLevelBar.tsx │ │ │ │ │ ├── StreamReceiver.tsx │ │ │ │ │ ├── WebRTCProvider.tsx │ │ │ │ │ ├── captureFrame.ts │ │ │ │ │ ├── rtc.ts │ │ │ │ │ └── turnService.ts │ │ │ │ ├── sdr/ │ │ │ │ │ ├── SdrAudioPlayer.tsx │ │ │ │ │ ├── SdrDashboard.tsx │ │ │ │ │ └── api.ts │ │ │ │ ├── search/ │ │ │ │ │ └── search-form.tsx │ │ │ │ ├── server-resource/ │ │ │ │ │ └── resourceUsage.tsx │ │ │ │ ├── setup/ │ │ │ │ │ └── index.tsx │ │ │ │ ├── sidebar/ │ │ │ │ │ ├── footer.tsx │ │ │ │ │ └── index.tsx │ │ │ │ ├── stat/ │ │ │ │ │ └── index.tsx │ │ │ │ ├── topbar/ │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── style.css │ │ │ │ ├── user/ │ │ │ │ │ ├── UserRoleAssigner.tsx │ │ │ │ │ ├── userAdd.tsx │ │ │ │ │ ├── userDelete.tsx │ │ │ │ │ ├── userEdit.tsx │ │ │ │ │ └── userForm.tsx │ │ │ │ └── ws/ │ │ │ │ ├── FlowUiEventBridge.tsx │ │ │ │ ├── IsConnected.tsx │ │ │ │ ├── WebSocketProvider.tsx │ │ │ │ ├── flowUiAdapters/ │ │ │ │ │ └── toastFlowUiAdapter.ts │ │ │ │ ├── flowUiEventRouter.test.ts │ │ │ │ ├── flowUiEventRouter.ts │ │ │ │ ├── ws.ts │ │ │ │ └── wsMock.ts │ │ │ ├── font.css │ │ │ ├── hooks/ │ │ │ │ ├── use-mobile.ts │ │ │ │ ├── useDesktopSidecar.ts │ │ │ │ └── usePreventBackNavigation.ts │ │ │ ├── index.css │ │ │ ├── lib/ │ │ │ │ ├── electron.ts │ │ │ │ ├── geometry-precision.ts │ │ │ │ ├── geometry.ts │ │ │ │ ├── jwt.ts │ │ │ │ ├── storage.ts │ │ │ │ ├── string.ts │ │ │ │ ├── supabase.ts │ │ │ │ ├── time.ts │ │ │ │ └── utils.ts │ │ │ ├── main.tsx │ │ │ ├── pages/ │ │ │ │ ├── auth/ │ │ │ │ │ └── index.tsx │ │ │ │ ├── code/ │ │ │ │ │ └── index.tsx │ │ │ │ ├── dashboard/ │ │ │ │ │ ├── DashboardMainPanel.tsx │ │ │ │ │ └── index.tsx │ │ │ │ ├── desktop-settings/ │ │ │ │ │ └── index.tsx │ │ │ │ ├── devices/ │ │ │ │ │ └── index.tsx │ │ │ │ ├── dynamic-dashboard/ │ │ │ │ │ ├── DynamicDashboardMainPanel.tsx │ │ │ │ │ ├── NewDynamicDashboardPanel.tsx │ │ │ │ │ └── index.tsx │ │ │ │ ├── flow/ │ │ │ │ │ └── index.tsx │ │ │ │ ├── landing/ │ │ │ │ │ └── index.tsx │ │ │ │ ├── map/ │ │ │ │ │ └── index.tsx │ │ │ │ ├── notfound/ │ │ │ │ │ └── index.tsx │ │ │ │ ├── recordings/ │ │ │ │ │ └── index.tsx │ │ │ │ ├── settings/ │ │ │ │ │ ├── account.tsx │ │ │ │ │ ├── config.tsx │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── integration.tsx │ │ │ │ │ ├── log.tsx │ │ │ │ │ ├── networks.tsx │ │ │ │ │ ├── services.tsx │ │ │ │ │ └── users.tsx │ │ │ │ └── setup/ │ │ │ │ └── index.tsx │ │ │ ├── shared/ │ │ │ │ ├── api/ │ │ │ │ │ └── index.ts │ │ │ │ ├── demo.ts │ │ │ │ ├── desktop.ts │ │ │ │ └── mock/ │ │ │ │ ├── mockAdapter.ts │ │ │ │ └── mockData.ts │ │ │ ├── vite-env.d.ts │ │ │ └── widgets/ │ │ │ ├── auth/ │ │ │ │ ├── AuthenticatedLayout.tsx │ │ │ │ └── TopBarWrapper.tsx │ │ │ ├── device-list/ │ │ │ │ └── DeviceList.tsx │ │ │ ├── entity-list/ │ │ │ │ └── EntityList.tsx │ │ │ ├── role-table/ │ │ │ │ └── RoleList.tsx │ │ │ └── user-table/ │ │ │ └── UserList.tsx │ │ ├── tailwind.config.js │ │ ├── tsconfig.app.json │ │ ├── tsconfig.app.tsbuildinfo │ │ ├── tsconfig.json │ │ ├── tsconfig.node.json │ │ ├── tsconfig.node.tsbuildinfo │ │ └── vite.config.ts │ ├── desktop/ │ │ ├── .gitignore │ │ ├── README.md │ │ ├── package.json │ │ └── src-tauri/ │ │ ├── Cargo.toml │ │ ├── build.rs │ │ ├── capabilities/ │ │ │ └── main.json │ │ ├── entitlements.plist │ │ ├── icons/ │ │ │ └── icon.icns │ │ ├── src/ │ │ │ └── main.rs │ │ └── tauri.conf.json │ ├── landing/ │ │ ├── .gitignore │ │ ├── README.md │ │ ├── components.json │ │ ├── index.html │ │ ├── package.json │ │ ├── public/ │ │ │ └── glb/ │ │ │ ├── Computers.glb │ │ │ ├── Drone.glb │ │ │ ├── Engineer.glb │ │ │ ├── Robot.glb │ │ │ ├── SecurityCamera.glb │ │ │ └── Trailer.glb │ │ ├── src/ │ │ │ ├── App.tsx │ │ │ ├── components/ │ │ │ │ ├── Footer.tsx │ │ │ │ ├── Navbar.tsx │ │ │ │ ├── UsageCharts.tsx │ │ │ │ ├── sections/ │ │ │ │ │ ├── CapsulePromo.tsx │ │ │ │ │ ├── Faqs.tsx │ │ │ │ │ ├── Features.tsx │ │ │ │ │ ├── FooterCta.tsx │ │ │ │ │ ├── HeroScene/ │ │ │ │ │ │ ├── CameraRig.tsx │ │ │ │ │ │ ├── Computers.tsx │ │ │ │ │ │ ├── HeroScene.tsx │ │ │ │ │ │ └── SpinningBox.tsx │ │ │ │ │ ├── IntegrationImage.tsx │ │ │ │ │ ├── ListCards.tsx │ │ │ │ │ ├── MidCta.tsx │ │ │ │ │ ├── ScrollBox.tsx │ │ │ │ │ ├── ScrollTextReveal.tsx │ │ │ │ │ ├── SecurityCta.tsx │ │ │ │ │ ├── SubheadingSection.tsx │ │ │ │ │ ├── ThreeCards.tsx │ │ │ │ │ ├── Usecase.tsx │ │ │ │ │ ├── UsecaseAI.tsx │ │ │ │ │ └── capsule/ │ │ │ │ │ ├── CapsuleArchitecture.tsx │ │ │ │ │ ├── CapsuleFaq.tsx │ │ │ │ │ ├── CapsuleFeatures.tsx │ │ │ │ │ ├── CapsuleFooterCta.tsx │ │ │ │ │ ├── CapsuleHero.tsx │ │ │ │ │ ├── CapsuleHowItWorks.tsx │ │ │ │ │ ├── CapsuleSecurity.tsx │ │ │ │ │ ├── CapsuleSubheading.tsx │ │ │ │ │ ├── CapsuleUsecases.tsx │ │ │ │ │ └── EncryptionFlowIllustration.tsx │ │ │ │ └── ui/ │ │ │ │ ├── alert-dialog.tsx │ │ │ │ ├── button.tsx │ │ │ │ ├── card.tsx │ │ │ │ ├── chart.tsx │ │ │ │ ├── navigation-menu.tsx │ │ │ │ └── sonner.tsx │ │ │ ├── contexts/ │ │ │ │ └── AuthContext.tsx │ │ │ ├── hooks/ │ │ │ │ └── useUsageData.ts │ │ │ ├── index.css │ │ │ ├── lib/ │ │ │ │ ├── billing.ts │ │ │ │ ├── supabase.ts │ │ │ │ ├── theatre.ts │ │ │ │ ├── useFadeInOnScroll.ts │ │ │ │ └── utils.ts │ │ │ ├── main.tsx │ │ │ ├── pages/ │ │ │ │ ├── Capsule.tsx │ │ │ │ ├── CheckoutSuccess.tsx │ │ │ │ ├── Contact.tsx │ │ │ │ ├── Dashboard.tsx │ │ │ │ ├── Disclaimer.tsx │ │ │ │ ├── Login.tsx │ │ │ │ ├── Main.tsx │ │ │ │ ├── Pricing.tsx │ │ │ │ ├── Privacy.tsx │ │ │ │ ├── PrivacyPolicy.tsx │ │ │ │ ├── Roadmap.tsx │ │ │ │ ├── Terms.tsx │ │ │ │ └── UseCase.tsx │ │ │ └── vite-env.d.ts │ │ ├── tsconfig.app.json │ │ ├── tsconfig.json │ │ ├── tsconfig.node.json │ │ └── vite.config.ts │ └── server/ │ ├── Cargo.toml │ ├── build.rs │ ├── diesel.toml │ ├── migrations/ │ │ ├── .keep │ │ ├── 2025-08-07-152325_users/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2025-08-22-092047_map/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2025-09-08-073323_create_rbac_tables/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2025-09-11-151252_custom_node/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2025-09-19-060609_create_streams/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2026-01-12-023507_dyndashboard/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ └── 2026-01-31-000001_create_recordings/ │ │ ├── down.sql │ │ └── up.sql │ ├── src/ │ │ ├── broker_mqtt.rs │ │ ├── config.rs │ │ ├── db/ │ │ │ ├── conn.rs │ │ │ ├── mod.rs │ │ │ ├── models.rs │ │ │ ├── repository/ │ │ │ │ ├── dashboards.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── rbac.rs │ │ │ │ ├── recordings.rs │ │ │ │ └── streams.rs │ │ │ └── schema.rs │ │ ├── error.rs │ │ ├── flow/ │ │ │ ├── binary_store.rs │ │ │ ├── engine.rs │ │ │ ├── manager_state.rs │ │ │ ├── mod.rs │ │ │ ├── nodes/ │ │ │ │ ├── branch.rs │ │ │ │ ├── calc.rs │ │ │ │ ├── custom_node.rs │ │ │ │ ├── dashboard_event_listener.rs │ │ │ │ ├── decode_h264.rs │ │ │ │ ├── decode_opus.rs │ │ │ │ ├── gst_decoder.rs │ │ │ │ ├── http.rs │ │ │ │ ├── interval.rs │ │ │ │ ├── json_modify.rs │ │ │ │ ├── json_selector.rs │ │ │ │ ├── log_message.rs │ │ │ │ ├── logic_operator.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── mqtt_publish.rs │ │ │ │ ├── mqtt_subscribe.rs │ │ │ │ ├── rtp_stream_in.rs │ │ │ │ ├── set_variable.rs │ │ │ │ ├── set_variable_with_exec.rs │ │ │ │ ├── show_toast.rs │ │ │ │ ├── start.rs │ │ │ │ ├── type_converter.rs │ │ │ │ ├── websocket_on.rs │ │ │ │ ├── websocket_send.rs │ │ │ │ └── yolo_detect.rs │ │ │ └── types.rs │ │ ├── handler/ │ │ │ ├── auth.rs │ │ │ ├── configurations.rs │ │ │ ├── custom_nodes.rs │ │ │ ├── dashboards.rs │ │ │ ├── device_tokens.rs │ │ │ ├── devices.rs │ │ │ ├── entities.rs │ │ │ ├── flows.rs │ │ │ ├── ha.rs │ │ │ ├── integration.rs │ │ │ ├── log.rs │ │ │ ├── map.rs │ │ │ ├── mod.rs │ │ │ ├── permissions.rs │ │ │ ├── recordings.rs │ │ │ ├── roles.rs │ │ │ ├── sdr.rs │ │ │ ├── stat.rs │ │ │ ├── state.rs │ │ │ ├── storage.rs │ │ │ ├── streams.rs │ │ │ ├── tunnel.rs │ │ │ ├── users.rs │ │ │ └── ws/ │ │ │ ├── dashboard_component_event.rs │ │ │ ├── handlers.rs │ │ │ ├── mod.rs │ │ │ └── webrtc.rs │ │ ├── init/ │ │ │ ├── db_record.rs │ │ │ ├── mod.rs │ │ │ └── streams.rs │ │ ├── lib.rs │ │ ├── logo.rs │ │ ├── main.rs │ │ ├── media/ │ │ │ ├── adapter.rs │ │ │ ├── mod.rs │ │ │ ├── rtp_push.rs │ │ │ └── rtsp_pull.rs │ │ ├── recording/ │ │ │ ├── manager.rs │ │ │ ├── mod.rs │ │ │ ├── muxer.rs │ │ │ └── service.rs │ │ ├── routes.rs │ │ ├── state.rs │ │ ├── tunnel_control.rs │ │ └── utils/ │ │ ├── entity_map.rs │ │ ├── hash.rs │ │ ├── mod.rs │ │ ├── stream_checker.rs │ │ └── system_configs.rs │ └── tests/ │ ├── common/ │ │ └── mod.rs │ ├── recording_manager_tests.rs │ ├── recordings_repository_tests.rs │ └── state_tests.rs ├── configs/ │ └── projects.json ├── docs/ │ ├── .vitepress/ │ │ └── config.mts │ ├── concepts.md │ ├── dashboard.md │ ├── features.md │ ├── flow.md │ ├── index.md │ ├── installation.md │ ├── introduction.md │ ├── map.md │ ├── setup.md │ └── troubleshooting.md ├── example/ │ ├── golang/ │ │ ├── .gitignore │ │ ├── audio-file/ │ │ │ ├── audio.go │ │ │ └── audio.sdp │ │ ├── audio-mic/ │ │ │ └── audio.go │ │ ├── go.mod │ │ ├── go.sum │ │ ├── video-sample/ │ │ │ └── video.go │ │ ├── video2-sample/ │ │ │ └── video.go │ │ └── video3-sample/ │ │ └── video.go │ └── js/ │ └── index.js ├── jest.config.js ├── package.json ├── packages/ │ ├── PKG.md │ ├── capsule-client/ │ │ ├── package.json │ │ ├── src/ │ │ │ ├── client.ts │ │ │ ├── conversation.ts │ │ │ ├── crypto.ts │ │ │ ├── index.ts │ │ │ └── types.ts │ │ └── tsconfig.json │ └── custom-node-utils/ │ ├── add_number.py │ ├── random.py │ └── vessel.config.json ├── scripts/ │ └── bundle-macos-deps.sh └── tests/ ├── TEST.md └── api.test.js ================================================ FILE CONTENTS ================================================ ================================================ FILE: .cargo/config.toml ================================================ [target.aarch64-apple-darwin] rustflags = ["-C", "link-args=-Wl,-headerpad_max_install_names"] [target.x86_64-apple-darwin] rustflags = ["-C", "link-args=-Wl,-headerpad_max_install_names"] ================================================ FILE: .github/GIT.md ================================================ ================================================ FILE: .github/workflows/build.yml ================================================ name: Rust # on: # push: # branches: ["main"] # pull_request: # branches: ["main"] env: CARGO_TERM_COLOR: always BINARY_NAME: server jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Node.js uses: actions/setup-node@v4 with: node-version: "20.x" - name: Install system dependencies run: sudo apt-get update && sudo apt-get install -y libglib2.0-dev pkg-config libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev - name: Install npm dependencies run: npm install - name: Build run: cargo build --release --verbose working-directory: ./apps/server - name: Upload artifact uses: actions/upload-artifact@v4 with: name: ${{ env.BINARY_NAME }} path: target/release/${{ env.BINARY_NAME }} ================================================ FILE: .github/workflows/release-macos.yml ================================================ name: Release macOS on: push: tags: ['v*'] workflow_dispatch: jobs: build: runs-on: macos-14 timeout-minutes: 60 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: '20' cache: 'npm' - uses: dtolnay/rust-toolchain@stable with: targets: aarch64-apple-darwin - uses: Swatinem/rust-cache@v2 with: workspaces: | . apps/desktop/src-tauri - name: Install GStreamer & GLib run: brew install gstreamer glib pkg-config - name: Import code signing certificate env: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} run: | KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain-db" CERT_PATH="$RUNNER_TEMP/cert.p12" security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" echo -n "$APPLE_CERTIFICATE" | base64 --decode -o "$CERT_PATH" security import "$CERT_PATH" -P "$APPLE_CERTIFICATE_PASSWORD" \ -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH" security set-key-partition-list -S apple-tool:,apple:,codesign: \ -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" security list-keychain -d user -s "$KEYCHAIN_PATH" login.keychain security default-keychain -s "$KEYCHAIN_PATH" security find-identity -v -p codesigning "$KEYCHAIN_PATH" - name: Install npm dependencies run: npm ci - name: Build, sign and notarize env: APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} APPLE_ENTITLEMENTS_PATH: ${{ github.workspace }}/apps/desktop/src-tauri/entitlements.plist VITE_SUPABASE_URL: ${{ secrets.VITE_SUPABASE_URL }} VITE_SUPABASE_ANON_KEY: ${{ secrets.VITE_SUPABASE_ANON_KEY }} VITE_CAPSULE_URL: ${{ secrets.VITE_CAPSULE_URL }} working-directory: apps/desktop run: npm run tauri -- build --target aarch64-apple-darwin - name: Notarize and staple DMG env: APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} run: | set -e DMG=$(ls target/aarch64-apple-darwin/release/bundle/dmg/*.dmg | head -1) echo "==> Submitting DMG for notarization: $DMG" xcrun notarytool submit "$DMG" \ --apple-id "$APPLE_ID" \ --password "$APPLE_PASSWORD" \ --team-id "$APPLE_TEAM_ID" \ --wait echo "==> Stapling DMG" xcrun stapler staple "$DMG" - name: Verify signature and notarization run: | set -e BUNDLE_DIR="target/aarch64-apple-darwin/release/bundle" APP=$(ls -d "$BUNDLE_DIR"/macos/*.app | head -1) DMG=$(ls "$BUNDLE_DIR"/dmg/*.dmg | head -1) echo "==> App: $APP" echo "==> DMG: $DMG" codesign -dv --verbose=4 "$APP" 2>&1 | grep -E 'Authority|TeamIdentifier|Runtime|Identifier' codesign --verify --deep --strict --verbose=2 "$APP" spctl -a -vv -t exec "$APP" xcrun stapler validate "$DMG" spctl -a -vv -t install "$DMG" - name: Upload DMG artifact uses: actions/upload-artifact@v4 with: name: vessel-macos-arm64 path: target/aarch64-apple-darwin/release/bundle/dmg/*.dmg if-no-files-found: error - name: Publish to GitHub Release if: startsWith(github.ref, 'refs/tags/') uses: softprops/action-gh-release@v2 with: files: target/aarch64-apple-darwin/release/bundle/dmg/*.dmg draft: true generate_release_notes: true - name: Cleanup keychain if: always() run: security delete-keychain "$RUNNER_TEMP/build.keychain-db" || true ================================================ FILE: .gitignore ================================================ /node_modules docs/.vitepress/dist docs/.vitepress/cache /target .env database.db database.db-* .DS_Store *.log.* *.log /storage /config.toml /.venv /recordings /packages/*/node_modules /packages/*/dist /packages/*/**/node_modules /apps/*/**/node_modules /supabase ================================================ FILE: .prettierrc ================================================ { "semi": true, "singleQuote": false, "jsxSingleQuote": true, "tabWidth": 2, "trailingComma": "all", "arrowParens": "always" } ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Contributor Covenant Code of Conduct ## Our Pledge We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. ## Our Standards Examples of behavior that contributes to a positive environment for our community include: - Demonstrating empathy and kindness toward other people - Being respectful of differing opinions, viewpoints, and experiences - Giving and gracefully accepting constructive feedback - Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience - Focusing on what is best not just for us as individuals, but for the overall community Examples of unacceptable behavior include: - The use of sexualized language or imagery, and sexual attention or advances of any kind - Trolling, insulting or derogatory comments, and personal or political attacks - Public or private harassment - Publishing others' private information, such as a physical or email address, without their explicit permission - Other conduct which could reasonably be considered inappropriate in a professional setting ## Enforcement Responsibilities Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. ## Scope This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at 3457xc@gmail.com. All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the reporter of any incident. ## Enforcement Guidelines Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: ### 1. Correction **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. ### 2. Warning **Community Impact**: A violation through a single incident or series of actions. **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. ### 3. Temporary Ban **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. ### 4. Permanent Ban **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. **Consequence**: A permanent ban from any sort of public interaction within the community. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). [homepage]: https://www.contributor-covenant.org For answers to common questions about this code of conduct, see the FAQ at https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. ================================================ FILE: CODE_RULE.md ================================================ # Coding Rules This document establishes the official coding standards for all mission-critical software. Adherence to these rules is mandatory to ensure maximum safety, reliability, and security. The guiding principle is to produce code that is robust, predictable, and resilient under all operating conditions by managing the entire development lifecycle, not just the code itself. ## ## Core Principles ### 1. Fail-Safe Principle All systems must be designed to default to a safe state in the event of any failure. The software must anticipate failures and handle them gracefully without compromising the integrity of the mission or system. - **R1.1: No Silent Failures.** All fallible operations **must** return an explicit result type (e.g., `Result` or a `Promise`). The consuming code **must** exhaustively handle both success and failure states. Linter rules must be configured to fail the build on any unhandled error or result. - **R1.2: Graceful Degradation.** In the event of a non-critical component failure, the system must continue to operate, albeit in a degraded mode. The software must be able to isolate faults and prevent them from cascading. - **R1.3: Explicit Resource Management.** Leverage language features for automatic resource cleanup where available (e.g., RAII). For resources not managed automatically, cleanup **must** be guaranteed using deterministic patterns like `try...finally` blocks. ### 2. Deterministic Behavior The software's output must depend solely on its inputs, and its execution time must be predictable and bounded. Non-deterministic behavior introduces unacceptable risk. - **R2.1: Minimize and Isolate Dynamic Allocation.** After an initial setup phase, dynamic heap allocation should be avoided in performance-critical loops. Where unavoidable, use patterns like object pooling and pre-allocated buffers with fixed capacity to control memory usage. - **R2.2: Forbid Recursion.** Recursive function calls are prohibited. All recursive algorithms **must** be implemented iteratively to prevent stack overflow errors. - **R2.3: Avoid Floating-Point Ambiguity.** Never compare floating-point numbers for exact equality. Comparisons **must** be made within a defined tolerance (`epsilon`). - **R2.4: Use Fixed-Size Data Structures.** All data structures, including arrays and buffers, **must** have a fixed, statically defined maximum size to prevent buffer overflows. - **R2.5: Use Named Constants over Magic Numbers.** Do not use unexplained numeric literals. Use the language's constant features (`const`, `static`, `enum`) to define all such values. ### 3. Security by Design Security is not an afterthought; it is an integral part of the design process. The system must be architected to be secure from the ground up. - **R3.1: Default to Secure.** The default state of any system parameter **must** be the most secure state. Permissions should be denied by default and only explicitly granted. - **R3.2: Validate All External Inputs.** All data originating from outside a trusted boundary is considered hostile. It **must** be validated at runtime before use, as compile-time type safety is insufficient for external data. - **R3.3: Principle of Least Privilege.** Each software module **must** only be granted the permissions and access rights essential for its designated task. - **R3.4: Keep it Simple.** Code complexity is the enemy of security. Avoid complex control flows and deep nesting. Prefer simple, linear logic that is easy to inspect and verify. - **R3.5: Ensure Data Integrity.** Use mechanisms like CRCs or checksums to verify the integrity of critical data, especially during transmission or storage. ### 4. Concurrency and Real-Time Behavior Systems with multiple threads or processes must behave predictably and meet their operational deadlines without fail. - **R4.1: Avoid Shared Mutable State.** The modification of shared data by multiple threads should be forbidden or strictly controlled. Use language-enforced safety mechanisms where available, and prefer message-passing architectures over shared memory. - **R4.2: Bounded Execution Time.** All tasks and functions **must** have a provable worst-case execution time (WCET) to ensure the system can meet its real-time deadlines. - **R4.3: Handle Interrupts with Extreme Care.** Interrupt Service Routines (ISRs) **must** be as short and simple as possible. Forbid any non-deterministic operations within an ISR. ### 5. Verification and Validation (V&V) It is not enough for code to be well-written; it must be proven to meet its requirements correctly. - **R5.1: Traceability to Requirements.** Every line of code **must** trace back to a specific, documented requirement. No code should exist that does not fulfill a requirement. - **R5.2: Mandatory Peer Reviews.** All code **must** be reviewed by at least one other qualified engineer before being integrated. This is a critical step for quality assurance. - **R5.3: Strive for 100% Test Coverage.** All code **must** be unit-tested, with the goal of achieving 100% statement and branch coverage to ensure every execution path is verified. ### 6. Toolchain and Dependency Integrity The reliability of the final product depends on the reliability of the tools and libraries used to create it. - **R6.1: Vet All Third-Party Code.** Any external library or open-source component **must** undergo a rigorous vetting process for security and reliability before it can be used. - **R6.2: Lock Compiler and Dependency Versions.** The exact versions of the compiler, libraries, and all other build tools **must** be specified and locked using the language's standard lock file mechanism (e.g., `Cargo.lock`, `package-lock.json`). --- ## Minor Rule ### m1. Everything is Strict A strict development environment minimizes ambiguity and catches errors at the earliest possible stage. - **R_m1.1: Zero Tolerance for Warnings.** All available compiler and linter checks **must** be enabled at their strictest levels. Any build that produces a warning is considered a failed build and **must** be corrected. - **R_m1.2: Enforce a Single Coding Standard.** All code **must** be formatted using the standard automated tool for the language (e.g., `rustfmt`, `prettier`). The formatter **must** be run as a pre-commit hook. - **R_m1.3: Use Comprehensive Static Analysis.** All code **must** pass a comprehensive suite of static analysis tools (e.g., `Clippy`, `ESLint`) without errors before being committed. The linter's ruleset **must** be configured for maximum strictness. ================================================ FILE: CONTRIBUTING.md ================================================ # CONTRIBUTING ... ## Projects Structure `/apps/client` Frontend code with Vite. `/apps/server` Rust server that connects the physical device to the front end. `/apps/sentinel-go` Audio transfer test code written in Golang. `/apps/landing` Landing page code https://vessel.cartesiancs.com `/docs` Document code https://vessel.cartesiancs.com/docs `/tests` Test code. ================================================ FILE: Cargo.toml ================================================ [workspace] members = [ "apps/server", "apps/desktop/src-tauri", "apps/capsule", ] resolver = "2" ================================================ FILE: Dockerfile ================================================ FROM rust:latest AS builder RUN apt-get update && apt-get install -y cmake build-essential libglib2.0-dev pkg-config musl-tools libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev python3-dev RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \ apt-get install -y nodejs WORKDIR /app COPY Cargo.toml Cargo.lock ./ COPY package*.json ./ RUN npm install COPY ./apps ./apps WORKDIR /app/apps/client RUN npm install WORKDIR /app/apps/server RUN cargo build --release WORKDIR /app/target/release CMD ["./server"] ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: README.md ================================================

Vessel

Visit Website · Report Bugs · Docs · App

## About The Project ![banner](./.github/banner.png) Vessel is the **C2 (Command & Control) software** for connecting, monitoring, and orchestrating arrays of physical sensors via an intuitive, visual flow-based interface. This project is to build a "proactive security system". To achieve this, the following three functions are necessary: 1. **Connect** to Physical Device 2. **Detect** Threats 3. **Control** and Respond This project solves the problems with existing **home security systems**. Current systems fail to protect against burglaries, trespassing, theft and even war. So we plan to open-source the technology used in existing defense systems. This system allows you to analyze video and audio sources with AI/ML technology. And automate actions through Flow-based operations. The Flow provides the flexibility to select multiple AI models and connect them directly to stream sources. When everything is implemented, individuals will be able to protect themselves from any threats. > [!NOTE] > 🚧 This project is under active development. Some features may be unstable or subject to change without notice. ## Features - Connect all sensers (MQTT, RTP, RTSP, HTTP, ...) - RTP Audio & Video Streaming - RTSP Video Streaming - Real-time streaming support - Flow Visual Logic - Custom Script Language Support - Pub/Sub MQTT with Flow - Map based UI - Home Assistant Integration - ROS2 Integration - External access support - Capsule (Zero-Knowledge LLM Call) security. - Local-first design, Offline-first design. ## Develop Get your local copy up and running. #### Prerequisites - [Rust](https://www.rust-lang.org/) & Cargo - [Node.js](https://nodejs.org/en/) (v18+) and npm - [gstreamer](https://gstreamer.freedesktop.org/documentation/rust/git/docs/gstreamer/index.html) - [mosquitto (MQTT)](https://mosquitto.org/) (additional) ### Option1. Run normally ##### 1. Server Setup ```bash # 1. Clone the repository git clone https://github.com/cartesiancs/vessel.git cd vessel/apps/server # 2. Copy and configure environment variables cp .env.example .env # nano .env (Modify if needed) # 3. Run database migrations diesel setup diesel migration run # 4. Run the server cargo run ``` ##### 2. Client Setup ```bash # 1. Install dependencies npm install # 2. Run the development server npm run client ``` ### Option2. Run Docker ```bash docker build -t server . docker run -p 0.0.0.0:6174:6174 server:latest ``` ### Option3. Desktop (Tauri) ```bash npm run desktop ``` Builds the Rust server sidecar in release mode, starts the Vite dev server for the client, and launches the Tauri shell. For a packaged build use `npm run desktop:build`. ## Compile This command compiles the entire project, including both the server and the client, into a single executable file. ```bash npm run build ``` The compiled binary, named 'server', will be located in the target/release directory. > To run the server executable, you must have a .env file in the same directory (target/release). ## Principles 1. Local-first 2. Offline-first 3. Ultimate control rests with the user ## Troubleshooting > A more detailed troubleshooting guide will be available soon. ## Roadmap Please visit our Roadmap page below: [Roadmap Page >](https://vessel.cartesiancs.com/roadmap) ## Contributing Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are greatly appreciated. Please refer to our [CONTRIBUTING.md](CONTRIBUTING.md) for details. ## Contributors ## License Distributed under the Apache-2.0 License. See [LICENSE](LICENSE) for more information. ## Disclaimer This project is intended for academic and research purposes only. It is designed to facilitate the connection and control of physical devices. All responsibility for its use lies with the user. ================================================ FILE: SECURITY.md ================================================ # Security Please report security issues to `3457xc@gmail.com`. This email is the maintainer's personal and public email, allowing for immediate responses to security issues. Alternatively, you may also send a message via LinkedIn DM. My LinkedIn profile is: [in/huhhyeongjun](https://www.linkedin.com/in/huhhyeongjun/) ================================================ FILE: apps/capsule/Cargo.toml ================================================ [package] name = "capsule" version = "0.1.0" edition = "2021" [dependencies] # Web Framework tokio = { version = "1", features = ["full"] } axum = { version = "0.7", features = ["json"] } tower-http = { version = "0.5", features = ["cors", "trace", "limit"] } tower = "0.5" # HTTP types http = "1" # Serialization serde = { version = "1", features = ["derive"] } serde_json = "1" # Cryptography chacha20poly1305 = "0.10" x25519-dalek = { version = "2", features = ["static_secrets"] } rand = "0.8" zeroize = { version = "1", features = ["derive"] } base64 = "0.22" hkdf = "0.12" sha2 = "0.10" # OpenAI reqwest = { version = "0.12", features = ["json", "stream"] } async-stream = "0.3" futures-util = "0.3" tokio-stream = "0.1" # JWT & Auth jsonwebtoken = "9" async-trait = "0.1" chrono = { version = "0.4", features = ["serde"] } axum-extra = { version = "0.9", features = ["typed-header"] } # Utilities anyhow = "1" thiserror = "1" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } dotenvy = "0.15" [[bin]] name = "capsule" path = "src/main.rs" ================================================ FILE: apps/capsule/Dockerfile ================================================ # Build stage FROM rust:1.75-alpine AS builder # 빌드 의존성 설치 RUN apk add --no-cache musl-dev pkgconfig openssl-dev WORKDIR /app # 의존성 캐싱을 위해 Cargo 파일 먼저 복사 COPY Cargo.toml Cargo.lock* ./ # 더미 소스로 의존성 빌드 (캐싱) RUN mkdir src && \ echo "fn main() {}" > src/main.rs && \ cargo build --release && \ rm -rf src # 실제 소스 복사 및 빌드 COPY src ./src RUN touch src/main.rs && cargo build --release # Runtime stage FROM alpine:3.19 # 보안: non-root 사용자 생성 RUN addgroup -g 1001 capsule && \ adduser -u 1001 -G capsule -s /bin/sh -D capsule # 런타임 의존성 RUN apk add --no-cache ca-certificates WORKDIR /app # 빌드된 바이너리 복사 COPY --from=builder /app/target/release/capsule . # 소유권 변경 RUN chown -R capsule:capsule /app # non-root로 실행 USER capsule # 포트 노출 EXPOSE 3000 # 헬스체크 HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1 # 환경 변수 ENV RUST_LOG=info ENV PORT=3000 # 실행 CMD ["./capsule"] ================================================ FILE: apps/capsule/docker-compose.yml ================================================ version: '3.8' services: capsule: build: context: . dockerfile: Dockerfile ports: - "3000:3000" environment: - RUST_LOG=info - PORT=3000 - OPENAI_API_KEY=${OPENAI_API_KEY} - OPENAI_MODEL=${OPENAI_MODEL:-gpt-4o} deploy: resources: limits: memory: 256M cpus: '0.5' # 보안 설정 security_opt: - no-new-privileges:true # 읽기 전용 파일시스템 (민감 데이터 디스크 저장 방지) read_only: true # 임시 파일용 tmpfs tmpfs: - /tmp # 재시작 정책 restart: unless-stopped # 헬스체크 healthcheck: test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/health"] interval: 30s timeout: 3s retries: 3 start_period: 5s ================================================ FILE: apps/capsule/src/api/auth.rs ================================================ //! Authentication extractor for protected endpoints //! //! Extracts and validates JWT tokens from Authorization header. //! Returns AuthUser with user_id for downstream handlers. use axum::{ extract::FromRequestParts, http::{request::Parts, StatusCode}, response::{IntoResponse, Response}, Json, RequestPartsExt, }; use axum_extra::{ headers::{authorization::Bearer, Authorization}, TypedHeader, }; use std::sync::Arc; use crate::services::{JwtError, JwtValidator, SupabaseClaims}; /// Authenticated user information /// /// Extracted from validated JWT token. /// Available in handlers as an extractor parameter. #[derive(Debug, Clone)] pub struct AuthUser { /// User ID (UUID) from JWT sub claim pub user_id: String, /// User email (optional) pub email: Option, /// Full JWT claims for additional data pub claims: SupabaseClaims, } /// Authentication errors #[derive(Debug)] pub enum AuthError { /// No Authorization header present MissingToken, /// Token format is invalid InvalidToken(String), /// Token has expired Expired, } impl IntoResponse for AuthError { fn into_response(self) -> Response { let (status, message) = match &self { AuthError::MissingToken => (StatusCode::UNAUTHORIZED, "Authorization token required"), AuthError::InvalidToken(msg) => { tracing::debug!("Invalid token: {}", msg); (StatusCode::UNAUTHORIZED, "Invalid authorization token") } AuthError::Expired => (StatusCode::UNAUTHORIZED, "Token expired"), }; (status, Json(serde_json::json!({ "error": message }))).into_response() } } #[async_trait::async_trait] impl FromRequestParts for AuthUser where S: Send + Sync, { type Rejection = AuthError; async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { // Extract Bearer token from Authorization header let TypedHeader(Authorization(bearer)) = parts .extract::>>() .await .map_err(|_| AuthError::MissingToken)?; // Get JWT validator from extensions (set by middleware layer) let jwt_validator = parts .extensions .get::>() .ok_or_else(|| AuthError::InvalidToken("JWT validator not configured".to_string()))?; // Validate token (async - fetches JWKS if needed) let claims = jwt_validator .validate(bearer.token()) .await .map_err(|e| match e { JwtError::Expired => AuthError::Expired, _ => AuthError::InvalidToken(e.to_string()), })?; tracing::debug!(user_id = %claims.sub, "User authenticated"); Ok(AuthUser { user_id: claims.sub.clone(), email: claims.email.clone(), claims, }) } } ================================================ FILE: apps/capsule/src/api/chat.rs ================================================ //! Chat API handlers with authentication and usage tracking //! //! # Security Data Flow //! 1. Extract and validate JWT token (AuthUser extractor) //! 2. Check subscription status (billing_subscriptions table) //! 3. Check rate limits (enclave_usage table) //! 4. Process request (decrypt image if present) //! 5. Track usage (fire-and-forget) //! 6. Return response use axum::{ extract::State, response::sse::{Event, Sse}, Json, }; use futures_util::{stream::Stream, StreamExt}; use std::sync::Arc; use crate::api::AuthUser; use crate::error::CapsuleError; use crate::types::{ChatRequest, ChatResponse}; use crate::AppState; /// POST /api/chat /// /// Receives encrypted image and message, returns AI response. /// /// # Authentication /// Requires valid Supabase JWT token in Authorization header. /// /// # Subscription /// Only users with active Pro subscription can access this endpoint. /// /// # Security Data Flow /// 1. `EncryptedImage` received (safe to log) /// 2. Decrypted to `DecryptedImage` (memory only) /// 3. OpenAI API call (using decrypted image) /// 4. `DecryptedImage` auto-dropped → memory zeroized /// 5. Response returned pub async fn chat_handler( State(state): State>, auth: AuthUser, Json(req): Json, ) -> Result, CapsuleError> { // 1. Check subscription and rate limits let rate_status = state .usage_tracker .check_rate_limit(&auth.user_id) .await .map_err(|e| CapsuleError::Internal(e.to_string()))?; // 2. Block non-subscribers if !rate_status.subscribed { tracing::info!(user_id = %auth.user_id, "Subscription required"); return Err(CapsuleError::SubscriptionRequired); } // 3. Check rate limit if !rate_status.allowed { tracing::info!( user_id = %auth.user_id, reason = ?rate_status.reason, "Rate limited" ); return Err(CapsuleError::RateLimited( rate_status.reason.unwrap_or_else(|| "Rate limit exceeded".to_string()), )); } // 3.5. Validate history req.validate_history()?; tracing::info!( user_id = %auth.user_id, message_len = req.message.len(), has_image = req.encrypted_image.is_some(), history_len = req.history.as_ref().map(|h| h.len()).unwrap_or(0), has_system_prompt = req.system_prompt.is_some(), "Chat request" ); let is_image_request = req.encrypted_image.is_some(); let history_slice = req.history.as_deref(); let system_prompt = req.system_prompt.as_deref(); let tools = req.tools.as_ref(); let tool_choice = req.tool_choice.as_ref(); // 4. Process request let result = if let Some(encrypted_image) = req.encrypted_image { let decrypted = state.key_manager.decrypt(&encrypted_image).await?; tracing::debug!("Image decrypted: {} bytes", decrypted.len()); state .openai .analyze_image(&req.message, decrypted, system_prompt, history_slice, tools, tool_choice) .await .map_err(|e| CapsuleError::OpenAIError(e.to_string()))? } else { state .openai .chat(&req.message, system_prompt, history_slice, tools, tool_choice) .await .map_err(|e| CapsuleError::OpenAIError(e.to_string()))? }; tracing::info!( user_id = %auth.user_id, response_len = result.content.len(), has_tool_calls = result.tool_calls.is_some(), input_tokens = result.usage.prompt_tokens, output_tokens = result.usage.completion_tokens, "Chat response generated" ); // 5. Track usage (fire-and-forget) let tracker = state.usage_tracker.clone(); let user_id = auth.user_id.clone(); let input_tokens = result.usage.prompt_tokens; let output_tokens = result.usage.completion_tokens; tokio::spawn(async move { if let Err(e) = tracker.track_request(&user_id, input_tokens, output_tokens, is_image_request).await { tracing::warn!(error = %e, "Failed to track usage"); } }); Ok(Json(ChatResponse { response: result.content, tool_calls: result.tool_calls, })) } /// POST /api/chat/stream /// /// Returns streaming response via Server-Sent Events. /// /// # Authentication /// Requires valid Supabase JWT token in Authorization header. /// /// # Subscription /// Only users with active Pro subscription can access this endpoint. pub async fn chat_stream_handler( State(state): State>, auth: AuthUser, Json(req): Json, ) -> Result>>, CapsuleError> { // 1. Check subscription and rate limits let rate_status = state .usage_tracker .check_rate_limit(&auth.user_id) .await .map_err(|e| CapsuleError::Internal(e.to_string()))?; // 2. Block non-subscribers if !rate_status.subscribed { tracing::info!(user_id = %auth.user_id, "Subscription required"); return Err(CapsuleError::SubscriptionRequired); } // 3. Check rate limit if !rate_status.allowed { tracing::info!( user_id = %auth.user_id, reason = ?rate_status.reason, "Rate limited" ); return Err(CapsuleError::RateLimited( rate_status.reason.unwrap_or_else(|| "Rate limit exceeded".to_string()), )); } // 3.5. Validate history req.validate_history()?; tracing::info!( user_id = %auth.user_id, message_len = req.message.len(), has_image = req.encrypted_image.is_some(), history_len = req.history.as_ref().map(|h| h.len()).unwrap_or(0), has_system_prompt = req.system_prompt.is_some(), "Stream chat request" ); let is_image_request = req.encrypted_image.is_some(); let history_slice = req.history.as_deref(); let system_prompt = req.system_prompt.as_deref(); let tools = req.tools.as_ref(); let tool_choice = req.tool_choice.as_ref(); // 4. Process request let (stream, usage) = if let Some(encrypted_image) = req.encrypted_image { let decrypted = state.key_manager.decrypt(&encrypted_image).await?; tracing::debug!("Image decrypted for streaming: {} bytes", decrypted.len()); state .openai .analyze_image_stream(&req.message, decrypted, system_prompt, history_slice, tools, tool_choice) .await .map_err(|e| CapsuleError::OpenAIError(e.to_string()))? } else { state .openai .chat_stream(&req.message, system_prompt, history_slice, tools, tool_choice) .await .map_err(|e| CapsuleError::OpenAIError(e.to_string()))? }; // 5. Track usage after stream completes (fire-and-forget) let tracker = state.usage_tracker.clone(); let user_id = auth.user_id.clone(); // Wrap the stream to track usage when it completes let wrapped_stream = async_stream::stream! { let mut inner = std::pin::pin!(stream); while let Some(item) = inner.next().await { yield item; } // Stream completed, now track usage let (input_tokens, output_tokens) = { let guard = usage.lock().unwrap(); (guard.prompt_tokens, guard.completion_tokens) }; tracing::info!( user_id = %user_id, input_tokens = input_tokens, output_tokens = output_tokens, "Stream completed, tracking usage" ); let tracker_clone = tracker.clone(); let user_id_clone = user_id.clone(); tokio::spawn(async move { if let Err(e) = tracker_clone.track_request(&user_id_clone, input_tokens, output_tokens, is_image_request).await { tracing::warn!(error = %e, "Failed to track usage"); } }); }; Ok(Sse::new(wrapped_stream)) } ================================================ FILE: apps/capsule/src/api/key.rs ================================================ use axum::{extract::State, Json}; use std::sync::Arc; use crate::types::PublicKeyResponse; use crate::AppState; /// GET /api/public-key /// /// 서버의 공개키를 반환합니다. /// 클라이언트는 이 키로 이미지를 암호화합니다. /// /// # Response /// ```json /// { /// "public_key": "base64_encoded_public_key", /// "key_id": "key_identifier", /// "expires_at": "2024-01-01T00:00:00Z" /// } /// ``` pub async fn public_key_handler(State(state): State>) -> Json { let (public_key, key_id, expires_at) = state.key_manager.current_public_key().await; tracing::info!(key_id = %key_id, "Public key requested"); Json(PublicKeyResponse { public_key, key_id, expires_at, }) } ================================================ FILE: apps/capsule/src/api/mod.rs ================================================ mod auth; mod chat; mod key; mod routes; pub use auth::AuthUser; pub use chat::{chat_handler, chat_stream_handler}; pub use key::public_key_handler; pub use routes::{create_router, RouterConfig}; ================================================ FILE: apps/capsule/src/api/routes.rs ================================================ use axum::{ routing::{get, post}, Extension, Router, }; use std::sync::Arc; use tower_http::{ cors::{AllowHeaders, AllowMethods, AllowOrigin, CorsLayer}, limit::RequestBodyLimitLayer, trace::TraceLayer, }; use crate::api::{chat_handler, chat_stream_handler, public_key_handler}; use crate::services::JwtValidator; use crate::AppState; /// Max request body size: 100MB (for encrypted images) const MAX_BODY_SIZE: usize = 100 * 1024 * 1024; /// Router configuration pub struct RouterConfig { /// Allowed CORS origins pub allowed_origins: Vec, } /// Create API router pub fn create_router( state: Arc, config: &RouterConfig, jwt_validator: Arc, ) -> Router { // CORS configuration let cors = build_cors_layer(config); Router::new() // Health check (public) .route("/health", get(health_handler)) // Public Key endpoint (public) .route("/api/public-key", get(public_key_handler)) // Chat endpoints (authenticated) .route("/api/chat", post(chat_handler)) .route("/api/chat/stream", post(chat_stream_handler)) // Middleware .layer(TraceLayer::new_for_http()) .layer(cors) .layer(RequestBodyLimitLayer::new(MAX_BODY_SIZE)) // JWT validator extension for auth extractor .layer(Extension(jwt_validator)) // State .with_state(state) } /// Build CORS layer fn build_cors_layer(config: &RouterConfig) -> CorsLayer { let allowed_origins = &config.allowed_origins; if allowed_origins.is_empty() || allowed_origins.iter().any(|o| o == "*") { // Development: Allow all origins (with warning) tracing::warn!("CORS: Allowing all origins (development mode)"); CorsLayer::new() .allow_origin(AllowOrigin::any()) .allow_methods(AllowMethods::list([ http::Method::GET, http::Method::POST, http::Method::OPTIONS, ])) .allow_headers(AllowHeaders::list([ http::header::CONTENT_TYPE, http::header::AUTHORIZATION, ])) } else { // Production: Allow only specified origins tracing::info!("CORS: Allowing origins: {:?}", allowed_origins); let origins: Vec<_> = allowed_origins .iter() .filter_map(|o| o.parse().ok()) .collect(); CorsLayer::new() .allow_origin(origins) .allow_methods(AllowMethods::list([ http::Method::GET, http::Method::POST, http::Method::OPTIONS, ])) .allow_headers(AllowHeaders::list([ http::header::CONTENT_TYPE, http::header::AUTHORIZATION, ])) } } /// Health check handler async fn health_handler() -> &'static str { "OK" } ================================================ FILE: apps/capsule/src/config.rs ================================================ use zeroize::Zeroizing; use crate::error::CapsuleError; /// Application configuration pub struct Config { /// Server port pub port: u16, /// OpenAI API key (protected with Zeroizing) pub openai_api_key: Zeroizing, /// OpenAI model (default: gpt-4o) pub openai_model: String, /// Allowed CORS origins pub allowed_origins: Vec, /// Supabase project URL pub supabase_url: String, /// Supabase service role key (protected with Zeroizing) pub supabase_service_key: Zeroizing, /// Key rotation interval in seconds (default: 86400 = 24 hours) pub key_rotation_interval_secs: u64, /// Grace period for previous key in seconds (default: 300 = 5 minutes) pub key_grace_period_secs: u64, } impl Config { /// Load configuration from environment variables /// /// # Security /// - API keys are protected with `Zeroizing` /// - Sensitive environment variables are removed after loading pub fn from_env() -> Result { dotenvy::dotenv().ok(); // Load OpenAI API key let openai_api_key = std::env::var("OPENAI_API_KEY").map_err(|_| { CapsuleError::ConfigError("OPENAI_API_KEY environment variable is required".to_string()) })?; // Load Supabase configuration let supabase_url = std::env::var("SUPABASE_URL").map_err(|_| { CapsuleError::ConfigError("SUPABASE_URL environment variable is required".to_string()) })?; let supabase_service_key = std::env::var("SUPABASE_SERVICE_KEY").map_err(|_| { CapsuleError::ConfigError( "SUPABASE_SERVICE_KEY environment variable is required".to_string(), ) })?; // Security: Remove sensitive environment variables std::env::remove_var("OPENAI_API_KEY"); std::env::remove_var("SUPABASE_SERVICE_KEY"); tracing::debug!("Removed sensitive environment variables"); let port = std::env::var("PORT") .unwrap_or_else(|_| "3000".to_string()) .parse() .unwrap_or(3000); let openai_model = std::env::var("OPENAI_MODEL").unwrap_or_else(|_| "gpt-4o".to_string()); // CORS allowed origins (comma-separated) let allowed_origins = std::env::var("ALLOWED_ORIGINS") .unwrap_or_else(|_| "*".to_string()) .split(',') .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .collect(); let key_rotation_interval_secs = std::env::var("KEY_ROTATION_INTERVAL_SECS") .unwrap_or_else(|_| "86400".to_string()) .parse() .unwrap_or(86400); let key_grace_period_secs = std::env::var("KEY_GRACE_PERIOD_SECS") .unwrap_or_else(|_| "300".to_string()) .parse() .unwrap_or(300); Ok(Self { port, openai_api_key: Zeroizing::new(openai_api_key), openai_model, allowed_origins, supabase_url, supabase_service_key: Zeroizing::new(supabase_service_key), key_rotation_interval_secs, key_grace_period_secs, }) } } ================================================ FILE: apps/capsule/src/crypto/encryption.rs ================================================ use base64::{engine::general_purpose::STANDARD, Engine}; use chacha20poly1305::{ aead::{Aead, KeyInit}, XChaCha20Poly1305, XNonce, }; use hkdf::Hkdf; use sha2::Sha256; use x25519_dalek::{PublicKey, StaticSecret}; use zeroize::Zeroize; use crate::error::CapsuleError; use crate::types::{DecryptedImage, EncryptedImage}; /// HKDF에 사용할 salt와 info const HKDF_SALT: &[u8] = b"vessel-capsule-v1-salt"; const HKDF_INFO: &[u8] = b"vessel-capsule-v1-key"; /// 특정 비밀키로 암호화된 이미지 복호화 /// /// # 보안 /// - Shared secret은 함수 종료 시 zeroize됨 /// - 반환되는 `DecryptedImage`는 Drop 시 자동 zeroize /// /// # 암호화 스킴 /// 1. X25519 ECDH로 shared secret 계산 /// 2. HKDF-SHA256으로 대칭키 유도 /// 3. XChaCha20-Poly1305로 복호화 (AEAD) pub(crate) fn decrypt_with_secret( server_secret: &StaticSecret, encrypted: &EncryptedImage, ) -> Result { // 1. Base64 디코딩 let ephemeral_pk_bytes = STANDARD .decode(&encrypted.ephemeral_public_key) .map_err(|_| CapsuleError::InvalidPublicKey)?; let nonce_bytes = STANDARD .decode(&encrypted.nonce) .map_err(|_| CapsuleError::InvalidNonce)?; let ciphertext = STANDARD .decode(&encrypted.ciphertext) .map_err(|_| CapsuleError::InvalidCiphertext)?; // 2. 공개키 파싱 (32 bytes) let ephemeral_pk: [u8; 32] = ephemeral_pk_bytes .try_into() .map_err(|_| CapsuleError::InvalidPublicKey)?; let client_public = PublicKey::from(ephemeral_pk); // 3. Shared Secret 계산 (X25519 ECDH) let mut shared_secret = server_secret.diffie_hellman(&client_public); // 4. HKDF로 대칭키 유도 (32 bytes for XChaCha20) let mut symmetric_key = [0u8; 32]; let hkdf = Hkdf::::new(Some(HKDF_SALT), shared_secret.as_bytes()); hkdf.expand(HKDF_INFO, &mut symmetric_key) .map_err(|_| CapsuleError::CipherInitFailed)?; // Shared secret 즉시 클리어 shared_secret.zeroize(); // 5. Nonce 파싱 (24 bytes for XChaCha20) let nonce: [u8; 24] = nonce_bytes .try_into() .map_err(|_| CapsuleError::InvalidNonce)?; // 6. XChaCha20-Poly1305로 복호화 let cipher = XChaCha20Poly1305::new_from_slice(&symmetric_key).map_err(|_| CapsuleError::CipherInitFailed)?; // 대칭키 즉시 클리어 symmetric_key.zeroize(); let plaintext = cipher .decrypt(XNonce::from_slice(&nonce), ciphertext.as_ref()) .map_err(|_| CapsuleError::DecryptionFailed)?; tracing::debug!("Successfully decrypted {} bytes", plaintext.len()); Ok(DecryptedImage::new(plaintext)) } #[cfg(test)] mod tests { use super::*; #[test] fn test_decrypt_invalid_public_key() { let secret = StaticSecret::random_from_rng(rand::thread_rng()); let encrypted = EncryptedImage { ephemeral_public_key: "invalid".to_string(), nonce: STANDARD.encode([0u8; 24]), ciphertext: STANDARD.encode(vec![0u8; 32]), key_id: None, }; let result = decrypt_with_secret(&secret, &encrypted); assert!(matches!(result, Err(CapsuleError::InvalidPublicKey))); } #[test] fn test_decrypt_invalid_nonce() { let secret = StaticSecret::random_from_rng(rand::thread_rng()); let encrypted = EncryptedImage { ephemeral_public_key: STANDARD.encode([0u8; 32]), nonce: "invalid".to_string(), ciphertext: STANDARD.encode(vec![0u8; 32]), key_id: None, }; let result = decrypt_with_secret(&secret, &encrypted); assert!(matches!(result, Err(CapsuleError::InvalidNonce))); } } ================================================ FILE: apps/capsule/src/crypto/keypair.rs ================================================ use base64::{engine::general_purpose::STANDARD, Engine}; use std::sync::Arc; use std::time::Duration; use tokio::sync::RwLock; use x25519_dalek::{PublicKey, StaticSecret}; use crate::crypto::decrypt_with_secret; use crate::error::CapsuleError; use crate::types::{DecryptedImage, EncryptedImage}; /// 버전이 지정된 키 쌍 struct VersionedKey { /// 키 고유 식별자 (public key base64의 앞 16자) key_id: String, /// Private Key secret: StaticSecret, /// Public Key public: PublicKey, /// 생성 시각 created_at: std::time::Instant, } impl VersionedKey { fn new() -> Self { let secret = StaticSecret::random_from_rng(rand::thread_rng()); let public = PublicKey::from(&secret); let key_id = STANDARD.encode(public.as_bytes())[..16].to_string(); tracing::info!(key_id = %key_id, "New versioned key pair generated"); Self { key_id, secret, public, created_at: std::time::Instant::now(), } } fn public_key_base64(&self) -> String { STANDARD.encode(self.public.as_bytes()) } } /// 현재/이전 키 슬롯 struct KeySlots { /// 현재 활성 키 (새 클라이언트에게 배포) current: VersionedKey, /// 이전 키 (grace period 동안 유지) previous: Option, } /// 서버 키 쌍 관리자 (로테이션 지원) /// /// # 보안 특성 /// - Private Key는 절대 외부로 노출되지 않음 /// - 복호화는 `decrypt()` 메서드를 통해서만 가능 /// - 키 로테이션으로 침해 범위를 로테이션 주기로 한정 /// - Drop 시 모든 Private Key 자동 zeroize (StaticSecret 내장) /// /// # Two-Slot Key Model /// - `current`: 새 클라이언트에게 배포, 새 요청 복호화 /// - `previous`: grace period 동안 유지, 이전 키를 캐싱한 클라이언트 지원 pub struct KeyManager { keys: Arc>, rotation_interval: Duration, grace_period: Duration, } impl KeyManager { /// 새 KeyManager 생성 (로테이션 설정 포함) /// /// # 보안 /// - 키는 메모리에만 존재 /// - 디스크에 저장되지 않음 /// - 컨테이너 재시작 시 새 키 생성 pub fn new(rotation_interval: Duration, grace_period: Duration) -> Self { let current = VersionedKey::new(); tracing::info!( key_id = %current.key_id, rotation_interval_secs = rotation_interval.as_secs(), grace_period_secs = grace_period.as_secs(), "KeyManager initialized" ); Self { keys: Arc::new(RwLock::new(KeySlots { current, previous: None, })), rotation_interval, grace_period, } } /// 현재 공개키 정보 반환 (key_id, expires_at 포함) pub async fn current_public_key(&self) -> (String, String, String) { let keys = self.keys.read().await; let elapsed = keys.current.created_at.elapsed(); let remaining = self.rotation_interval.saturating_sub(elapsed); let expiry = chrono::Utc::now() + chrono::Duration::from_std(remaining).unwrap_or(chrono::Duration::zero()); ( keys.current.public_key_base64(), keys.current.key_id.clone(), expiry.to_rfc3339(), ) } /// 복호화 수행 (key_id로 적절한 키 선택) /// /// # 키 선택 로직 /// - `key_id` 있으면: 매칭되는 키로 복호화 /// - `key_id` 없으면 (하위 호환): current → previous 순서로 시도 pub async fn decrypt(&self, encrypted: &EncryptedImage) -> Result { let keys = self.keys.read().await; match &encrypted.key_id { Some(kid) => { // key_id가 명시된 경우: 정확히 매칭되는 키 사용 if kid == &keys.current.key_id { decrypt_with_secret(&keys.current.secret, encrypted) } else if let Some(prev) = &keys.previous { if kid == &prev.key_id { tracing::debug!(key_id = %kid, "Decrypting with previous key"); decrypt_with_secret(&prev.secret, encrypted) } else { tracing::warn!( requested_key_id = %kid, current_key_id = %keys.current.key_id, "Unknown key_id requested" ); Err(CapsuleError::DecryptionFailed) } } else { tracing::warn!( requested_key_id = %kid, current_key_id = %keys.current.key_id, "Unknown key_id requested (no previous key)" ); Err(CapsuleError::DecryptionFailed) } } None => { // key_id가 없는 경우 (하위 호환): current → previous 순서로 시도 match decrypt_with_secret(&keys.current.secret, encrypted) { Ok(result) => Ok(result), Err(_) => { if let Some(prev) = &keys.previous { tracing::debug!("Retrying decryption with previous key (no key_id)"); decrypt_with_secret(&prev.secret, encrypted) } else { Err(CapsuleError::DecryptionFailed) } } } } } } /// 키 로테이션: current → previous, 새 키 → current async fn rotate(&self) { let mut keys = self.keys.write().await; let new_key = VersionedKey::new(); tracing::info!( new_key_id = %new_key.key_id, old_key_id = %keys.current.key_id, "Key rotation: new key generated" ); let old_current = std::mem::replace(&mut keys.current, new_key); // 이전 previous가 있으면 여기서 drop → StaticSecret zeroize keys.previous = Some(old_current); } /// 이전 키 제거 (grace period 만료 후 호출) async fn clear_previous(&self) { let mut keys = self.keys.write().await; if let Some(prev) = keys.previous.take() { tracing::info!( key_id = %prev.key_id, "Previous key cleared (grace period expired)" ); // prev drop → StaticSecret zeroize } } /// 백그라운드 로테이션 태스크 시작 pub fn start_rotation_task(self: &Arc) -> tokio::task::JoinHandle<()> { let manager = Arc::clone(self); let rotation_interval = self.rotation_interval; let grace_period = self.grace_period; tokio::spawn(async move { loop { tokio::time::sleep(rotation_interval).await; tracing::info!("Key rotation triggered"); manager.rotate().await; // Grace period 후 이전 키 제거 let manager_clone = Arc::clone(&manager); tokio::spawn(async move { tokio::time::sleep(grace_period).await; manager_clone.clear_previous().await; }); } }) } } impl Drop for KeyManager { fn drop(&mut self) { tracing::info!("KeyManager dropped, all secret keys will be zeroized"); } } #[cfg(test)] mod tests { use super::*; use crate::types::EncryptedImage; use base64::{engine::general_purpose::STANDARD, Engine}; use chacha20poly1305::{ aead::{Aead, KeyInit}, XChaCha20Poly1305, XNonce, }; use hkdf::Hkdf; use sha2::Sha256; /// 테스트용: 특정 공개키로 이미지를 암호화 fn encrypt_for_key(public_key: &PublicKey, data: &[u8]) -> (EncryptedImage, String) { let ephemeral_secret = StaticSecret::random_from_rng(rand::thread_rng()); let ephemeral_public = PublicKey::from(&ephemeral_secret); let shared_secret = ephemeral_secret.diffie_hellman(public_key); let mut symmetric_key = [0u8; 32]; let hkdf = Hkdf::::new( Some(b"vessel-capsule-v1-salt"), shared_secret.as_bytes(), ); hkdf.expand(b"vessel-capsule-v1-key", &mut symmetric_key) .unwrap(); let cipher = XChaCha20Poly1305::new_from_slice(&symmetric_key).unwrap(); let nonce_bytes: [u8; 24] = rand::random(); let ciphertext = cipher .encrypt(XNonce::from_slice(&nonce_bytes), data) .unwrap(); let key_id = STANDARD.encode(public_key.as_bytes())[..16].to_string(); let encrypted = EncryptedImage { ephemeral_public_key: STANDARD.encode(ephemeral_public.as_bytes()), nonce: STANDARD.encode(nonce_bytes), ciphertext: STANDARD.encode(&ciphertext), key_id: Some(key_id.clone()), }; (encrypted, key_id) } #[tokio::test] async fn test_rotation_creates_new_key() { let km = Arc::new(KeyManager::new( Duration::from_secs(3600), Duration::from_secs(60), )); let (_, id1, _) = km.current_public_key().await; km.rotate().await; let (_, id2, _) = km.current_public_key().await; assert_ne!(id1, id2); } #[tokio::test] async fn test_decrypt_with_current_key() { let km = Arc::new(KeyManager::new( Duration::from_secs(3600), Duration::from_secs(60), )); let keys = km.keys.read().await; let public = keys.current.public; drop(keys); let (encrypted, _) = encrypt_for_key(&public, b"test image data"); let decrypted = km.decrypt(&encrypted).await.unwrap(); assert_eq!(decrypted.len(), b"test image data".len()); } #[tokio::test] async fn test_previous_key_available_after_rotation() { let km = Arc::new(KeyManager::new( Duration::from_secs(3600), Duration::from_secs(60), )); // 현재 키로 암호화 let keys = km.keys.read().await; let old_public = keys.current.public; drop(keys); let (encrypted, _) = encrypt_for_key(&old_public, b"old key data"); // 로테이션 km.rotate().await; // 이전 키로 복호화 성공해야 함 let decrypted = km.decrypt(&encrypted).await.unwrap(); assert_eq!(decrypted.len(), b"old key data".len()); } #[tokio::test] async fn test_previous_key_cleared_after_grace_period() { let km = Arc::new(KeyManager::new( Duration::from_secs(3600), Duration::from_secs(60), )); // 현재 키로 암호화 let keys = km.keys.read().await; let old_public = keys.current.public; drop(keys); let (encrypted, _) = encrypt_for_key(&old_public, b"expired data"); // 로테이션 + 이전 키 제거 km.rotate().await; km.clear_previous().await; // 이전 키가 없으므로 복호화 실패해야 함 let result = km.decrypt(&encrypted).await; assert!(result.is_err()); } #[tokio::test] async fn test_no_key_id_fallback() { let km = Arc::new(KeyManager::new( Duration::from_secs(3600), Duration::from_secs(60), )); // 현재 키로 암호화 (key_id 없이) let keys = km.keys.read().await; let old_public = keys.current.public; drop(keys); let (mut encrypted, _) = encrypt_for_key(&old_public, b"no key id data"); encrypted.key_id = None; // key_id 제거 (하위 호환 시뮬레이션) // 로테이션 후에도 fallback으로 복호화 성공 km.rotate().await; let decrypted = km.decrypt(&encrypted).await.unwrap(); assert_eq!(decrypted.len(), b"no key id data".len()); } } ================================================ FILE: apps/capsule/src/crypto/mod.rs ================================================ mod keypair; mod encryption; pub use keypair::KeyManager; pub(crate) use encryption::decrypt_with_secret; ================================================ FILE: apps/capsule/src/error.rs ================================================ use axum::{ http::StatusCode, response::{IntoResponse, Response}, Json, }; use serde_json::json; use thiserror::Error; /// Capsule error types #[derive(Error, Debug)] pub enum CapsuleError { #[error("Invalid public key format")] InvalidPublicKey, #[error("Invalid nonce format")] InvalidNonce, #[error("Invalid ciphertext format")] InvalidCiphertext, #[error("Cipher initialization failed")] CipherInitFailed, #[error("Decryption failed - invalid ciphertext or key")] DecryptionFailed, #[error("OpenAI API error: {0}")] OpenAIError(String), #[error("Configuration error: {0}")] ConfigError(String), #[error("Internal error: {0}")] Internal(String), #[error("Rate limit exceeded: {0}")] RateLimited(String), #[error("Subscription required")] SubscriptionRequired, #[error("History validation failed: {0}")] HistoryTooLarge(String), } impl IntoResponse for CapsuleError { fn into_response(self) -> Response { let (status, error_message) = match &self { CapsuleError::InvalidPublicKey | CapsuleError::InvalidNonce | CapsuleError::InvalidCiphertext => (StatusCode::BAD_REQUEST, self.to_string()), CapsuleError::DecryptionFailed => { (StatusCode::BAD_REQUEST, "Decryption failed".to_string()) } CapsuleError::CipherInitFailed | CapsuleError::Internal(_) => { // Internal errors don't expose details tracing::error!("Internal error: {}", self); ( StatusCode::INTERNAL_SERVER_ERROR, "Internal server error".to_string(), ) } CapsuleError::OpenAIError(msg) => { tracing::error!("OpenAI error: {}", msg); (StatusCode::BAD_GATEWAY, "AI service error".to_string()) } CapsuleError::ConfigError(msg) => { tracing::error!("Config error: {}", msg); ( StatusCode::INTERNAL_SERVER_ERROR, "Configuration error".to_string(), ) } CapsuleError::RateLimited(reason) => { (StatusCode::TOO_MANY_REQUESTS, reason.clone()) } CapsuleError::SubscriptionRequired => { ( StatusCode::FORBIDDEN, "Pro subscription required".to_string(), ) } CapsuleError::HistoryTooLarge(msg) => { (StatusCode::BAD_REQUEST, msg.clone()) } }; let body = Json(json!({ "error": error_message })); (status, body).into_response() } } impl From for CapsuleError { fn from(err: anyhow::Error) -> Self { CapsuleError::Internal(err.to_string()) } } ================================================ FILE: apps/capsule/src/main.rs ================================================ mod api; mod config; mod crypto; mod error; mod services; mod types; use std::sync::Arc; use std::time::Duration; use config::Config; use crypto::KeyManager; use services::{JwtValidator, OpenAIService, UsageTracker}; /// Application state /// /// # Security /// - `KeyManager` holds private keys internally with rotation support /// - Private keys are never exposed externally /// - Keys are rotated periodically to limit blast radius /// - JWT validator uses Zeroizing for secret storage /// - Usage tracker uses service key for Supabase API calls pub struct AppState { /// Key manager (encryption/decryption with rotation) pub key_manager: Arc, /// OpenAI service pub openai: OpenAIService, /// JWT validator for Supabase tokens pub jwt_validator: Arc, /// Usage tracker for rate limiting and billing pub usage_tracker: UsageTracker, } #[tokio::main] async fn main() -> anyhow::Result<()> { // Initialize logging tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::from_default_env() .add_directive("capsule=debug".parse().unwrap()), ) .init(); tracing::info!("Starting Capsule server..."); // Load configuration (API keys removed from env after loading) let config = Config::from_env()?; tracing::info!("Configuration loaded (port: {})", config.port); tracing::info!("CORS allowed origins: {:?}", config.allowed_origins); tracing::info!("Supabase URL: {}", config.supabase_url); // Router configuration let app_config = api::RouterConfig { allowed_origins: config.allowed_origins.clone(), }; // Create JWT validator (uses JWKS from Supabase for ES256 validation) let jwt_validator = Arc::new(JwtValidator::new(config.supabase_url.clone())); // Create usage tracker let usage_tracker = UsageTracker::new( config.supabase_url.clone(), config.supabase_service_key, ); // Create KeyManager with rotation configuration let key_manager = Arc::new(KeyManager::new( Duration::from_secs(config.key_rotation_interval_secs), Duration::from_secs(config.key_grace_period_secs), )); // Start background key rotation task let _rotation_handle = key_manager.start_rotation_task(); // Create application state let openai_model = config.openai_model.clone(); let state = Arc::new(AppState { key_manager: Arc::clone(&key_manager), openai: OpenAIService::new(config.openai_api_key).with_model(openai_model), jwt_validator: jwt_validator.clone(), usage_tracker, }); // Create router let app = api::create_router(state, &app_config, jwt_validator); // Start server let addr = format!("0.0.0.0:{}", config.port); let listener = tokio::net::TcpListener::bind(&addr).await?; tracing::info!("Capsule server listening on {}", addr); tracing::info!("Security: Private keys exist only in memory"); tracing::info!("Security: Key rotation enabled (interval: {}s, grace: {}s)", config.key_rotation_interval_secs, config.key_grace_period_secs); tracing::info!("Security: Decrypted images are zeroized after use"); tracing::info!("Security: API keys protected with Zeroizing"); tracing::info!("Security: JWT authentication required for chat endpoints"); tracing::info!("Security: Subscription verification via Supabase"); axum::serve(listener, app).await?; Ok(()) } ================================================ FILE: apps/capsule/src/services/jwt.rs ================================================ //! JWT validation service for Supabase tokens //! //! Validates Supabase JWT tokens using ES256 algorithm with JWKS. //! Extracts user_id (sub claim) for authentication. use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, Validation}; use serde::{Deserialize, Serialize}; use std::sync::Arc; use thiserror::Error; use tokio::sync::RwLock; /// JWT validation errors #[derive(Error, Debug)] pub enum JwtError { #[error("Invalid token format")] InvalidFormat, #[error("Token expired")] Expired, #[error("Invalid signature")] InvalidSignature, #[error("Invalid audience")] InvalidAudience, #[error("JWKS fetch failed: {0}")] JwksFetchFailed(String), #[error("No matching key found")] NoMatchingKey, #[error("Validation failed: {0}")] ValidationFailed(String), } /// JWKS response from Supabase #[derive(Debug, Deserialize)] struct JwksResponse { keys: Vec, } /// JSON Web Key #[derive(Debug, Clone, Deserialize)] struct Jwk { kty: String, #[serde(default)] kid: Option, #[serde(default)] alg: Option, /// EC curve (for ES256) #[serde(default)] crv: Option, /// EC x coordinate (base64url) #[serde(default)] x: Option, /// EC y coordinate (base64url) #[serde(default)] y: Option, } /// Supabase JWT claims /// /// Contains the standard claims from Supabase auth tokens. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SupabaseClaims { /// Subject - User ID (UUID) pub sub: String, /// Audience - Should be "authenticated" pub aud: String, /// User role pub role: String, /// Expiration timestamp pub exp: usize, /// Issued at timestamp pub iat: usize, /// User email (optional) pub email: Option, } /// Cached JWKS data struct CachedJwks { keys: Vec, fetched_at: std::time::Instant, } /// JWT validator for Supabase tokens /// /// # Security /// - Uses ES256 algorithm with JWKS public keys /// - Validates audience claim to ensure token is for authenticated users /// - Validates expiration to prevent replay attacks /// - JWKS is cached for 1 hour to reduce network calls pub struct JwtValidator { /// Supabase URL for JWKS endpoint supabase_url: String, /// HTTP client client: reqwest::Client, /// Cached JWKS jwks_cache: Arc>>, } impl JwtValidator { /// Create a new JWT validator /// /// # Arguments /// * `supabase_url` - Supabase project URL pub fn new(supabase_url: String) -> Self { Self { supabase_url, client: reqwest::Client::new(), jwks_cache: Arc::new(RwLock::new(None)), } } /// Fetch JWKS from Supabase (with caching) async fn get_jwks(&self) -> Result, JwtError> { // Check cache { let cache = self.jwks_cache.read().await; if let Some(cached) = cache.as_ref() { // Cache valid for 1 hour if cached.fetched_at.elapsed() < std::time::Duration::from_secs(3600) { return Ok(cached.keys.clone()); } } } // Fetch from Supabase let jwks_url = format!("{}/auth/v1/.well-known/jwks.json", self.supabase_url); tracing::debug!("Fetching JWKS from: {}", jwks_url); let response = self .client .get(&jwks_url) .send() .await .map_err(|e| JwtError::JwksFetchFailed(e.to_string()))?; if !response.status().is_success() { return Err(JwtError::JwksFetchFailed(format!( "HTTP {}", response.status() ))); } let jwks: JwksResponse = response .json() .await .map_err(|e| JwtError::JwksFetchFailed(e.to_string()))?; tracing::debug!("Fetched {} keys from JWKS", jwks.keys.len()); // Update cache { let mut cache = self.jwks_cache.write().await; *cache = Some(CachedJwks { keys: jwks.keys.clone(), fetched_at: std::time::Instant::now(), }); } Ok(jwks.keys) } /// Validate a JWT token and extract claims /// /// # Arguments /// * `token` - Bearer token string (without "Bearer " prefix) /// /// # Returns /// * `Ok(SupabaseClaims)` - Validated claims including user_id /// * `Err(JwtError)` - Validation failure reason /// /// # Security /// - Validates ES256 signature against JWKS public key /// - Checks "authenticated" audience claim /// - Verifies token has not expired pub async fn validate(&self, token: &str) -> Result { // Decode header to get kid let header = decode_header(token).map_err(|e| JwtError::InvalidFormat)?; tracing::debug!("JWT header: alg={:?}, kid={:?}", header.alg, header.kid); // Fetch JWKS let keys = self.get_jwks().await?; // Find matching key let jwk = if let Some(kid) = &header.kid { keys.iter().find(|k| k.kid.as_ref() == Some(kid)) } else { // Use first ES256 key if no kid keys.iter() .find(|k| k.alg.as_deref() == Some("ES256") || k.crv.as_deref() == Some("P-256")) } .ok_or(JwtError::NoMatchingKey)?; // Build decoding key from JWK let decoding_key = self.jwk_to_decoding_key(jwk)?; // Validate token let mut validation = Validation::new(Algorithm::ES256); validation.set_audience(&["authenticated"]); let token_data = decode::(token, &decoding_key, &validation).map_err(|e| { match e.kind() { jsonwebtoken::errors::ErrorKind::ExpiredSignature => JwtError::Expired, jsonwebtoken::errors::ErrorKind::InvalidSignature => JwtError::InvalidSignature, jsonwebtoken::errors::ErrorKind::InvalidAudience => JwtError::InvalidAudience, _ => JwtError::ValidationFailed(e.to_string()), } })?; Ok(token_data.claims) } /// Convert JWK to DecodingKey fn jwk_to_decoding_key(&self, jwk: &Jwk) -> Result { if jwk.kty != "EC" { return Err(JwtError::ValidationFailed(format!( "Unsupported key type: {}", jwk.kty ))); } let x = jwk .x .as_ref() .ok_or_else(|| JwtError::ValidationFailed("Missing x coordinate".to_string()))?; let y = jwk .y .as_ref() .ok_or_else(|| JwtError::ValidationFailed("Missing y coordinate".to_string()))?; // Build JWK JSON for jsonwebtoken let jwk_json = serde_json::json!({ "kty": "EC", "crv": "P-256", "x": x, "y": y }); DecodingKey::from_jwk( &serde_json::from_value(jwk_json) .map_err(|e| JwtError::ValidationFailed(format!("Invalid JWK format: {}", e)))?, ) .map_err(|e| JwtError::ValidationFailed(format!("Failed to create decoding key: {}", e))) } } #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn test_invalid_token() { let validator = JwtValidator::new("https://example.supabase.co".to_string()); let result = validator.validate("invalid-token").await; assert!(result.is_err()); } } ================================================ FILE: apps/capsule/src/services/mod.rs ================================================ mod jwt; mod openai; mod usage; pub use jwt::{JwtError, JwtValidator, SupabaseClaims}; pub use openai::{ChatResult, OpenAIService, TokenUsage}; pub use usage::{RateLimitStatus, UsageTracker}; ================================================ FILE: apps/capsule/src/services/openai.rs ================================================ use axum::response::sse::Event; use futures_util::{Stream, StreamExt}; use reqwest::Client; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::pin::Pin; use zeroize::{Zeroize, Zeroizing}; use crate::error::CapsuleError; use crate::types::{DecryptedImage, HistoryMessage}; const OPENAI_API_URL: &str = "https://api.openai.com/v1/chat/completions"; /// OpenAI API 서비스 /// /// # 보안 /// - `api_key`는 `Zeroizing`으로 보호됨 /// - Drop 시 자동으로 메모리 클리어 pub struct OpenAIService { client: Client, api_key: Zeroizing, model: String, } impl OpenAIService { /// 새 OpenAI 서비스 생성 pub fn new(api_key: Zeroizing) -> Self { Self { client: Client::new(), api_key, model: "gpt-4o".to_string(), } } /// 모델 설정 pub fn with_model(mut self, model: String) -> Self { self.model = model; self } /// 메시지 배열 빌드: system_prompt → history → current_message fn build_messages( system_prompt: Option<&str>, history: Option<&[HistoryMessage]>, current_message: Message, ) -> Vec { let mut messages = Vec::new(); if let Some(prompt) = system_prompt { messages.push(Message { role: "system".to_string(), content: Some(MessageContent::Text(prompt.to_string())), tool_call_id: None, tool_calls: None, }); } if let Some(history) = history { for msg in history { let mut m = Message { role: msg.role.clone(), content: Some(MessageContent::Text(msg.content.clone())), tool_call_id: None, tool_calls: None, }; if let Some(ref tc_id) = msg.tool_call_id { m.tool_call_id = Some(tc_id.clone()); } if let Some(ref tc) = msg.tool_calls { m.tool_calls = Some(tc.clone()); if msg.content.is_empty() { m.content = None; } } messages.push(m); } } messages.push(current_message); messages } /// 텍스트 전용 채팅 pub async fn chat( &self, message: &str, system_prompt: Option<&str>, history: Option<&[HistoryMessage]>, tools: Option<&serde_json::Value>, tool_choice: Option<&serde_json::Value>, ) -> Result { let current = Message { role: "user".to_string(), content: Some(MessageContent::Text(message.to_string())), tool_call_id: None, tool_calls: None, }; let request_body = ChatRequest { model: self.model.clone(), messages: Self::build_messages(system_prompt, history, current), max_tokens: Some(4096), stream: false, stream_options: None, tools: tools.cloned(), tool_choice: tool_choice.cloned(), }; let response = self .client .post(OPENAI_API_URL) .header("Authorization", format!("Bearer {}", &*self.api_key)) .header("Content-Type", "application/json") .json(&request_body) .send() .await?; if !response.status().is_success() { let error_text = response.text().await?; return Err(anyhow::anyhow!("OpenAI API error: {}", error_text)); } let response_body: ChatResponse = response.json().await?; let choice = response_body.choices.first(); Ok(ChatResult { content: choice .and_then(|c| c.message.content.clone()) .unwrap_or_default(), tool_calls: choice.and_then(|c| c.message.tool_calls.clone()), usage: response_body.usage.into(), }) } /// 이미지 분석 요청 /// /// # 보안 /// - `decrypted_image`는 소유권이 이전됨 /// - 함수 종료 시 자동으로 drop → zeroize /// - base64 문자열도 사용 후 명시적 zeroize pub async fn analyze_image( &self, message: &str, decrypted_image: DecryptedImage, system_prompt: Option<&str>, history: Option<&[HistoryMessage]>, tools: Option<&serde_json::Value>, tool_choice: Option<&serde_json::Value>, ) -> Result { let mut image_base64 = decrypted_image.to_base64(); drop(decrypted_image); let current = Message { role: "user".to_string(), content: Some(MessageContent::MultiPart(vec![ ContentPart::Text { text: message.to_string(), }, ContentPart::ImageUrl { image_url: ImageUrl { url: format!("data:image/jpeg;base64,{}", image_base64), }, }, ])), tool_call_id: None, tool_calls: None, }; let request_body = ChatRequest { model: self.model.clone(), messages: Self::build_messages(system_prompt, history, current), max_tokens: Some(4096), stream: false, stream_options: None, tools: tools.cloned(), tool_choice: tool_choice.cloned(), }; let response = self .client .post(OPENAI_API_URL) .header("Authorization", format!("Bearer {}", &*self.api_key)) .header("Content-Type", "application/json") .json(&request_body) .send() .await; image_base64.zeroize(); let response = response?; if !response.status().is_success() { let error_text = response.text().await?; return Err(anyhow::anyhow!("OpenAI API error: {}", error_text)); } let response_body: ChatResponse = response.json().await?; let choice = response_body.choices.first(); Ok(ChatResult { content: choice .and_then(|c| c.message.content.clone()) .unwrap_or_default(), tool_calls: choice.and_then(|c| c.message.tool_calls.clone()), usage: response_body.usage.into(), }) } /// 텍스트 전용 스트리밍 채팅 /// /// Returns a stream of SSE events and a shared usage tracker. /// The usage will be populated when the stream completes. pub async fn chat_stream( &self, message: &str, system_prompt: Option<&str>, history: Option<&[HistoryMessage]>, tools: Option<&serde_json::Value>, tool_choice: Option<&serde_json::Value>, ) -> Result<(Pin> + Send>>, std::sync::Arc>), anyhow::Error> { let current = Message { role: "user".to_string(), content: Some(MessageContent::Text(message.to_string())), tool_call_id: None, tool_calls: None, }; let request_body = ChatRequest { model: self.model.clone(), messages: Self::build_messages(system_prompt, history, current), max_tokens: Some(4096), stream: true, stream_options: Some(StreamOptions { include_usage: true }), tools: tools.cloned(), tool_choice: tool_choice.cloned(), }; let response = self .client .post(OPENAI_API_URL) .header("Authorization", format!("Bearer {}", &*self.api_key)) .header("Content-Type", "application/json") .json(&request_body) .send() .await?; if !response.status().is_success() { let error_text = response.text().await?; return Err(anyhow::anyhow!("OpenAI API error: {}", error_text)); } let usage = std::sync::Arc::new(std::sync::Mutex::new(TokenUsage::default())); let stream = Box::pin(Self::process_stream_with_usage(response, usage.clone())); Ok((stream, usage)) } /// 이미지 분석 스트리밍 요청 /// /// Returns a stream of SSE events and a shared usage tracker. /// The usage will be populated when the stream completes. pub async fn analyze_image_stream( &self, message: &str, decrypted_image: DecryptedImage, system_prompt: Option<&str>, history: Option<&[HistoryMessage]>, tools: Option<&serde_json::Value>, tool_choice: Option<&serde_json::Value>, ) -> Result<(Pin> + Send>>, std::sync::Arc>), anyhow::Error> { let mut image_base64 = decrypted_image.to_base64(); drop(decrypted_image); let current = Message { role: "user".to_string(), content: Some(MessageContent::MultiPart(vec![ ContentPart::Text { text: message.to_string(), }, ContentPart::ImageUrl { image_url: ImageUrl { url: format!("data:image/jpeg;base64,{}", image_base64), }, }, ])), tool_call_id: None, tool_calls: None, }; let request_body = ChatRequest { model: self.model.clone(), messages: Self::build_messages(system_prompt, history, current), max_tokens: Some(4096), stream: true, stream_options: Some(StreamOptions { include_usage: true }), tools: tools.cloned(), tool_choice: tool_choice.cloned(), }; let response = self .client .post(OPENAI_API_URL) .header("Authorization", format!("Bearer {}", &*self.api_key)) .header("Content-Type", "application/json") .json(&request_body) .send() .await; image_base64.zeroize(); let response = response?; if !response.status().is_success() { let error_text = response.text().await?; return Err(anyhow::anyhow!("OpenAI API error: {}", error_text)); } let usage = std::sync::Arc::new(std::sync::Mutex::new(TokenUsage::default())); let stream = Box::pin(Self::process_stream_with_usage(response, usage.clone())); Ok((stream, usage)) } /// SSE 스트림 처리 (with usage tracking and tool call accumulation) fn process_stream_with_usage( response: reqwest::Response, usage: std::sync::Arc>, ) -> impl Stream> { async_stream::stream! { let mut stream = response.bytes_stream(); let mut tool_call_buffer: HashMap = HashMap::new(); while let Some(chunk) = stream.next().await { match chunk { Ok(bytes) => { let text = String::from_utf8_lossy(&bytes); for line in text.lines() { if line.starts_with("data: ") { let data = &line[6..]; if data == "[DONE]" { // Emit accumulated tool calls before [DONE] if any if !tool_call_buffer.is_empty() { let mut entries: Vec<_> = tool_call_buffer.drain().collect(); entries.sort_by_key(|(idx, _)| *idx); let tool_calls: Vec = entries .into_iter() .map(|(_, tc)| tc.to_json()) .collect(); if let Ok(json) = serde_json::to_string(&tool_calls) { yield Ok(Event::default().data(format!("[TOOL_CALLS]{}", json))); } } yield Ok(Event::default().data("[DONE]")); return; } if let Ok(chunk) = serde_json::from_str::(data) { if let Some(u) = chunk.usage { if let Ok(mut usage_guard) = usage.lock() { usage_guard.prompt_tokens = u.prompt_tokens; usage_guard.completion_tokens = u.completion_tokens; usage_guard.total_tokens = u.total_tokens; } } if let Some(choice) = chunk.choices.first() { // Stream text content if let Some(content) = &choice.delta.content { yield Ok(Event::default().data(content.clone())); } // Accumulate tool calls by index if let Some(ref tcs) = choice.delta.tool_calls { for tc in tcs { let entry = tool_call_buffer .entry(tc.index) .or_insert_with(AccumulatedToolCall::default); if let Some(ref id) = tc.id { entry.id = id.clone(); } if let Some(ref func) = tc.function { if let Some(ref name) = func.name { entry.function_name = name.clone(); } if let Some(ref args) = func.arguments { entry.arguments.push_str(args); } } } } // Emit tool calls when finish_reason is "tool_calls" if choice.finish_reason.as_deref() == Some("tool_calls") && !tool_call_buffer.is_empty() { let mut entries: Vec<_> = tool_call_buffer.drain().collect(); entries.sort_by_key(|(idx, _)| *idx); let tool_calls: Vec = entries .into_iter() .map(|(_, tc)| tc.to_json()) .collect(); if let Ok(json) = serde_json::to_string(&tool_calls) { yield Ok(Event::default().data(format!("[TOOL_CALLS]{}", json))); } } } } } } } Err(e) => { yield Err(CapsuleError::OpenAIError(e.to_string())); return; } } } } } } // -- Accumulated tool call buffer -- #[derive(Default)] struct AccumulatedToolCall { id: String, function_name: String, arguments: String, } impl AccumulatedToolCall { fn to_json(&self) -> serde_json::Value { serde_json::json!({ "id": self.id, "type": "function", "function": { "name": self.function_name, "arguments": self.arguments, } }) } } // -- OpenAI API request/response types -- #[derive(Serialize)] struct ChatRequest { model: String, messages: Vec, #[serde(skip_serializing_if = "Option::is_none")] max_tokens: Option, stream: bool, #[serde(skip_serializing_if = "Option::is_none")] stream_options: Option, #[serde(skip_serializing_if = "Option::is_none")] tools: Option, #[serde(skip_serializing_if = "Option::is_none")] tool_choice: Option, } #[derive(Serialize)] struct StreamOptions { include_usage: bool, } #[derive(Serialize)] struct Message { role: String, #[serde(skip_serializing_if = "Option::is_none")] content: Option, #[serde(skip_serializing_if = "Option::is_none")] tool_call_id: Option, #[serde(skip_serializing_if = "Option::is_none")] tool_calls: Option, } #[derive(Serialize)] #[serde(untagged)] enum MessageContent { Text(String), MultiPart(Vec), } #[derive(Serialize)] #[serde(tag = "type")] enum ContentPart { #[serde(rename = "text")] Text { text: String }, #[serde(rename = "image_url")] ImageUrl { image_url: ImageUrl }, } #[derive(Serialize)] struct ImageUrl { url: String, } /// Token usage information from OpenAI API #[derive(Debug, Clone, Default, Deserialize)] pub struct TokenUsage { pub prompt_tokens: i32, pub completion_tokens: i32, pub total_tokens: i32, } /// Result of a chat request including content and token usage #[derive(Debug, Clone)] pub struct ChatResult { pub content: String, pub tool_calls: Option, pub usage: TokenUsage, } #[derive(Deserialize)] struct ChatResponse { choices: Vec, usage: Option, } #[derive(Deserialize)] struct Usage { prompt_tokens: i32, completion_tokens: i32, total_tokens: i32, } impl From> for TokenUsage { fn from(usage: Option) -> Self { match usage { Some(u) => TokenUsage { prompt_tokens: u.prompt_tokens, completion_tokens: u.completion_tokens, total_tokens: u.total_tokens, }, None => TokenUsage::default(), } } } #[derive(Deserialize)] struct Choice { message: ResponseMessage, } #[derive(Deserialize)] struct ResponseMessage { content: Option, tool_calls: Option, } #[derive(Deserialize)] struct StreamChunk { choices: Vec, usage: Option, } #[derive(Deserialize)] struct StreamChoice { delta: Delta, #[serde(default)] finish_reason: Option, } #[derive(Deserialize)] struct Delta { content: Option, #[serde(default)] tool_calls: Option>, } #[derive(Deserialize)] struct StreamToolCall { index: u32, id: Option, #[serde(default)] function: Option, } #[derive(Deserialize, Default)] struct StreamToolCallFunction { name: Option, arguments: Option, } ================================================ FILE: apps/capsule/src/services/usage.rs ================================================ //! Usage tracking service for rate limiting and billing //! //! Communicates with Supabase to: //! 1. Check subscription status (billing_subscriptions table) //! 2. Check rate limits (enclave_rate_limits table) //! 3. Track usage (enclave_usage table) use reqwest::Client; use serde::Deserialize; use zeroize::Zeroizing; /// Rate limit check result from Supabase RPC #[derive(Debug, Deserialize)] pub struct RateLimitStatus { /// Whether the request is allowed pub allowed: bool, /// Reason for denial (if not allowed) pub reason: Option, /// Whether user has active subscription pub subscribed: bool, /// Remaining requests for today pub requests_remaining: i32, /// Remaining tokens for today pub tokens_remaining: i32, } /// Usage tracking service /// /// # Security /// - Service key is stored in `Zeroizing` /// - All requests use HTTPS to Supabase /// - Fire-and-forget usage tracking doesn't block responses #[derive(Clone)] pub struct UsageTracker { client: Client, supabase_url: String, service_key: Zeroizing, } impl UsageTracker { /// Create a new usage tracker /// /// # Arguments /// * `supabase_url` - Supabase project URL /// * `service_key` - Supabase service role key (NOT anon key) pub fn new(supabase_url: String, service_key: Zeroizing) -> Self { Self { client: Client::new(), supabase_url, service_key, } } /// Check if user can make a request /// /// Calls the `check_rate_limit` RPC function which: /// 1. Verifies active subscription in billing_subscriptions /// 2. Checks daily request/token limits /// /// # Arguments /// * `user_id` - User UUID from JWT claims /// /// # Returns /// * `RateLimitStatus` with allowed flag and remaining quotas pub async fn check_rate_limit(&self, user_id: &str) -> Result { let url = format!("{}/rest/v1/rpc/check_rate_limit", self.supabase_url); tracing::debug!(user_id = %user_id, "Checking rate limit"); let response = self .client .post(&url) .header("apikey", self.service_key.as_str()) .header( "Authorization", format!("Bearer {}", self.service_key.as_str()), ) .header("Content-Type", "application/json") .json(&serde_json::json!({ "p_user_id": user_id })) .send() .await?; if !response.status().is_success() { let status_code = response.status(); let error_text = response.text().await.unwrap_or_default(); tracing::error!( status = %status_code, error = %error_text, "check_rate_limit RPC failed - check if function exists in Supabase" ); return Err(anyhow::anyhow!("Supabase RPC error: {}", error_text)); } let status = response.json::().await?; tracing::debug!( allowed = status.allowed, subscribed = status.subscribed, requests_remaining = status.requests_remaining, "Rate limit check result" ); Ok(status) } /// Track a completed request /// /// Calls the `increment_usage` RPC function to record: /// - Request count /// - Input/output tokens /// - Image request flag /// /// # Note /// This is designed to be called in a fire-and-forget manner /// using `tokio::spawn` to not block the response. /// /// # Arguments /// * `user_id` - User UUID from JWT claims /// * `input_tokens` - Number of input tokens used /// * `output_tokens` - Number of output tokens generated /// * `is_image_request` - Whether this was an image analysis request pub async fn track_request( &self, user_id: &str, input_tokens: i32, output_tokens: i32, is_image_request: bool, ) -> Result<(), anyhow::Error> { let today = chrono::Utc::now().format("%Y-%m-%d").to_string(); let url = format!("{}/rest/v1/rpc/increment_usage", self.supabase_url); tracing::debug!( user_id = %user_id, date = %today, is_image = is_image_request, "Tracking usage request" ); let response = self .client .post(&url) .header("apikey", self.service_key.as_str()) .header( "Authorization", format!("Bearer {}", self.service_key.as_str()), ) .header("Content-Type", "application/json") .json(&serde_json::json!({ "p_user_id": user_id, "p_date": today, "p_request_count": 1, "p_input_tokens": input_tokens, "p_output_tokens": output_tokens, "p_image_requests": if is_image_request { 1 } else { 0 } })) .send() .await?; if !response.status().is_success() { let status = response.status(); let error_text = response.text().await.unwrap_or_default(); tracing::error!( status = %status, error = %error_text, "Failed to track usage - check if increment_usage RPC function exists" ); return Err(anyhow::anyhow!("Supabase RPC error: {}", error_text)); } tracing::info!(user_id = %user_id, "Usage tracked successfully"); Ok(()) } } ================================================ FILE: apps/capsule/src/types/decrypted.rs ================================================ use base64::{engine::general_purpose::STANDARD, Engine}; use zeroize::{Zeroize, ZeroizeOnDrop}; /// 복호화된 이미지 - Capsule 내부에서만 존재 /// /// # 보안 특성 /// - `Debug` 미구현: 로그에 출력 불가 /// - `Display` 미구현: `println!` 등으로 출력 불가 /// - `Clone` 미구현: 복사 불가 /// - `Zeroize` + `ZeroizeOnDrop`: Drop 시 자동 메모리 클리어 /// /// # 컴파일 타임 보안 /// ```compile_fail /// let img = DecryptedImage::new(vec![1, 2, 3]); /// println!("{:?}", img); // 컴파일 에러: Debug 미구현 /// ``` /// /// ```compile_fail /// let img = DecryptedImage::new(vec![1, 2, 3]); /// let copy = img.clone(); // 컴파일 에러: Clone 미구현 /// ``` #[derive(Zeroize, ZeroizeOnDrop)] pub struct DecryptedImage { /// 복호화된 이미지 데이터 (private 필드) data: Vec, } impl DecryptedImage { /// 내부에서만 생성 가능 (crate 내부 가시성) pub(crate) fn new(data: Vec) -> Self { Self { data } } /// OpenAI API 호출용 base64 인코딩 /// /// # 주의 /// 반환된 `String`도 사용 후 명시적으로 zeroize 권장 /// /// # Example /// ``` /// let mut base64_str = decrypted.to_base64(); /// // ... OpenAI API 호출 ... /// base64_str.zeroize(); // 명시적 클리어 /// ``` pub fn to_base64(&self) -> String { STANDARD.encode(&self.data) } /// 이미지 크기 (바이트) pub fn len(&self) -> usize { self.data.len() } /// 이미지가 비어있는지 확인 pub fn is_empty(&self) -> bool { self.data.is_empty() } } // Debug, Display, Clone을 의도적으로 구현하지 않음 // 이는 컴파일 타임에 민감 데이터 노출을 방지함 ================================================ FILE: apps/capsule/src/types/encrypted.rs ================================================ use serde::{Deserialize, Serialize}; use crate::error::CapsuleError; /// 최대 히스토리 메시지 수 const MAX_HISTORY_MESSAGES: usize = 50; /// 최대 히스토리 총 문자 수 (~25k 토큰) const MAX_HISTORY_CHARS: usize = 100_000; /// 허용되는 메시지 역할 const ALLOWED_ROLES: &[&str] = &["user", "assistant", "system", "tool"]; /// 클라이언트에서 전송된 암호화된 이미지 /// 이 타입은 안전하게 로깅 가능 (민감 데이터 없음 - 복호화 불가) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct EncryptedImage { /// 클라이언트의 임시 공개키 (32 bytes, base64) pub ephemeral_public_key: String, /// Nonce (24 bytes, base64) pub nonce: String, /// 암호화된 이미지 데이터 (base64) pub ciphertext: String, /// 암호화에 사용된 서버 키 ID (하위 호환을 위해 선택사항) #[serde(default)] pub key_id: Option, } /// 대화 히스토리의 단일 메시지 /// /// 클라이언트가 관리하며, 서버는 저장하지 않음 (zero-knowledge) /// 이미지는 텍스트 요약으로만 포함 가능 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct HistoryMessage { /// 메시지 역할: "user", "assistant", "system", 또는 "tool" pub role: String, /// 텍스트 내용 pub content: String, /// Tool call ID (role이 "tool"일 때 필수) #[serde(default, skip_serializing_if = "Option::is_none")] pub tool_call_id: Option, /// Tool calls (role이 "assistant"이고 tool call 응답일 때) #[serde(default, skip_serializing_if = "Option::is_none")] pub tool_calls: Option, } /// 채팅 요청 #[derive(Debug, Deserialize)] pub struct ChatRequest { /// 사용자 메시지 pub message: String, /// 암호화된 이미지 (선택사항) #[serde(default)] pub encrypted_image: Option, /// 대화 히스토리 (선택사항, 오래된 순서) #[serde(default)] pub history: Option>, /// 시스템 프롬프트 (선택사항) #[serde(default)] pub system_prompt: Option, /// OpenAI tools 정의 (프론트엔드에서 주입, 그대로 passthrough) #[serde(default)] pub tools: Option, /// OpenAI tool_choice (프론트엔드에서 주입, 그대로 passthrough) /// "auto" | "required" | "none" | {"type":"function","function":{"name":"..."}} #[serde(default)] pub tool_choice: Option, } impl ChatRequest { /// 히스토리 유효성 검사 pub fn validate_history(&self) -> Result<(), CapsuleError> { if let Some(history) = &self.history { if history.len() > MAX_HISTORY_MESSAGES { return Err(CapsuleError::HistoryTooLarge(format!( "History contains {} messages, maximum is {}", history.len(), MAX_HISTORY_MESSAGES ))); } let total_chars: usize = history.iter().map(|m| m.content.len()).sum(); if total_chars > MAX_HISTORY_CHARS { return Err(CapsuleError::HistoryTooLarge(format!( "History total size is {} characters, maximum is {}", total_chars, MAX_HISTORY_CHARS ))); } for msg in history { if !ALLOWED_ROLES.contains(&msg.role.as_str()) { return Err(CapsuleError::HistoryTooLarge(format!( "Invalid role '{}', allowed: {:?}", msg.role, ALLOWED_ROLES ))); } } } Ok(()) } } /// 채팅 응답 #[derive(Debug, Serialize)] pub struct ChatResponse { /// AI 응답 텍스트 pub response: String, /// Tool calls (함수 호출 요청, 있을 경우) #[serde(skip_serializing_if = "Option::is_none")] pub tool_calls: Option, } /// Public Key 응답 #[derive(Debug, Serialize)] pub struct PublicKeyResponse { /// 서버 공개키 (base64) pub public_key: String, /// 키 고유 식별자 pub key_id: String, /// 키 만료 예상 시각 (RFC 3339) - 클라이언트 캐시 갱신 힌트 pub expires_at: String, } /// 스트리밍 청크 #[derive(Debug, Serialize)] pub struct StreamChunk { /// 응답 텍스트 조각 pub content: String, /// 완료 여부 pub done: bool, } ================================================ FILE: apps/capsule/src/types/mod.rs ================================================ mod encrypted; mod decrypted; pub use encrypted::*; pub use decrypted::*; ================================================ FILE: apps/capsule/tests/api.test.mjs ================================================ /** * Capsule API 테스트 * * 사용법: * cd apps/capsule/tests * npm install * npm test # 텍스트 전용 테스트 * npm test ./path/to/image.jpg # 이미지 분석 테스트 */ import { readFileSync } from 'fs'; import { encryptImage } from './crypto.mjs'; const BASE_URL = process.env.CAPSULE_URL || 'http://localhost:3000'; // 테스트 결과 추적 const results = { passed: 0, failed: 0, tests: [], }; /** 테스트 실행 헬퍼 */ async function test(name, fn) { process.stdout.write(` ${name}... `); try { await fn(); console.log('✓'); results.passed++; results.tests.push({ name, status: 'passed' }); } catch (error) { console.log('✗'); console.log(` Error: ${error.message}`); results.failed++; results.tests.push({ name, status: 'failed', error: error.message }); } } /** 단언 헬퍼 */ function assert(condition, message) { if (!condition) { throw new Error(message || 'Assertion failed'); } } function assertEqual(actual, expected, message) { if (actual !== expected) { throw new Error(message || `Expected ${expected}, got ${actual}`); } } /** API 호출 헬퍼 */ async function api(path, options = {}) { const response = await fetch(`${BASE_URL}${path}`, { headers: { 'Content-Type': 'application/json' }, ...options, }); if (!response.ok) { const error = await response.text(); throw new Error(`API Error ${response.status}: ${error}`); } const contentType = response.headers.get('content-type'); if (contentType && contentType.includes('application/json')) { return response.json(); } return response.text(); } // ============================================ // 테스트 케이스 // ============================================ async function testHealthCheck() { const response = await fetch(`${BASE_URL}/health`); assertEqual(response.status, 200, 'Health check should return 200'); const text = await response.text(); assertEqual(text, 'OK', 'Health check should return OK'); } async function testPublicKey() { const data = await api('/api/public-key'); assert(data.public_key, 'Response should have public_key'); assert(data.public_key.length > 0, 'Public key should not be empty'); // Base64 encoded 32 bytes = 44 characters assertEqual(data.public_key.length, 44, 'Public key should be 44 chars (base64 of 32 bytes)'); return data.public_key; } async function testTextChat() { const data = await api('/api/chat', { method: 'POST', body: JSON.stringify({ message: '1+1은?' }), }); assert(data.response, 'Response should have response field'); assert(data.response.length > 0, 'Response should not be empty'); } async function testTextChatEmptyMessage() { const data = await api('/api/chat', { method: 'POST', body: JSON.stringify({ message: '' }), }); // 빈 메시지도 처리되어야 함 assert(data.response !== undefined, 'Response should exist'); } async function testImageEncryption(publicKey) { // 테스트용 더미 이미지 데이터 (1x1 JPEG) const dummyJpeg = new Uint8Array([ 0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0xff, 0xdb, 0x00, 0x43, 0x00, 0x08, 0x06, 0x06, 0x07, 0x06, 0x05, 0x08, 0x07, 0x07, 0x07, 0x09, 0x09, 0x08, 0x0a, 0x0c, 0x14, 0x0d, 0x0c, 0x0b, 0x0b, 0x0c, 0x19, 0x12, 0x13, 0x0f, 0x14, 0x1d, 0x1a, 0x1f, 0x1e, 0x1d, 0x1a, 0x1c, 0x1c, 0x20, 0x24, 0x2e, 0x27, 0x20, 0x22, 0x2c, 0x23, 0x1c, 0x1c, 0x28, 0x37, 0x29, 0x2c, 0x30, 0x31, 0x34, 0x34, 0x34, 0x1f, 0x27, 0x39, 0x3d, 0x38, 0x32, 0x3c, 0x2e, 0x33, 0x34, 0x32, 0xff, 0xc0, 0x00, 0x0b, 0x08, 0x00, 0x01, 0x00, 0x01, 0x01, 0x01, 0x11, 0x00, 0xff, 0xc4, 0x00, 0x1f, 0x00, 0x00, 0x01, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0xff, 0xc4, 0x00, 0xb5, 0x10, 0x00, 0x02, 0x01, 0x03, 0x03, 0x02, 0x04, 0x03, 0x05, 0x05, 0x04, 0x04, 0x00, 0x00, 0x01, 0x7d, 0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21, 0x31, 0x41, 0x06, 0x13, 0x51, 0x61, 0x07, 0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xa1, 0x08, 0x23, 0x42, 0xb1, 0xc1, 0x15, 0x52, 0xd1, 0xf0, 0x24, 0x33, 0x62, 0x72, 0x82, 0x09, 0x0a, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4a, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5a, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6a, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7a, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8a, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9a, 0xa2, 0xa3, 0xa4, 0xa5, 0xa6, 0xa7, 0xa8, 0xa9, 0xaa, 0xb2, 0xb3, 0xb4, 0xb5, 0xb6, 0xb7, 0xb8, 0xb9, 0xba, 0xc2, 0xc3, 0xc4, 0xc5, 0xc6, 0xc7, 0xc8, 0xc9, 0xca, 0xd2, 0xd3, 0xd4, 0xd5, 0xd6, 0xd7, 0xd8, 0xd9, 0xda, 0xe1, 0xe2, 0xe3, 0xe4, 0xe5, 0xe6, 0xe7, 0xe8, 0xe9, 0xea, 0xf1, 0xf2, 0xf3, 0xf4, 0xf5, 0xf6, 0xf7, 0xf8, 0xf9, 0xfa, 0xff, 0xda, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3f, 0x00, 0xfb, 0xd5, 0xdb, 0x20, 0xa8, 0xf1, 0x45, 0x10, 0xff, 0xd9, ]); const encrypted = await encryptImage(dummyJpeg, publicKey); assert(encrypted.ephemeral_public_key, 'Should have ephemeral_public_key'); assert(encrypted.nonce, 'Should have nonce'); assert(encrypted.ciphertext, 'Should have ciphertext'); assertEqual(encrypted.ephemeral_public_key.length, 44, 'Ephemeral public key should be 44 chars'); assertEqual(encrypted.nonce.length, 32, 'Nonce should be 32 chars (24 bytes base64)'); assert(encrypted.ciphertext.length > 0, 'Ciphertext should not be empty'); } async function testImageAnalysis(imagePath, publicKey) { // 이미지 로드 const imageData = new Uint8Array(readFileSync(imagePath)); console.log(`\n Image: ${imagePath} (${imageData.length} bytes)`); // 이미지 암호화 const encrypted = await encryptImage(imageData, publicKey); console.log(` Encrypted: ${encrypted.ciphertext.length} chars (base64)`); // API 호출 const data = await api('/api/chat', { method: 'POST', body: JSON.stringify({ message: '이 이미지에 무엇이 있는지 간단히 설명해주세요.', encrypted_image: encrypted, }), }); assert(data.response, 'Response should have response field'); assert(data.response.length > 0, 'Response should not be empty'); console.log(` Response: ${data.response.substring(0, 100)}...`); } async function testInvalidEncryptedImage() { try { await api('/api/chat', { method: 'POST', body: JSON.stringify({ message: 'test', encrypted_image: { ephemeral_public_key: 'invalid', nonce: 'invalid', ciphertext: 'invalid', }, }), }); throw new Error('Should have thrown error for invalid encrypted image'); } catch (error) { assert(error.message.includes('API Error'), 'Should return API error'); } } // ============================================ // 메인 // ============================================ async function main() { console.log(`\nCapsule API Tests`); console.log(`Server: ${BASE_URL}`); console.log('─'.repeat(50)); let publicKey = null; // 기본 테스트 console.log('\n[Basic Tests]'); await test('Health check', testHealthCheck); await test('Get public key', async () => { publicKey = await testPublicKey(); }); // 텍스트 채팅 테스트 console.log('\n[Text Chat Tests]'); await test('Text chat', testTextChat); await test('Empty message', testTextChatEmptyMessage); // 암호화 테스트 console.log('\n[Encryption Tests]'); await test('Image encryption', () => testImageEncryption(publicKey)); await test('Invalid encrypted image', testInvalidEncryptedImage); // 이미지 분석 테스트 (이미지 경로가 제공된 경우) const imagePath = process.argv[2]; if (imagePath) { console.log('\n[Image Analysis Tests]'); await test('Image analysis', () => testImageAnalysis(imagePath, publicKey)); } else { console.log('\n[Image Analysis Tests]'); console.log(' Skipped (no image path provided)'); console.log(' Usage: npm test ./path/to/image.jpg'); } // 결과 출력 console.log('\n' + '─'.repeat(50)); console.log(`Results: ${results.passed} passed, ${results.failed} failed`); if (results.failed > 0) { console.log('\nFailed tests:'); results.tests .filter((t) => t.status === 'failed') .forEach((t) => console.log(` - ${t.name}: ${t.error}`)); process.exit(1); } console.log('\nAll tests passed! ✓\n'); } main().catch((error) => { console.error('\nTest suite failed:', error.message); process.exit(1); }); ================================================ FILE: apps/capsule/tests/crypto.mjs ================================================ /** * 클라이언트 측 암호화 유틸리티 * 서버의 암호화 스킴과 동일하게 구현 */ import { x25519 } from '@noble/curves/ed25519'; import { xchacha20poly1305 } from '@noble/ciphers/chacha'; import { hkdf } from '@noble/hashes/hkdf'; import { sha256 } from '@noble/hashes/sha256'; import { randomBytes } from '@noble/ciphers/webcrypto'; /** HKDF 상수 (서버와 동일해야 함) */ const HKDF_SALT = new TextEncoder().encode('vessel-capsule-v1-salt'); const HKDF_INFO = new TextEncoder().encode('vessel-capsule-v1-key'); /** Base64 인코딩 */ export function bytesToBase64(bytes) { return Buffer.from(bytes).toString('base64'); } /** Base64 디코딩 */ export function base64ToBytes(base64) { return new Uint8Array(Buffer.from(base64, 'base64')); } /** * 이미지를 서버 공개키로 암호화 * * @param {Uint8Array} imageData - 암호화할 이미지 데이터 * @param {string} serverPublicKeyBase64 - 서버 공개키 (base64) * @returns {Promise} 암호화된 이미지 데이터 */ export async function encryptImage(imageData, serverPublicKeyBase64) { // 1. 서버 공개키 디코딩 const serverPublicKey = base64ToBytes(serverPublicKeyBase64); // 2. 클라이언트 임시 키 쌍 생성 const ephemeralPrivateKey = randomBytes(32); const ephemeralPublicKey = x25519.getPublicKey(ephemeralPrivateKey); // 3. Shared secret 계산 (X25519 ECDH) const sharedSecret = x25519.getSharedSecret(ephemeralPrivateKey, serverPublicKey); // 4. HKDF로 대칭키 유도 const symmetricKey = hkdf(sha256, sharedSecret, HKDF_SALT, HKDF_INFO, 32); // 5. Nonce 생성 (24 bytes) const nonce = randomBytes(24); // 6. XChaCha20-Poly1305로 암호화 const cipher = xchacha20poly1305(symmetricKey, nonce); const ciphertext = cipher.encrypt(imageData); // 민감 데이터 클리어 ephemeralPrivateKey.fill(0); sharedSecret.fill(0); symmetricKey.fill(0); return { ephemeral_public_key: bytesToBase64(ephemeralPublicKey), nonce: bytesToBase64(nonce), ciphertext: bytesToBase64(ciphertext), }; } ================================================ FILE: apps/capsule/tests/package.json ================================================ { "name": "capsule-tests", "type": "module", "private": true, "scripts": { "test": "node api.test.mjs", "test:image": "node api.test.mjs" }, "dependencies": { "@noble/ciphers": "^0.5.0", "@noble/curves": "^1.3.0", "@noble/hashes": "^1.3.3" } } ================================================ FILE: apps/client/.gitignore ================================================ # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* lerna-debug.log* node_modules dist dist-ssr *.local # Editor directories and files .vscode/* !.vscode/extensions.json .idea .DS_Store *.suo *.ntvs* *.njsproj *.sln *.sw? maindist ================================================ FILE: apps/client/README.md ================================================ # client ================================================ FILE: apps/client/components.json ================================================ { "$schema": "https://ui.shadcn.com/schema.json", "style": "new-york", "rsc": false, "tsx": true, "tailwind": { "config": "tailwind.config.js", "css": "src/index.css", "baseColor": "neutral", "cssVariables": true, "prefix": "" }, "aliases": { "components": "@/components", "utils": "@/lib/utils", "ui": "@/components/ui", "lib": "@/lib", "hooks": "@/hooks" }, "iconLibrary": "lucide" } ================================================ FILE: apps/client/eslint.config.js ================================================ import js from "@eslint/js"; import globals from "globals"; import reactHooks from "eslint-plugin-react-hooks"; import reactRefresh from "eslint-plugin-react-refresh"; import tseslint from "typescript-eslint"; export default tseslint.config( { ignores: ["dist"] }, { extends: [js.configs.recommended, ...tseslint.configs.recommended], files: ["**/*.{ts,tsx}"], languageOptions: { ecmaVersion: 2020, globals: globals.browser, }, plugins: { "react-hooks": reactHooks, "react-refresh": reactRefresh, }, rules: { ...reactHooks.configs.recommended.rules, "react-refresh/only-export-components": [ "warn", { allowConstantExport: true }, ], }, }, ); ================================================ FILE: apps/client/index.html ================================================ Vessel
================================================ FILE: apps/client/package.json ================================================ { "name": "client", "private": true, "version": "0.0.0", "type": "module", "main": "maindist/main.js", "scripts": { "dev": "concurrently \"npm run dev:vite\"", "dev:vite": "vite --host", "build:main": "tsc -p ./configs/electron", "build:app": "tsc -b && vite build", "build:vite": "tsc -b && vite build", "build": "vite build", "lint": "eslint .", "preview": "vite preview", "test": "vitest run", "test:watch": "vitest" }, "dependencies": { "@vessel/capsule-client": "file:../../packages/capsule-client", "@codemirror/lang-json": "^6.0.2", "@emotion/react": "^11.14.0", "@monaco-editor/react": "^4.7.0", "@radix-ui/react-alert-dialog": "^1.1.14", "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-navigation-menu": "^1.2.14", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.2.5", "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.7", "@shadcn/ui": "^0.0.4", "@supabase/supabase-js": "^2.49.4", "@tailwindcss/postcss": "^4.1.8", "@tauri-apps/api": "^2.2.0", "@tauri-apps/plugin-shell": "^2.3.4", "@uiw/react-codemirror": "^4.24.2", "autoprefixer": "^10.4.21", "axios": "^1.11.0", "clsx": "^2.1.1", "cmdk": "^1.1.1", "d3": "^7.9.0", "electron": "^35.0.0", "js-cookie": "^3.0.5", "leaflet": "^1.9.4", "maplibre-gl": "^5.6.2", "next-themes": "^0.4.6", "postcss": "^8.5.4", "react-hook-form": "^7.62.0", "react-icons": "^5.5.0", "react-leaflet": "^5.0.0", "react-map-gl": "^8.0.4", "react-resizable-panels": "^3.0.5", "sonner": "^2.0.5", "vite-plugin-dts": "^4.5.3", "zustand": "^5.0.5" }, "devDependencies": { "vitest": "^3.0.0", "@eslint/js": "^9.21.0", "@types/d3": "^7.4.3", "@types/js-cookie": "^3.0.6", "@types/leaflet": "^1.9.20", "wait-on": "^8.0.3" } } ================================================ FILE: apps/client/src/App.tsx ================================================ import { AuthPage } from "./pages/auth"; import { createBrowserRouter, RouterProvider } from "react-router"; import { DashboardSwipeLayout, DashboardSwipeRoutePlaceholder, } from "./features/dashboard-swipe/DashboardSwipeLayout"; import { DevicePage } from "./pages/devices"; import { FlowPage } from "./pages/flow"; import { AuthInterceptor } from "./features/auth/AuthInterceptor"; import { NotFound } from "./pages/notfound"; import LandingPage from "./pages/landing"; import { MapPage } from "./pages/map"; import { SetupPage } from "./pages/setup"; import { CodePage } from "./pages/code"; import { AuthenticatedLayout } from "./widgets/auth/AuthenticatedLayout"; import { TopBarWrapper } from "./widgets/auth/TopBarWrapper"; import { useDesktopSidecar } from "./hooks/useDesktopSidecar"; import { usePreventBackNavigation } from "./hooks/usePreventBackNavigation"; import { SettingsPage } from "./pages/settings"; import { AccountSettingsPage } from "./pages/settings/account"; import { ServicesSettingsPage } from "./pages/settings/services"; import { UsersSettingsPage } from "./pages/settings/users"; import { NetworksSettingsPage } from "./pages/settings/networks"; import { IntegrationSettingsPage } from "./pages/settings/integration"; import { LogSettingsPage } from "./pages/settings/log"; import { ConfigSettingsPage } from "./pages/settings/config"; import { RecordingsPage } from "./pages/recordings"; import { DesktopSettingsPage } from "./pages/desktop-settings"; const router = createBrowserRouter([ { path: "/", element: , }, { path: "/auth", element: ( ), }, { element: , children: [ { element: ( ), children: [ { path: "/dashboard", element: , }, { path: "/dynamic-dashboard/new", element: , }, { path: "/dynamic-dashboard/:dashboardId", element: , }, { path: "/dynamic-dashboard", element: , }, ], }, { path: "/devices", element: ( ), }, { path: "/flow", element: ( ), }, { path: "/map", element: ( ), }, { path: "/setup", element: ( ), }, { path: "/settings", element: ( ), }, { path: "/settings/account", element: ( ), }, { path: "/settings/services", element: ( ), }, { path: "/settings/users", element: ( ), }, { path: "/settings/networks", element: ( ), }, { path: "/settings/integration", element: ( ), }, { path: "/settings/log", element: ( ), }, { path: "/settings/config", element: ( ), }, { path: "/code", element: ( ), }, { path: "/recordings", element: ( ), }, ], }, { path: "*", element: ( ), }, ]); const isDesktopSettingsWindow = (): boolean => { if (typeof window === "undefined") return false; try { const params = new URLSearchParams(window.location.search); if (params.get("view") === "desktop_settings") return true; if (params.get("desktop_settings") === "1") return true; if (window.location.hash.includes("desktop_settings")) return true; return false; } catch { return false; } }; function App() { useDesktopSidecar(); usePreventBackNavigation(); if (isDesktopSettingsWindow()) { return ; } return ( <> ); } export default App; ================================================ FILE: apps/client/src/app/pageWrapper/page-wrapper.tsx ================================================ import { isElectron } from "@/lib/electron"; import type { PropsWithChildren } from "react"; export function PageWrapper(props: PropsWithChildren) { if (isElectron()) { return (
{props.children}
); } else { return (
{props.children}
); } } ================================================ FILE: apps/client/src/app/providers/theme-provider.tsx ================================================ import { createContext, useContext, useEffect, useState } from "react"; type Theme = "dark" | "light" | "system"; type ThemeProviderProps = { children: React.ReactNode; defaultTheme?: Theme; storageKey?: string; }; type ThemeProviderState = { theme: Theme; setTheme: (theme: Theme) => void; }; const initialState: ThemeProviderState = { theme: "system", setTheme: () => null, }; const ThemeProviderContext = createContext(initialState); export function ThemeProvider({ children, defaultTheme = "system", storageKey = "vite-ui-theme", ...props }: ThemeProviderProps) { const [theme, setTheme] = useState( () => (localStorage.getItem(storageKey) as Theme) || defaultTheme ); useEffect(() => { const root = window.document.documentElement; root.classList.remove("light", "dark"); if (theme === "system") { const systemTheme = window.matchMedia("(prefers-color-scheme: dark)") .matches ? "dark" : "light"; root.classList.add(systemTheme); return; } root.classList.add(theme); }, [theme]); const value = { theme, setTheme: (theme: Theme) => { localStorage.setItem(storageKey, theme); setTheme(theme); }, }; return ( {children} ); } export const useTheme = () => { const context = useContext(ThemeProviderContext); if (context === undefined) throw new Error("useTheme must be used within a ThemeProvider"); return context; }; ================================================ FILE: apps/client/src/components/icon/Logo.tsx ================================================ export function VesselLogo() { return ( ); } ================================================ FILE: apps/client/src/components/ui/alert-dialog.tsx ================================================ import * as React from "react"; import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"; import { cn } from "@/lib/utils"; import { buttonVariants } from "@/components/ui/button"; function AlertDialog({ ...props }: React.ComponentProps) { return ; } function AlertDialogTrigger({ ...props }: React.ComponentProps) { return ( ); } function AlertDialogPortal({ ...props }: React.ComponentProps) { return ( ); } function AlertDialogOverlay({ className, ...props }: React.ComponentProps) { return ( ); } function AlertDialogContent({ className, ...props }: React.ComponentProps) { return ( ); } function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) { return (
); } function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) { return (
); } function AlertDialogTitle({ className, ...props }: React.ComponentProps) { return ( ); } function AlertDialogDescription({ className, ...props }: React.ComponentProps) { return ( ); } function AlertDialogAction({ className, ...props }: React.ComponentProps) { return ( ); } function AlertDialogCancel({ className, ...props }: React.ComponentProps) { return ( ); } export { AlertDialog, AlertDialogPortal, AlertDialogOverlay, AlertDialogTrigger, AlertDialogContent, AlertDialogHeader, AlertDialogFooter, AlertDialogTitle, AlertDialogDescription, AlertDialogAction, AlertDialogCancel, }; ================================================ FILE: apps/client/src/components/ui/alert.tsx ================================================ import * as React from "react"; import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@/lib/utils"; const alertVariants = cva( "relative w-full border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7", { variants: { variant: { default: "bg-background text-foreground", destructive: "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", }, }, defaultVariants: { variant: "default", }, }, ); const Alert = React.forwardRef< HTMLDivElement, React.HTMLAttributes & VariantProps >(({ className, variant, ...props }, ref) => (
)); Alert.displayName = "Alert"; const AlertTitle = React.forwardRef< HTMLParagraphElement, React.HTMLAttributes >(({ className, ...props }, ref) => (
)); AlertTitle.displayName = "AlertTitle"; const AlertDescription = React.forwardRef< HTMLParagraphElement, React.HTMLAttributes >(({ className, ...props }, ref) => (
)); AlertDescription.displayName = "AlertDescription"; export { Alert, AlertTitle, AlertDescription }; ================================================ FILE: apps/client/src/components/ui/avatar.tsx ================================================ import * as React from "react" import * as AvatarPrimitive from "@radix-ui/react-avatar" import { cn } from "@/lib/utils" function Avatar({ className, ...props }: React.ComponentProps) { return ( ) } function AvatarImage({ className, ...props }: React.ComponentProps) { return ( ) } function AvatarFallback({ className, ...props }: React.ComponentProps) { return ( ) } export { Avatar, AvatarImage, AvatarFallback } ================================================ FILE: apps/client/src/components/ui/badge.tsx ================================================ import * as React from "react"; import { Slot } from "@radix-ui/react-slot"; import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@/lib/utils"; const badgeVariants = cva( "inline-flex items-center justify-center border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", { variants: { variant: { default: "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", secondary: "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", destructive: "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", }, }, defaultVariants: { variant: "default", }, }, ); function Badge({ className, variant, asChild = false, ...props }: React.ComponentProps<"span"> & VariantProps & { asChild?: boolean }) { const Comp = asChild ? Slot : "span"; return ( ); } export { Badge, badgeVariants }; ================================================ FILE: apps/client/src/components/ui/breadcrumb.tsx ================================================ import * as React from "react" import { Slot } from "@radix-ui/react-slot" import { ChevronRight, MoreHorizontal } from "lucide-react" import { cn } from "@/lib/utils" function Breadcrumb({ ...props }: React.ComponentProps<"nav">) { return