Skip to content

Add the public channel behind flag_public_channel - #4284

Merged
snopoke merged 44 commits into
mainfrom
bt/public-channel-backend-3682
Aug 31, 2026
Merged

Add the public channel behind flag_public_channel#4284
snopoke merged 44 commits into
mainfrom
bt/public-channel-backend-3682

Conversation

@barry47products

@barry47products barry47products commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

Product Description

Step 2 of #3682 (design: docs/design/public-channel.md, D1 to D6, D8, D9). Behind flag_public_channel, a chatbot can get a "Public link" channel: a /c/<token>/ page on the OCS host with the chat widget in kiosk mode. Share the link and people can chat, no embedding needed.

What a builder sees:

  • "Public link" in the add-channel dropdown (one per chatbot), with welcome messages and starter questions as channel settings.
  • The channel dialog shows the link, a copy button and "Regenerate link". The chatbot home gets a "Copy link" chip next to the channel button.
  • The page shows the chatbot name and description above the widget. If the channel is disabled or nothing is published, visitors get a banner instead of a live widget.

Visitors only ever reach the published version. Regenerating the link revokes the old one straight away and ends every live conversation on it. Disabling or deleting the channel ends live conversations too.

The old chat-widget prototype behind flag_chat_widget is gone; that flag row can be deleted from /admin/flags/ after merge.

Technical Description

  • ChannelPlatform.PUBLIC maps to ApiChannel and reuses the embedded widget's widget_token embed key and session-token auth. ChannelPlatform.widget_platforms() covers the places both widget platforms share (auth lookups, widget_auth_level). The auth-level ratchet, migration 0029, EmbeddedWidgetChannelForm, fetch_widget_embed_key, the widget update status and the outdated-widget report stay EMBEDDED_WIDGET only, since the platform bundles the widget a public link serves.
  • One origin rule, channel_origin_allowed(request, channel), replaces the two allowed_domains reads: allowed_domains for the embedded widget, the Site hostname (port ignored) for a public channel.
  • Start refuses a public visitor with 409 no_published_version. Send and upload re-resolve the published version on every request and return the same 409 if it has gone. Team members are exempt at start and per request so they can preview an unpublished chatbot through its page.
  • ChatAPIRateThrottle keys a public start on the visitor IP (so RATE_LIMIT_TRUSTED_PROXY_COUNT matters behind a proxy). The page itself is on the existing public_chat scope.
  • "Regenerate link" posts regenerate_link=1 to the edit dialog. PublicChannelForm.clean() mints the token; post_save calls ExperimentChannel.end_live_sessions(), which marks the channel's open sessions COMPLETE (what the widget and API key off). soft_delete() and disabling the channel do the same.
  • The page is a standalone template with a CSP, X-Robots-Tag: noindex and Referrer-Policy: origin; no Sentry or analytics on it. /c/ is disallowed in robots.txt. user-id/user-name are passed to team members only.
  • ChannelPlatform.for_dropdown now tolerates a used platform whose flag is off (it used to KeyError on the chatbot home).

Things to know when running it:

  • persistent-session="tab", now that widget 0.12.0 is live on the site. The session lives in sessionStorage, so it survives a reload and clears when the tab closes, and a shared browser does not hand the next visitor the previous conversation.
  • Everything is pinned to the Django Site domain. If that row does not name the deployed host, links 404 and starts 403. The hosting docs say so now.
  • Turning the flag off hides the channel type from the dropdown but does not revoke existing links. Regenerate or delete the channel for that.
  • The /c/<token>/ lookup on extra_data is unindexed until the GIN index follow-up; the public_chat per-IP throttle bounds it.
  • Unchanged platform shape worth knowing: an anonymous start with the working version's chatbot_id and no embed key still reaches the team API channel and its published-or-working version (that is the keyless-start sunset's job); after start, chat endpoints throttle per session rather than per IP; RATE_LIMIT_ENFORCE defaults to log-only; the start response includes version names and descriptions.

Follow-ups from the design, not in this PR: GIN index on extra_data, scrubbing /c/<token>/ from logs and Sentry URLs before GA, step 3 (consent in the widget).

Migrations

  • The migrations are backwards compatible

0034_alter_experimentchannel_platform: choices only, no data touched.

Demo

Local dev server, flag on, Site domain localhost:8000, published chatbot.

Step Screenshot
1. Create dialog 01-create-dialog
2. Edit dialog after create: link, copy, regenerate 02-edit-dialog-with-link
3. "Copy link" chip on the chatbot home 03-home-copy-chip
4. /c/<token>/ as an anonymous visitor 04-public-page-visitor
5. A starter question answered (participant platform=public, trace on the published version) 05-public-chat-reply
6. "Link regenerated" 06-after-regenerate-dialog
7. The old tab: session ended, composer locked (old URL 404s, old key 401s) 07-old-tab-chat-ended
8. The new link chatting 08-new-link-works
9. After unpublishing: banner, widget disabled, live send and fresh start both 409 no_published_version 09-unpublished-banner

The public page renders light regardless of the app theme: it is a standalone template with no data-theme handling. Worth a follow-up if we want it to follow the visitor's OS preference.

Docs and Changelog

  • This PR requires docs/changelog update

Channel docs gain the "Public link" channel (flag-gated); the automation opens the docs PR on merge. Hosting docs (rate-limit scopes, RATE_LIMIT_TRUSTED_PROXY_COUNT, Site domain) are updated here.

Operator Impact

  • Self-hosted operators must know about or act on this change

Nothing breaks on upgrade. The channel sits behind flag_public_channel, which is off by default, and no existing behaviour changes until a team is opted in at /admin/flags/. Before enabling it:

  • The Django Site row must name the deployed host. Public links are built from it, and every request to /c/<token>/ is checked against it. If it does not match, links 404 and their chat starts are refused with 403. The value is cached per process, so a change needs a restart. Written up in docs/hosting/configuration.md.
  • Set RATE_LIMIT_TRUSTED_PROXY_COUNT behind a proxy or tunnel. A public link start is keyed on the visitor IP, and the page itself is on the public_chat scope. Left at 0 behind a proxy, every visitor shares one bucket.
  • Visitors' browsers fetch the widget from unpkg.com. The page's CSP allows unpkg.com for scripts and cdnjs.cloudflare.com for fonts and styles. A deployment that expects visitor traffic to reach only its own origin needs to know that.
  • robots.txt now disallows /c/.

Turning the flag off later hides the channel type from the dropdown but does not revoke links already handed out. Regenerate or delete the channel for that.

After merge the flag_chat_widget row can be deleted from /admin/flags/; the prototype it gated is gone.

Re-check the consent form per request so a consent form published mid-session ends the live public session with 409 consent_unavailable, end live sessions when a public channel is disabled or deleted (only regenerate did), drop blank lines from the welcome and starter lists instead of failing validation (a trailing newline blocked regenerate), pass user-id and user-name to team members only, keep the widget live for team members on refused states, include public channels in the outdated widget report, and pin the API-level revocation behaviour with a test.
Comment thread apps/chatbots/tests/test_public_link_page.py Fixed
Comment thread apps/chatbots/tests/test_public_link_page.py Fixed
codescene-delta-analysis[bot]

This comment was marked as outdated.

@coderabbitai

coderabbitai Bot commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Adds PUBLIC as a channel platform across API schemas, models, registries, feature flags, and widget tooling. Adds public-link configuration, token regeneration, URL rendering, and live-session cleanup. Adds a canonical-host public chatbot page with kiosk rendering, refusal states, throttling, and security headers. Centralizes origin authorization and applies public-channel version, consent, authentication, and throttling rules to chat APIs. Adds documentation and comprehensive tests.

Estimated code review effort: 4 (Complex) | ~60 minutes

Merge Risk: 🟠 High · up to aa95a

The public-link flow can expose unpublished chatbot details, mishandle IPv6 hosts, and leave conversations active after link regeneration or channel shutdown if a database update fails; the legacy flag removal also needs explicit owner approval. Merge should be blocked until these issues are addressed or formally accepted.

Suggested reviewers: snopoke, smittiec

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 22.83% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 127 functions across 21 files. (9 skipped… Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely identifies the main change: adding the public channel behind the flag_public_channel feature flag.
Description check ✅ Passed The description is complete and follows the repository template. It covers the product and technical changes, migration compatibility, demo steps, documentation impact, operator impact, feature-flag b…
Full details: Docstring Coverage

Explanation

Docstring coverage is 22.83% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 127 functions across 21 files. (9 skipped: 9 unsupported.)

Full details: Description check

Explanation

The description is complete and follows the repository template. It covers the product and technical changes, migration compatibility, demo steps, documentation impact, operator impact, feature-flag behavior, deployment requirements, and known follow-ups.

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

codescene-delta-analysis[bot]

This comment was marked as outdated.

Pull the version_number handling in chat_send_message into _send_version, share the published-version lookup between the two public-link helpers, gate email and public in for_dropdown through one flag helper, and assert the public page CSP by value rather than by substring.
codescene-delta-analysis[bot]

This comment was marked as outdated.

The inline-imports check showed the local import in end_live_sessions hoists cleanly; Experiment was already imported from the same module at the top of the file.
codescene-delta-analysis[bot]

This comment was marked as outdated.

A public link serves the widget bundled with the platform, so its version moves with the deploy and a team has nothing to upgrade. Reporting it gives whoever runs the command a row they cannot act on.

Claude-Session: https://claude.ai/code/session_014quZadopDrfAzty9X1Sm5D
codescene-delta-analysis[bot]

This comment was marked as outdated.

widget_update_status, min_widget_version and pending_min_widget_version all describe a widget the host site loads and the team can upgrade. A public link serves the widget bundled with the platform, so its version follows the deploy and these three name something the team has no way to change.

widget_auth_level stays on both widget platforms: the public channel authenticates with an embed key and session token, so apps.api.permissions and the chat views read it.

Claude-Session: https://claude.ai/code/session_014quZadopDrfAzty9X1Sm5D
codescene-delta-analysis[bot]

This comment was marked as outdated.

Comment thread apps/chatbots/public_link.py Outdated
A public link served from a non-canonical host returned a bare 404, which reads the same as a mistyped token, so a misconfigured deployment gives whoever is testing it nothing to work from.

Follows the TeamAccessDenied convention: a tagged Http404 subclass that 404.html recognises through the exception context variable. A signed-in viewer is named both hosts; an anonymous visitor still gets the bare 404, so nothing about the deployment reaches the public and the request is still refused before any lookup.

Claude-Session: https://claude.ai/code/session_014quZadopDrfAzty9X1Sm5D
Comment thread apps/chatbots/tests/test_public_link_page.py Fixed
codescene-delta-analysis[bot]

This comment was marked as outdated.

codescene-delta-analysis[bot]

This comment was marked as outdated.

_page_state returned early on a disabled channel without resolving the published version, so the view fell back to the working version for the page title and description. A team that renamed its draft after publishing had that name shown to anonymous visitors whenever the channel was switched off, which contradicts the published-version-only rule the start API enforces.

Resolving the published version before any refusal keeps every state naming the chatbot a visitor could have reached. The existing tests could not catch this: the helper renames before create_new_version, so draft and published names were always identical.

Claude-Session: https://claude.ai/code/session_014quZadopDrfAzty9X1Sm5D
CodeQL flagged the bare "other.example.com in html" check as incomplete URL substring sanitization. It is a test assertion rather than sanitization, so nothing was exploitable, but a substring that loose also passes when the host appears anywhere on the page.

Asserting the surrounding markup pins the host to the sentence meant to carry it, and deriving the canonical root from get_server_root() keeps the assertion correct whichever way USE_HTTPS_IN_ABSOLUTE_URLS is set.

Claude-Session: https://claude.ai/code/session_014quZadopDrfAzty9X1Sm5D
codescene-delta-analysis[bot]

This comment was marked as outdated.

@claude

claude Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

Non-blocking: the PR description is missing the ### Operator Impact section from .github/pull_request_template.md — the substance is already covered in the body prose and the hosting docs, so it just needs the heading and checkbox.

The start-session and send paths refused a public visitor with a 409 when the published version carried a consent form, and the page showed a matching banner. No other channel reads consent_form in the Chat API, so the public link was the only surface applying it, and a chatbot carrying its team's form would have had a link that never opened.

The page banner goes with the API refusal. Keeping it would show a visitor a refusal on a page whose widget the API now serves.

409 on start-session is no_published_version only, and the schema description follows. Tests cover the new behaviour on both paths: a consent-form chatbot starts a session and receives a session token, a send continues after a consent form is published mid-session, and the page renders the kiosk widget with no banner.

Claude-Session: https://claude.ai/code/session_01GGtbSvKvUsS1i8mvwiQBuK
codescene-delta-analysis[bot]

This comment was marked as outdated.

Two copies of the same fixture, one per test module, each creating the flag row and adding the team. They move to the package conftest alongside the other shared channel fixtures.

The docstring records why waffle's override_flag does not replace this: the platform gate reads Flag.is_active_for_team, which matches on the team M2M and never on everyone, the only field override_flag sets. A test decorated with it would assert the opposite of what it means.

Claude-Session: https://claude.ai/code/session_01GGtbSvKvUsS1i8mvwiQBuK
codescene-delta-analysis[bot]

This comment was marked as outdated.

The docstring said most chatbots carry their team's consent form. Nothing establishes that: ChatbotForm never sets consent_form, so a chatbot created through the UI starts without one.

The point the test makes does not need a frequency claim. Gating the public link on a consent form would refuse any chatbot that carries one, and no other channel collects consent through the Chat API.

Claude-Session: https://claude.ai/code/session_01GGtbSvKvUsS1i8mvwiQBuK
codescene-delta-analysis[bot]

This comment was marked as outdated.

The page fell back to the working version whenever no published version existed, so a builder who shared a link and then renamed the draft to something internal had that name rendered in the title and heading for anyone holding the token. Two paths reached it: a channel that has never been published, and one that is both disabled and never published.

Anonymous visitors now get a placeholder name and no description. Team members keep the draft's name, since they use this page to preview before publishing and the placeholder would hide what they came to look at.

The docstring on _page_state claimed every state names the chatbot the visitor could have reached. That was stronger than the code could deliver on a branch with no published row, so it now says what actually holds.

Claude-Session: https://claude.ai/code/session_01GGtbSvKvUsS1i8mvwiQBuK

@codescene-delta-analysis codescene-delta-analysis Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Health Improved (2 files improve in Code Health)

Gates Failed
Enforce critical code health rules (1 file with Low Cohesion)

Our agent can fix these. Install it.

Gates Passed
2 Quality Gates Passed

Reason for failure
Enforce critical code health rules Violations Code Health Impact
forms.py 1 critical rule 9.07 → 8.00 Suppress

See analysis details in CodeScene

View Improvements
File Code Health Impact Categories Improved
models.py 9.69 → 10.00 Complex Method
chat.py 8.24 → 8.60 Complex Method, Bumpy Road Ahead, Overall Code Complexity

Quality Gate Profile: Clean Code Collective
Install CodeScene MCP: safeguard and uplift AI-generated code. Catch issues early with our IDE extension and CLI tool.

@snopoke
snopoke merged commit 8136bb6 into main Aug 31, 2026
21 of 22 checks passed
@snopoke
snopoke deleted the bt/public-channel-backend-3682 branch August 31, 2026 10:31
@ocs-agent

ocs-agent Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

⚠️ Automated changelog update failed for this PR.

Workflow Run: https://github.com/dimagi/open-chat-studio-docs/actions/runs/33382814830

Please check the logs or manually update the changelog in the docs repository.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants