diff --git a/.env.example b/.env.example index 548bd1971..2928bcc1c 100644 --- a/.env.example +++ b/.env.example @@ -25,6 +25,21 @@ OPENAI_ACCESS_TOKEN= OPENAI_MODEL= OPENAI_URI_BASE= +# Optional: External AI Assistant — delegates chat to a remote AI agent +# instead of calling LLMs directly. The agent calls back to Sure's /mcp endpoint. +# See docs/hosting/ai.md for full details. +# ASSISTANT_TYPE=external +# EXTERNAL_ASSISTANT_URL=https://your-agent-host/v1/chat/completions +# EXTERNAL_ASSISTANT_TOKEN=your-api-token +# EXTERNAL_ASSISTANT_AGENT_ID=main +# EXTERNAL_ASSISTANT_SESSION_KEY=agent:main:main +# EXTERNAL_ASSISTANT_ALLOWED_EMAILS=user@example.com + +# Optional: MCP server endpoint — enables /mcp for external AI assistants. +# Both values are required. MCP_USER_EMAIL must match an existing user's email. +# MCP_API_TOKEN=your-random-bearer-token +# MCP_USER_EMAIL=user@example.com + # Optional: Langfuse config LANGFUSE_HOST=https://cloud.langfuse.com LANGFUSE_PUBLIC_KEY= diff --git a/.github/workflows/pipelock.yml b/.github/workflows/pipelock.yml new file mode 100644 index 000000000..f7e5bd897 --- /dev/null +++ b/.github/workflows/pipelock.yml @@ -0,0 +1,27 @@ +name: Pipelock Security Scan + +on: + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + security-scan: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Pipelock Scan + uses: luckyPipewrench/pipelock@v1 + with: + scan-diff: 'true' + fail-on-findings: 'true' + test-vectors: 'false' + exclude-paths: | + config/locales/views/reports/ + docs/hosting/ai.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e032cd731..c44b64524 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -40,3 +40,12 @@ To get setup for local development, you have two options: 7. Before requesting a review, please make sure that all [Github Checks](https://docs.github.com/en/rest/checks?apiVersion=2022-11-28) have passed and your branch is up-to-date with the `main` branch. After doing so, request a review and wait for a maintainer's approval. All PRs should target the `main` branch. + +### Automated Security Scanning + +Every pull request to the `main` branch automatically runs a Pipelock security scan. This scan analyzes your PR diff for: + +- Leaked secrets (API keys, tokens, credentials) +- Agent security risks (misconfigurations, exposed credentials, missing controls) + +The scan runs as part of the CI pipeline and typically completes in ~30 seconds. If security issues are found, the CI check will fail. You don't need to configure anything—the security scanning is automatic and zero-configuration. diff --git a/Gemfile.lock b/Gemfile.lock index 8b6d1b77e..ca60e0790 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -282,7 +282,7 @@ GEM actionview (>= 5.0.0) activesupport (>= 5.0.0) jmespath (1.6.2) - json (2.18.1) + json (2.19.2) json-jwt (1.16.7) activesupport (>= 4.2) aes_key_wrap @@ -441,7 +441,7 @@ GEM ostruct (0.6.2) pagy (9.3.5) parallel (1.27.0) - parser (3.3.8.0) + parser (3.3.10.2) ast (~> 2.4.1) racc pdf-reader (2.15.1) diff --git a/README.md b/README.md index 81d509be7..fe45b8bbb 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ To stay compliant and avoid trademark issues: With data-heavy apps, inevitably, there are performance issues. We've set up a public dashboard showing the problematic requests seen on the demo site, along with the stacktraces to help debug them. -https://www.skylight.io/app/applications/s6PEZSKwcklL/recent/6h/endpoints +[https://www.skylight.io/app/applications/s6PEZSKwcklL/recent/6h/endpoints](https://oss.skylight.io/app/applications/s6PEZSKwcklL/recent/6h/endpoints) Any contributions that help improve performance are very much welcome. diff --git a/app/assets/images/claw-dark.svg b/app/assets/images/claw-dark.svg new file mode 100644 index 000000000..9eba8a03e --- /dev/null +++ b/app/assets/images/claw-dark.svg @@ -0,0 +1,79 @@ + \ No newline at end of file diff --git a/app/assets/images/claw.svg b/app/assets/images/claw.svg new file mode 100644 index 000000000..3da342760 --- /dev/null +++ b/app/assets/images/claw.svg @@ -0,0 +1,79 @@ + \ No newline at end of file diff --git a/app/assets/tailwind/maybe-design-system.css b/app/assets/tailwind/maybe-design-system.css index 184cbf3a6..f27b97078 100644 --- a/app/assets/tailwind/maybe-design-system.css +++ b/app/assets/tailwind/maybe-design-system.css @@ -368,12 +368,14 @@ text-overflow: clip; } - select.form-field__input { + select.form-field__input, + button.form-field__input { @apply pr-10 appearance-none; background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e"); background-position: right -0.15rem center; background-repeat: no-repeat; background-size: 1.25rem 1.25rem; + text-align: left; } .form-field__radio { diff --git a/app/components/DS/buttonish.rb b/app/components/DS/buttonish.rb index 7eeb5ee66..0c68d1dbe 100644 --- a/app/components/DS/buttonish.rb +++ b/app/components/DS/buttonish.rb @@ -80,7 +80,7 @@ class DS::Buttonish < DesignSystemComponent merged_base_classes, full_width ? "w-full justify-center" : nil, container_size_classes, - size_data.dig(:text_classes), + icon_only? ? nil : size_data.dig(:text_classes), variant_data.dig(:container_classes) ) end @@ -108,7 +108,7 @@ class DS::Buttonish < DesignSystemComponent end def icon_only? - variant.in?([ :icon, :icon_inverse ]) + variant.in?([ :icon, :icon_inverse ]) || (icon.present? && text.blank?) end private diff --git a/app/components/DS/menu.html.erb b/app/components/DS/menu.html.erb index d4f0ea8d8..ed6490184 100644 --- a/app/components/DS/menu.html.erb +++ b/app/components/DS/menu.html.erb @@ -1,4 +1,4 @@ -<%= tag.div data: { controller: "DS--menu", DS__menu_placement_value: placement, DS__menu_offset_value: offset, testid: testid } do %> +<%= tag.div data: { controller: "DS--menu", DS__menu_placement_value: placement, DS__menu_offset_value: offset, DS__menu_mobile_fullwidth_value: mobile_fullwidth, testid: testid } do %> <% if variant == :icon %> <%= render DS::Button.new(variant: "icon", icon: icon_vertical ? "more-vertical" : "more-horizontal", data: { DS__menu_target: "button" }) %> <% elsif variant == :button %> @@ -12,7 +12,7 @@ <% end %>
<% end %> diff --git a/app/components/DS/menu.rb b/app/components/DS/menu.rb index 39ef35e97..32a14a472 100644 --- a/app/components/DS/menu.rb +++ b/app/components/DS/menu.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class DS::Menu < DesignSystemComponent - attr_reader :variant, :avatar_url, :initials, :placement, :offset, :icon_vertical, :no_padding, :testid + attr_reader :variant, :avatar_url, :initials, :placement, :offset, :icon_vertical, :no_padding, :testid, :mobile_fullwidth, :max_width renders_one :button, ->(**button_options, &block) do options_with_target = button_options.merge(data: { DS__menu_target: "button" }) @@ -23,7 +23,7 @@ class DS::Menu < DesignSystemComponent VARIANTS = %i[icon button avatar].freeze - def initialize(variant: "icon", avatar_url: nil, initials: nil, placement: "bottom-end", offset: 12, icon_vertical: false, no_padding: false, testid: nil) + def initialize(variant: "icon", avatar_url: nil, initials: nil, placement: "bottom-end", offset: 12, icon_vertical: false, no_padding: false, testid: nil, mobile_fullwidth: true, max_width: nil) @variant = variant.to_sym @avatar_url = avatar_url @initials = initials @@ -32,6 +32,8 @@ class DS::Menu < DesignSystemComponent @icon_vertical = icon_vertical @no_padding = no_padding @testid = testid + @mobile_fullwidth = mobile_fullwidth + @max_width = max_width raise ArgumentError, "Invalid variant: #{@variant}" unless VARIANTS.include?(@variant) end diff --git a/app/components/DS/menu_controller.js b/app/components/DS/menu_controller.js index 512358f6c..33d3714ef 100644 --- a/app/components/DS/menu_controller.js +++ b/app/components/DS/menu_controller.js @@ -16,6 +16,7 @@ export default class extends Controller { show: Boolean, placement: { type: String, default: "bottom-end" }, offset: { type: Number, default: 6 }, + mobileFullwidth: { type: Boolean, default: true }, }; connect() { @@ -105,13 +106,14 @@ export default class extends Controller { if (!this.buttonTarget || !this.contentTarget) return; const isSmallScreen = !window.matchMedia("(min-width: 768px)").matches; + const useMobileFullwidth = isSmallScreen && this.mobileFullwidthValue; computePosition(this.buttonTarget, this.contentTarget, { - placement: isSmallScreen ? "bottom" : this.placementValue, + placement: useMobileFullwidth ? "bottom" : this.placementValue, middleware: [offset(this.offsetValue), shift({ padding: 5 })], strategy: "fixed", }).then(({ x, y }) => { - if (isSmallScreen) { + if (useMobileFullwidth) { Object.assign(this.contentTarget.style, { position: "fixed", left: "0px", diff --git a/app/components/DS/select.html.erb b/app/components/DS/select.html.erb new file mode 100644 index 000000000..4b07ccd7b --- /dev/null +++ b/app/components/DS/select.html.erb @@ -0,0 +1,94 @@ +<%# locals: form:, method:, collection:, options: {} %> + +<%= account.long_subtype_label %>
<% end %> + <% if account.supports_default? && is_default %> +<%= t("accounts.account.default_label") %>
+ <% end %> + <% if account.institution_name.present? %> +<%= account.institution_name %>
+ <% end %> <% end %>| <%= t(".table.user") %> | -<%= t(".table.trial_ends_at") %> | -<%= t(".table.family_accounts") %> | -<%= t(".table.family_transactions") %> | -<%= t(".table.role") %> | -||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
-
-
-
- <%= user.initials %>
-
-
-
- <%= user.display_name %> -<%= user.email %> -- <%= t(".table.last_login") %>: <%= @last_login_by_user[user.id]&.to_fs(:long) || t(".table.never") %> - <%= t(".table.session_count") %>: <%= number_with_delimiter(@sessions_count_by_user[user.id] || 0) %> - - |
- - <%= user.family.subscription&.trial_ends_at&.to_fs(:long) || t(".not_available") %> - | -- <%= number_with_delimiter(@accounts_count_by_family[user.family_id] || 0) %> - | -- <%= number_with_delimiter(@entries_count_by_family[user.family_id] || 0) %> - | -
- <% if user.id == Current.user.id %>
- <%= t(".you") %>
- <% else %>
- <%= form_with model: [:admin, user], method: :patch, class: "flex items-center justify-end gap-2" do |form| %>
- <%= form.select :role,
- options_for_select([
- [t(".roles.guest"), "guest"],
- [t(".roles.member", default: "Member"), "member"],
- [t(".roles.admin"), "admin"],
- [t(".roles.super_admin"), "super_admin"]
- ], user.role),
- {},
- class: "text-sm rounded-lg border border-primary bg-container text-primary px-2 py-1",
- onchange: "this.form.requestSubmit()" %>
- <% end %>
+
+ <% if @families_with_users.any? %>
+
+ <% @families_with_users.each do |family, users| %>
+ <% pending_invitations = @invitations_by_family[family.id] || [] %>
+
+
+
+
+
+
+ <%= family.name.presence || t(".unnamed_family") %> ++ <%= t(".family_summary", + members: users.size, + accounts: number_with_delimiter(@accounts_count_by_family[family.id] || 0), + transactions: number_with_delimiter(@entries_count_by_family[family.id] || 0)) %> + + |
| <%= t(".table.user") %> | +<%= t(".table.last_login") %> | +<%= t(".table.session_count") %> | +<%= t(".table.role") %> | +
|---|---|---|---|
|
+
+
+
+ <%= user.initials %>
+
+
+
+ <%= user.display_name %> +<%= user.email %> + |
+ + <%= @last_login_by_user[user.id]&.to_fs(:long) || t(".table.never") %> + | ++ <%= number_with_delimiter(@sessions_count_by_user[user.id] || 0) %> + | ++ <% if user.id == Current.user.id %> + <%= t(".you") %> + <% else %> + <%= form_with model: [:admin, user], method: :patch, class: "flex items-center justify-end gap-2", data: { controller: "auto-submit-form" } do |form| %> + <%= form.select :role, + options_for_select([ + [t(".roles.guest"), "guest"], + [t(".roles.member", default: "Member"), "member"], + [t(".roles.admin"), "admin"], + [t(".roles.super_admin"), "super_admin"] + ], user.role), + {}, + class: "text-sm rounded-lg border border-primary bg-container text-primary px-2 py-1", + data: { auto_submit_form_target: "auto" } %> + <% end %> + <% end %> + | +
<%= t(".no_users") %>
-<%= invitation.email %>
+<%= t(".invitations.pending_label") %>
+<%= t(".no_users") %>
+<%= t("budgets.copy_previous_prompt.title") %>
+<%= t("budgets.copy_previous_prompt.description", source_name: source_budget.name) %>
++ <%= t(".last_price_update") %>: <%= @last_price_updated ? l(@last_price_updated, format: :long) : t(".never") %> +
++ <%= t("holdings.show.last_price_update") %>: <%= @last_price_updated ? l(@last_price_updated, format: :long) : t("holdings.show.never") %> +
+ <% if @provider_error %> +<%= @provider_error %>
+ <% end %> +<%= t(".description") %>
+<%= t(".split_warning_title") %>
+<%= t(".split_warning_description") %>
+<%= t(".empty_state_primary") %>
+<%= t(".empty_state_secondary") %>
+<%= t(".description") %>
+<%= t(".qif_description") %>
- Browse to add your CSV file here -
-<%= t(".description") %>
+<%= t(".moniker_prompt", product_name: product_name) %>
<%= holding.ticker %>
+<%= truncate(holding.name, length: 20) %>
<%= holding.ticker %>
-<%= truncate(holding.name, length: 20) %>
+<%= t(".warning_title") %>
+<%= t(".warning_description") %>
+<%= @transaction.entry.name %>
++ <%= @transaction.entry.account.name %> + • <%= I18n.l(@transaction.entry.date, format: :long) %> + • <%= number_to_currency(@transaction.entry.amount.abs, unit: Money::Currency.new(@transaction.entry.currency).symbol) %> +
++ <%= t(".showing_range", start: @range_start, end: @range_end) %> +
+<%= t(".no_candidates") %>
+<%= holding.ticker %>
+<%= truncate(holding.name, length: 25) %>
<%= holding.ticker %>
-<%= truncate(holding.name, length: 25) %>
+<%= t(".env_notice", type: ENV["ASSISTANT_TYPE"]) %>
+ <% else %> +<%= t(".description") %>
+ <% end %> +<%= t(".env_configured_external") %>
+ <% end %> + + <% if Assistant::External.configured? && !ENV["EXTERNAL_ASSISTANT_URL"].present? %> +<%= t(".disconnect_title") %>
+<%= t(".disconnect_description") %>
+<%= t(".url_help") %>
+ + <%= form.password_field :external_assistant_token, + label: t(".token_label"), + placeholder: t(".token_placeholder"), + value: (Assistant::External.config.token.present? ? "********" : nil), + autocomplete: "off", + autocapitalize: "none", + spellcheck: "false", + inputmode: "text", + disabled: ENV["EXTERNAL_ASSISTANT_TOKEN"].present?, + data: { "auto-submit-form-target": "auto" } %> +<%= t(".token_help") %>
+ + <%= form.text_field :external_assistant_agent_id, + label: t(".agent_id_label"), + placeholder: t(".agent_id_placeholder"), + value: Assistant::External.config.agent_id, + autocomplete: "off", + autocapitalize: "none", + spellcheck: "false", + inputmode: "text", + disabled: ENV["EXTERNAL_ASSISTANT_AGENT_ID"].present?, + data: { "auto-submit-form-target": "auto" } %> +<%= t(".agent_id_help") %>
+ <% end %> + <% end %> +<%= t(".default_family_title") %>
+<%= t(".default_family_description") %>
+
- Currently on the <%= @family.subscription.name %>.
+ Currently on the <%= @family.subscription.name %>.
<% if @family.next_payment_date %>
<% if @family.subscription_pending_cancellation? %>
@@ -25,7 +25,7 @@
- Currently using the open demo of <%= product_name %>
+ Currently using the open demo of <%= product_name %>
(Data will be deleted in <%= @family.days_left_in_trial %> days)
diff --git a/app/views/settings/providers/_enable_banking_panel.html.erb b/app/views/settings/providers/_enable_banking_panel.html.erb
index e1568a8f4..1ca388cea 100644
--- a/app/views/settings/providers/_enable_banking_panel.html.erb
+++ b/app/views/settings/providers/_enable_banking_panel.html.erb
@@ -6,6 +6,7 @@
Field descriptions:
@@ -24,10 +25,12 @@ <% end %> <% - enable_banking_item = Current.family.enable_banking_items.first_or_initialize(name: "Enable Banking Connection") + # Use local family variable if available (e.g., from Sidekiq broadcast), otherwise fall back to Current.family (HTTP requests) + family = local_assigns[:family] || Current.family + enable_banking_item = family.enable_banking_items.first_or_initialize(name: "Enable Banking Connection") is_new_record = enable_banking_item.new_record? # Check if there are any authenticated connections (have session_id) - has_authenticated_connections = Current.family.enable_banking_items.where.not(session_id: nil).exists? + has_authenticated_connections = family.enable_banking_items.where.not(session_id: nil).exists? %> <%= styled_form_with model: enable_banking_item, @@ -100,7 +103,7 @@<%= t('subscriptions.upgrade.trialing', days: Current.family.days_left_in_trial) %>
+<%= t("subscriptions.upgrade.trialing", days: Current.family.days_left_in_trial) %>
<% else %> -<%= t('subscriptions.upgrade.trial_over') %>
+<%= t("subscriptions.upgrade.trial_over") %>
<% end %><%= t('subscriptions.upgrade.cta') %>
+<%= t("subscriptions.upgrade.cta") %>
<%= form_with url: new_subscription_path, method: :get, class: "max-w-xs", data: { turbo: false } do |form| %>- <%= t('subscriptions.upgrade.redirect_to_stripe') %> + <%= t("subscriptions.upgrade.redirect_to_stripe") %>
<%= t(".browse_to_add") %>
++ <%= t(".select_up_to", + count: Transaction::MAX_ATTACHMENTS_PER_TRANSACTION, + size: Transaction::MAX_ATTACHMENT_SIZE / 1.megabyte, + used: transaction.attachments.count) %> +
+<%= attachment.filename %>
+<%= number_to_human_size(attachment.byte_size) %>
+<%= t(".no_attachments") %>
+ <% end %> +<%= t("transactions.show.pending_duplicate_merger_description") %>
+