House ad tests were adding ids to house ads, this was being stripped
by our allow lister.
Not allowing ids is a feature, cause this protects people from footguns
Particularly if we allow it is much more likely they will end up having
dupe ids for ads
Add `target_user_ids` as an alternative to `target_usernames` when
creating private messages via PostCreator and TopicCreator. The two
options are mutually exclusive and raise ArgumentError if both are
provided.
This avoids username-based lookups where the caller already has user
IDs, and prevents stale references when users rename after a pending
PM is created.
Key changes:
- PostCreator/TopicCreator: accept `target_user_ids`, validate
mutual exclusivity with `target_usernames`, extract shared
`add_users_from_scope` in TopicCreator
- Automation PendingPm: migrate from `sender`/`target_usernames`
string columns to `sender_id`/`target_user_ids` integer columns
with a backfill migration
- Scriptable::Utils.send_pm: resolve sender upfront, support
integer sender param, store IDs in pending PMs
Previously, tool runner actions (createTopic, createPost, editPost,
editTopic, sendChatMessage) used system_guardian or bypassed permission
checks, allowing AI tools to act beyond the resolved user's privileges.
This change:
- Adds `resolve_guardian` helper to pair user resolution with Guardian
- Replaces system_guardian with per-user guardian in all tool actions
- Adds explicit `can_create?`/`can_edit?`/`can_see?` checks before
performing mutations
- Defaults to bot_user instead of system_user when no username given
- Removes redundant username requirement for sendChatMessage
- Adds comprehensive specs for guardian permission enforcement
This means you always want better alignment between bot user or username
that the custom tool uses. It makes it simpler to reason about
permissions
but also is a bit of a breaking change.
The original commit added route-based targeting for house ads, allowing
ads to be shown on specific Ember routes in addition to categories.
However, the filtering logic was implemented inconsistently - the
`chooseAdHtml()` method correctly filters by both categories and routes,
but the `didInsertElement()` method (which initializes random ad
positions) only filters by categories.
- Add `stalled_topic` trigger to `llm_triage`, `llm_persona_triage`,
`ai_tool_action`, and `llm_tagger` automation scripts
- Each script resolves the first post from `context["topic"]` when
triggered by `stalled_topic` (which doesn't pass a post directly)
- Add tests for the new trigger in all 4 spec files
The `preview` action in `SharedAiConversationsController` was accessible
without authentication because it was missing from the `requires_login
only:` list, allowing anonymous users to hit the endpoint. However, it
would not create any security exploit due to guardians, and would just
generate an incorrect error later in the chain.
Note this commit also removes `update` from the list of required login
actions as it doesn’t exist anymore.
The Horizon theme's topic card system relied on inspecting
`router.currentRouteName` to decide when to apply card styling. This
was fragile — the theme was coupled to internal routing details — and
made it impossible to scope card behavior per-list (e.g. showing simple
cards for suggested topics but high-context cards on discovery).
This PR introduces a `listContext` string prop that flows through the
topic list component tree, replacing all route-name checks with explicit
context values.
## What changed
### Core: `listContext` prop threading
Every topic list call site now passes an explicit context string
(`"discovery"`, `"suggested"`, `"related"`, `"group-activity"`,
`"user-activity"`, `"messages"`, `"assigned"`). The prop flows from
the call site through `BasicTopicList` → `TopicList` → `Item` and into
all value transformers (`topic-list-class`, `topic-list-columns`,
`topic-list-item-class`, `topic-list-item-style`,
`topic-list-item-mobile-layout`) and the `topic-list-item-click`
behavior transformer.
This lets the theme decide card behavior based on semantic context
rather than route names, and means the theme no longer needs to
`container.lookup("service:router")`.
### Horizon initializer rewrite
- Replace `isHighContextRoute(router.currentRouteName)` with
`isTopicCardContext(listContext)` checks using two declarative arrays:
`TOPIC_CARD_CONTEXTS` and `SIMPLE_CARD_CONTEXTS`.
- Register a `topic-list-class` transformer that adds `--d-topic-cards`
to topic lists in card contexts.
- Suggested/related lists always get the simple card layout, even when
high-context mode is enabled.
- Preserve the `bulk-select` column in high-context layout (its item
cell is hidden by existing CSS) so the header toggle icon renders.
- Set `header: HeaderTopicCell` on the high-context-card column so the
bulk action buttons ("Select All" / "Clear All") appear in the header.
### CSS scoping and cleanup
- **Topic card styles** scoped to `.topic-list.--d-topic-cards` instead
of applying globally to `.topic-list-body`, preventing style leakage
into messages and other non-card lists.
- **Badge category colors** consolidated into CSS custom properties
(`--badge-category-bg`, `--badge-category-text`) on
`.badge-category__wrapper`, replacing 6 repeated
`light-dark(oklch(...))` expressions across 3 files.
- **Messages page**: removed the 72-line `body.user-messages-page`
override block. Messages get `listContext="messages"` which is not a
card context, so they use default rendering. Desktop bulk-select and
header overrides scoped with `body:not(.user-messages-page)`.
- **Bulk select in high-context cards**: fixed checkbox centering,
selected-state background (removed override that reset it to
`--secondary`), sticky header `z-index`, and `max-width: unset` on
header cells at small viewports.
- **Removed** `color-exploration.scss` (unused sidebar color vars),
`excerpt-expanded` grid area styles, hardcoded `#e45735` (now
`var(--danger)`).
- **Renamed** `has-replies` → `--has-replies` for BEM consistency.
### Other fixes
- Extract `getTopicStatusBadge()` into shared utility, fixing
inconsistency where `topic-status-column` only checked `topic.pinned`
but the high-context card checked both `pinned` and `pinned_globally`.
- Fix `topic-list-item-mobile-layout` transformer to properly return
`value` for non-card contexts (was returning `undefined`).
- Fix `hasReplies` to use `replyCount > 0` instead of `posts_count > 1`.
- Typo fix in `chat.scss`.
---------
Co-authored-by: chapoi <101828855+chapoi@users.noreply.github.com>
Any authenticated user in the localization allowed group could trigger
translation of any post by ID, including posts in private messages,
restricted categories, whispers, and deleted posts.
This is not considered as SECURITY issue as the action will only start a
translation and return `{"success" => "OK"}`. You won't have access to
any inaccessible content.
The category tags page and webhooks form were using `@form.Container`
and `field.Custom` to wrap the select-kit TagChooser component. These
wrappers are simple visual containers that don't participate in
FormKit's field lifecycle — they silently ignore props like `@optional`
and don't support validation, error display, or consistent field
styling.
This commit introduces a proper `field.TagChooser` FormKit control that
wraps the select-kit TagChooser, mapping `@field.value` to `@tags` and
`@field.set` to `@onChange`. It follows the same pattern as the existing
`field.Icon` control.
Changes:
- New `fk/control/tag-chooser.gjs` with pass-through args for
TagChooser-specific options (`@everyTag`, `@excludeSynonyms`, etc.)
- Register the control in `fk/field.gjs` and add pass-through args to
`fk/control-wrapper.gjs`
- Convert `upsert-category/tags.gjs` allowed_tags from Container to
`field.TagChooser`
- Convert `webhooks-form.gjs` tag_names from `field.Custom` to
`field.TagChooser`
- Add `tag-chooser` support to the FormKit Ruby page object for system
specs
- Update simplified_category_creation_spec to use FormKit page objects
- Add integration tests and styleguide example
- Add webhook tag filter system spec
Ref - t/174117
PM support for the solved plugin was intentionally removed in
https://github.com/discourse/discourse-solved/pull/334, but multiple
communities rely on solved in group messages for private support
workflows (e.g. using Discourse as an internal ticket system).
Add a new `allow_solved_in_groups` site setting (group list, default
empty) that enables the solved UI for group messages belonging to the
configured groups. Regular 1-on-1 PMs remain excluded.
The implementation adds an early return in `allow_accepted_answers?` for
private messages that checks whether the topic's allowed groups
intersect with the configured setting. The category/tag logic is
extracted into `solved_enabled_for_category?` so callers without a topic
object (e.g. the category-change diff in plugin.rb) still work. All
existing PM exclusions (reports, badges, filters, directory columns) are
intentionally left unchanged.
https://meta.discourse.org/t/370496
Switches tag URLs from name-based (`/tag/my-tag`) to slug+id-based
(`/tag/my-tag/123`), making tag references stable across renames and
enabling translated tag names.
| Type | Before | After |
|------|--------|-------|
| Browser | `/tag/my-tag` | `/tag/my-tag/123` |
| Browser | `/tag/my-tag/l/latest` | `/tag/my-tag/123/l/latest` |
| API | `/tag/my-tag.json` | `/tag/123.json` |
- Old `/tag/:name` URLs still work
- Browser requests get 301 redirected to canonical URLs
- API (JSON) requests return data without redirect
- Untagged (`/tag/none`) and intersection routes remain unchanged.
Depends on https://github.com/discourse/discourse/pull/36678
---------
Co-authored-by: Krzysztof Kotlarek <kotlarek.krzysztof@gmail.com>
Introduce an `AiSecret` model to allow admins to manage
API keys and secrets in a single place, shared across
LLMs and embedding definitions.
Previously each LLM and embedding stored its own api_key
directly. This change introduces a secrets vault so that
a single secret can be referenced by multiple models,
reducing duplication and making key rotation easier.
Key changes:
- New `ai_secrets` table, model, serializer, and CRUD
controller with in-use protection on delete
- LlmModel and EmbeddingDefinition now accept an optional
`ai_secret_id` foreign key as an alternative to inline
`api_key`; validation ensures one or the other is set
- Provider params of type `:secret` (e.g. Bedrock
`access_key_id`) resolve through AiSecret at runtime
- Admin UI: new Secrets nav tab with list/edit views,
inline AiSecretSelector dropdown + quick-create modal
on LLM and embedding editor forms
- Post-migration deduplicates existing api_key values
into the new secrets table and back-fills foreign keys
- Fabricator and specs for model, controller, and
usage-tracking logic
---------
Co-authored-by: awesomerobot <kris.aubuchon@discourse.org>
Co-authored-by: Keegan George <kgeorge13@gmail.com>
Currently `e.message` would return the same error, but returning an
exception message is brittle as it could change in the future and expose
data in the serializer we wouldn't want to expose. Having high control
on what we expose in the serializer is a more future proof pattern.
Ensures the `type` is valid. Before this commit, giving an invalid type
would just return the list of invitees who havent answered yet as it
would be computed to `nil`.
This codepath could hit a `NoMethodError` (500 error) in
`InviteesController#update` and `#destroy` when invitee or event is not
found, due to calling methods on `nil`.
Unvalidated `llm_id` parameter in `AiSpamController#test` allows use of
any LLM model, including non-existent ones, causing unhandled
`ActiveRecord::RecordNotFound` errors.
This is not a security issue, because artifacts are allowed to include
arbitrary javascript anyway. Plus, the injection takes place inside a
sandboxed iframe.
Internal `patch-triage/263`
The GitHub linkback feature posts comments on PRs/issues/commits when
they are mentioned in Discourse posts. However, it never checked whether
the GitHub PR/issue/commit already contained a link back to the same
Discourse topic (in its description or existing comments). This led to
redundant linkback comments when a PR already referenced the topic.
Before posting a linkback comment, we now fetch the PR/issue body and
existing comments (or commit comments) from the GitHub API and check
whether any of them already contain a URL pointing to the same Discourse
topic. URL matching uses Discourse.route_for — the same mechanism used
by the oneboxer, search, and TopicLink — to reliably recognize all topic
URL formats (/t/slug/id, /t/id, /t/id/post_number, etc.).
On any GitHub API error, the check fails open (still posts the linkback)
to preserve the pre-existing behavior — and because if the GET fails,
the subsequent POST will likely fail too.
Changes the event node view link validation, which is only used to
display a "visit link" button on the node view, to use the logic from
markdown-it's linkify.
To achieve this, we expose `pluginParams` (the same that we can access
from input rules, ProseMirror plugins, keymap, etc) to the
`GlimmerNodeView`.
It also moves the event removal regex to an existing helper file, and
makes it support content between the `[event]` open and close tags.
We have recently adjusted the sidebar action of leaving a group DM to
avoid the destructive leave action, which removes the membership
entirely, so the user can't find and reopen that same group DM again.
This PR makes sure the DM list's X to close a group DM does the same.
<img width="435" height="75" alt="image"
src="https://github.com/user-attachments/assets/8138abd6-0972-4117-ae36-b78d66e7ccbb"
/>
The `avatar` helper from `bound-avatar-template` do not expect
`usernamePath`, `namePath`, or `title`, so they're currently being
ignored.
This PR removes these unnecessary arguments and adds a `title` to the
`a`.
This change adds a confirmation modal when moving the topic from a
solved category to a non-solved category when the topic has an accepted
solution. If the admin continues then the solved answer data is removed
from the answer post and the post stream is refreshed to remove the
solved data from the UI (ie. checkbox to mark as solved).
The admin also has the option of hiding the modal in future by selecting
the checkbox when confirming, which uses local storage to record their
preference.
How the modal looks:
<img width="550" alt="Confirmation modal when moving from solved
category"
src="https://github.com/user-attachments/assets/a016f0b8-e7a2-4adb-8ee2-1a2efec230ba"
/>
Internal ref - /t/173671
This is a follow up to the removal of chat action buttons for non
channel members in #37060
Previously we allowed non channel members to react to posts, now the
user can still remove reactions they left in the past (if they were once
a channel member and then left), but attempting to add new reactions via
the API or by clicking other user reactions to increment them would
result in an error message stating that you need to join the channel.
From the API request to add a reaction they would get a 403 response
code with a message `You must be a member of this channel`.
If clicking an existing reaction from the UI they will get an error
dialog:
<img width="550" alt="When non members click existing reactions"
src="https://github.com/user-attachments/assets/57b7a205-e5b7-426d-a64d-19dd04803686"
/>
Internal ref - /t/160400