feat: standalone embeddable thread view with opt-in frame origins#100
Conversation
6f28446 to
0f28673
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6f284465fe
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if env := os.Getenv("CLICKCLACK_PUBLIC_API_URL"); env != "" { | ||
| cfg.PublicAPIURL = env | ||
| } | ||
| if env := os.Getenv("CLICKCLACK_EMBED_FRAME_ANCESTORS"); env != "" { |
There was a problem hiding this comment.
Forward embed origins into Cloudflare containers
When ClickClack is deployed through the checked Cloudflare pipeline, this environment setting never reaches the API process: infra/cloudflare/worker.ts explicitly constructs ClickClackContainer.envVars but does not forward env.CLICKCLACK_EMBED_FRAME_ANCESTORS. Consequently, configuring the documented Worker variable leaves cfg.EmbedFrameAncestors empty and every embedded page remains restricted to 'self', making cross-origin embedding unusable on that deployment.
Useful? React with 👍 / 👎.
| parsed, err := url.Parse(value) | ||
| if err != nil || parsed.Opaque != "" || parsed.User != nil || parsed.Hostname() == "" || | ||
| strings.Contains(parsed.Hostname(), "*") || | ||
| (parsed.Scheme != "http" && parsed.Scheme != "https") || | ||
| (parsed.Path != "" && parsed.Path != "/") || parsed.RawQuery != "" || parsed.Fragment != "" { |
There was a problem hiding this comment.
Reject origins with invalid ports
Reject malformed port forms while validating configured origins. Go's url.Parse accepts values such as https://control.example.com: and https://control.example.com:99999, and the current predicates accept and emit them into frame-ancestors; browsers cannot match these as valid origins, so an operator typo silently leaves the intended host unable to frame the embed instead of failing startup validation.
Useful? React with 👍 / 👎.
What Problem This Solves
There is no way to dock a single ClickClack conversation inside another application. Teams that anchor a discussion to an external object — for example an OpenClaw agent session whose Control UI wants a per-session team-chat panel — currently have to open the full ClickClack app in a separate window, with the whole workspace chrome, and navigate to the thread by hand.
Why This Change Was Made
Adds a standalone thread-only page at
/embed/thread/{workspace_route_id}/{message_route_id}that renders the root message, replies, and the normal thread composer with live realtime updates — and nothing else (no rail, sidebar, or top bar). It reuses the existingThreadPanel,ChatComposer, and message rendering rather than forking them. Framing is opt-in per exact origin via a newCLICKCLACK_EMBED_FRAME_ANCESTORS/--embed-frame-ancestors/embed_frame_ancestorssetting; only/embed/*HTML responses receive theframe-ancestorsCSP (default'self'), and no global frame, cookie, or CORS behavior changes. Humans authenticate with their normal session cookie; a signed-out frame offers a sign-in link that opens the full app in a new tab and reconnects automatically when focus returns.User Impact
Operators can embed any thread as an iframe panel in a host application they control: it loads with the viewer's real ClickClack identity, posts replies with nonce idempotency, follows realtime reply/edit/delete events with cursor recovery, matches light/dark appearance, and offers an "Open in ClickClack" escape hatch into the full app. Deployments that do not configure embed origins are unchanged.
Evidence
tests/e2e/embed-thread.spec.ts): loads the embed by public route IDs, verifies standalone full-width rendering with no app chrome, posts a reply through the composer, and observes an externally posted reply, an edit, and a delete arrive via realtime.frame-ancestorsCSP on/embed/*only (configured origins + default'self', absent on/app/*), origin validation (rejects wildcards, paths, credentials), config/env/flag parsing.pnpm test(web build + all Go packages),pnpm typecheck && pnpm -r typecheck,pnpm lint,pnpm fmt:checkall pass.