mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 02:26:12 +02:00
feat: allow operator UI snippets on Cloud instances (#13168)
Adds an optional Cloud-only HTML snippet so operators can load Plain’s standard chat bubble. **6 files, 16 implementation lines added; 102 additions including tests and docs.** ## Thinking Path > - Paperclip serves Cloud and self-hosted users. > - Closed beta users need a way to report problems. > - Plain provides a ready-made chat widget. > - Cloud operators can load it through a generic deployment setting. > - Self-hosted instances ignore that setting. ## Linked Issues or Issue Description **Subsystem affected** Server-served UI HTML. **Problem or motivation** Enable a chat bubble in Cloud without adding a support feature to the React app. **Proposed solution** Insert trusted `PAPERCLIP_CLOUD_UI_SNIPPET` HTML before `</body>` when the existing Cloud-managed predicate is true. The setting is off by default. Related Cloud-gated integration: #12190. ## What Changed Review the [final diff](https://github.com/paperclipai/paperclip/pull/13168/files) in this order: 1. `server/src/cloud-ui-snippet.ts`: the eight-line Cloud gate and HTML insertion. 2. `server/src/static-index-html.ts` and `server/src/app.ts`: apply it to static root/index, SPA routes, and Vite HTML. 3. Two test files and `doc/cloud-ui-snippet.md`: boundary checks and setup instructions. React UI, customer identity, and database behavior are unchanged. The existing feedback flag remains. Plain chat is anonymous; no Paperclip name, email, or organization is supplied. ## Verification - **Greptile: 5/5**, no actionable findings, reviewed commit `04bb44515`. - **[CI passed](https://github.com/paperclipai/paperclip/actions/runs/34535763243)**, including build, typecheck, server tests, and end-to-end tests. - Local: six focused tests, full typecheck, and build passed. The full local suite has not produced a final result; CI is the completed full verification. - Staging deployment and live chat testing remain to be done. ### Staging setup Set **one server environment variable**, `PAPERCLIP_CLOUD_UI_SNIPPET`, to: ```html <script> (function(d) { var script = d.createElement('script'); script.src = 'https://chat.cdn-plain.com/index.js'; script.onload = function() { Plain.init({ appId: 'liveChatApp_01M26J213F6RR53YRARZVAFCZZ' }); }; d.head.appendChild(script); })(document); </script> ``` This is the public staging app ID. **No API key or signing secret is needed.** Deploy to staging and restart the app with this setting. Test `/`, `/index.html`, and an organization dashboard; send a message and confirm a support reply returns. Production rollout is separate. [Plain embed docs](https://www.plain.com/docs/product/channels/chat) · [Configuration and rollback](https://github.com/paperclipai/paperclip/blob/04bb4451510b124f21b6771b7a8163faad61d8ec/doc/cloud-ui-snippet.md) ## Risks Only trusted operators should set this value. The HTML is public and scripts execute in the app origin; do not include secrets or user-provided HTML. Plain owns the anonymous browser session, with no Paperclip account-switch integration. To roll back, unset the variable, restart, and refresh open tabs. ## Model Used OpenAI Codex (GPT-6), with repository inspection and code execution. Exact runtime model identifier and context size are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change and contains no internal Paperclip ticket id or instance-derived details - [ ] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
6 files changed
+102
-3
No files matched your search
@@ -0,0 +1,42 @@
|
||||
# Cloud UI snippet
|
||||
|
||||
Cloud operators can set `PAPERCLIP_CLOUD_UI_SNIPPET` to an HTML snippet.
|
||||
The server inserts it before `</body>` in static and Vite-served UI pages.
|
||||
It requires the existing Cloud-managed instance signal. Self-hosted instances
|
||||
ignore this setting. No snippet is enabled by default.
|
||||
|
||||
This is trusted deployment configuration, not user input. It executes in the
|
||||
application origin and is visible to every browser that receives the UI shell.
|
||||
Do not include secrets or customer data. Restart the app after changing it.
|
||||
Operators must review scripts and any required CSP changes before deployment.
|
||||
|
||||
## Plain closed beta
|
||||
|
||||
Set the value to this standard embed, replacing `YOUR_CHAT_APP_ID` with the
|
||||
public chat app ID for the target environment:
|
||||
|
||||
```html
|
||||
<script>
|
||||
(function(d) {
|
||||
var script = d.createElement('script');
|
||||
script.src = 'https://chat.cdn-plain.com/index.js';
|
||||
script.onload = function() { Plain.init({ appId: 'YOUR_CHAT_APP_ID' }); };
|
||||
d.head.appendChild(script);
|
||||
})(document);
|
||||
</script>
|
||||
```
|
||||
|
||||
No signing secret or Plain API key is required. No Paperclip customer identity
|
||||
or organization data is passed. Plain manages the anonymous browser session;
|
||||
there is no Paperclip account-switch integration. Ask users for identifying
|
||||
information when needed. The existing feedback flag remains unchanged.
|
||||
|
||||
Docs: [Plain chat](https://www.plain.com/docs/product/channels/chat).
|
||||
|
||||
## Verification and rollback
|
||||
|
||||
On staging, open `/`, `/index.html`, and an organization dashboard directly.
|
||||
Confirm the bubble appears and a test message reaches Plain. Verify the support
|
||||
reply returns. On a self-hosted instance, confirm no snippet or widget is loaded.
|
||||
Unset the snippet and restart to remove it on the next page load. Existing open
|
||||
tabs retain the widget until refreshed. No production deployment is implied.
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { injectCloudUiSnippet } from "../cloud-ui-snippet.js";
|
||||
|
||||
const html = '<html><body><div id="root"></div></body></html>';
|
||||
const snippet = '<script src="https://example.com/widget.js"></script>';
|
||||
|
||||
describe("Cloud UI snippet", () => {
|
||||
it("leaves self-hosted HTML unchanged even when a snippet is configured", () => {
|
||||
expect(injectCloudUiSnippet(html, { PAPERCLIP_CLOUD_UI_SNIPPET: snippet })).toBe(html);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN: "test-token" },
|
||||
{ PAPERCLIP_MANAGED_CONFIG: "{}" },
|
||||
])("injects only on a configured Cloud instance: %j", (cloud) => {
|
||||
expect(injectCloudUiSnippet(html, { ...cloud, PAPERCLIP_CLOUD_UI_SNIPPET: snippet }))
|
||||
.toBe(html.replace("</body>", `${snippet}\n</body>`));
|
||||
expect(injectCloudUiSnippet(html, cloud)).toBe(html);
|
||||
expect(injectCloudUiSnippet(html, { ...cloud, PAPERCLIP_CLOUD_UI_SNIPPET: " " })).toBe(html);
|
||||
});
|
||||
|
||||
it("preserves literal replacement tokens in operator JavaScript", () => {
|
||||
const script = '<script>console.log("$&", "$`", "$\'");</script>';
|
||||
const result = injectCloudUiSnippet(html, {
|
||||
PAPERCLIP_MANAGED_CONFIG: "{}", PAPERCLIP_CLOUD_UI_SNIPPET: script,
|
||||
});
|
||||
expect(result).toContain(script);
|
||||
expect(result).not.toContain("test-token");
|
||||
});
|
||||
});
|
||||
@@ -3,18 +3,31 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { readBrandedStaticIndexHtml } from "../static-index-html.js";
|
||||
|
||||
describe("static SPA fallback HTML", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("includes the operator snippet only in Cloud-served static HTML", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-cloud-html-"));
|
||||
tempDirs.push(dir);
|
||||
fs.writeFileSync(path.join(dir, "index.html"), "<html><body>App</body></html>");
|
||||
vi.stubEnv("PAPERCLIP_CLOUD_UI_SNIPPET", '<script src="https://example.com/chat.js"></script>');
|
||||
vi.stubEnv("PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", undefined);
|
||||
vi.stubEnv("PAPERCLIP_MANAGED_CONFIG", undefined);
|
||||
expect(readBrandedStaticIndexHtml(dir)).not.toContain("chat.js");
|
||||
vi.stubEnv("PAPERCLIP_MANAGED_CONFIG", "{}");
|
||||
expect(readBrandedStaticIndexHtml(dir)).toContain('chat.js"></script>\n</body>');
|
||||
});
|
||||
|
||||
it("serves the current index.html instead of reusing stale asset hashes", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-static-index-"));
|
||||
tempDirs.push(tempDir);
|
||||
|
||||
+6
-1
@@ -113,6 +113,7 @@ import { adapterRoutes } from "./routes/adapters.js";
|
||||
import { managedAgentProfileRoutes } from "./routes/managed-agent-profiles.js";
|
||||
import { remoteAgentProfileRoutes } from "./routes/remote-agent-profiles.js";
|
||||
import { pluginUiStaticRoutes } from "./routes/plugin-ui-static.js";
|
||||
import { injectCloudUiSnippet } from "./cloud-ui-snippet.js";
|
||||
import { readBrandedStaticIndexHtml } from "./static-index-html.js";
|
||||
import { staticUiCacheControl } from "./static-ui-cache.js";
|
||||
import { applyUiBranding } from "./ui-branding.js";
|
||||
@@ -950,6 +951,10 @@ export async function createApp(
|
||||
immutable: true,
|
||||
}),
|
||||
);
|
||||
// Serve root/index through the same runtime HTML transform as SPA routes.
|
||||
app.get(["/", "/index.html"], (_req, res) => {
|
||||
res.type("html").set("Cache-Control", "no-cache").send(readBrandedStaticIndexHtml(uiDist));
|
||||
});
|
||||
// Non-hashed static files (favicon.ico, manifest, robots.txt, etc.):
|
||||
// short cache so operators who swap them out see the new version
|
||||
// reasonably fast, with must-revalidate overrides for index.html and
|
||||
@@ -1058,7 +1063,7 @@ export async function createApp(
|
||||
viteHtmlRenderer = createCachedViteHtmlRenderer({
|
||||
vite,
|
||||
uiRoot,
|
||||
brandHtml: applyUiBranding,
|
||||
brandHtml: (html) => injectCloudUiSnippet(applyUiBranding(html)),
|
||||
});
|
||||
const renderViteHtml = viteHtmlRenderer;
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { isCloudManagedInstance, type CloudInstanceEnv } from "./services/cloud-instance.js";
|
||||
|
||||
/** Trusted operator HTML only. This content is public and runs in the app origin. */
|
||||
export function injectCloudUiSnippet(html: string, env: CloudInstanceEnv = process.env): string {
|
||||
const snippet = env.PAPERCLIP_CLOUD_UI_SNIPPET;
|
||||
if (!isCloudManagedInstance(env) || !snippet?.trim()) return html;
|
||||
return html.replace(/<\/body>/i, () => `${snippet}\n</body>`);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { injectCloudUiSnippet } from "./cloud-ui-snippet.js";
|
||||
import { applyUiBranding } from "./ui-branding.js";
|
||||
|
||||
export function readBrandedStaticIndexHtml(uiDist: string): string {
|
||||
return applyUiBranding(fs.readFileSync(path.join(uiDist, "index.html"), "utf-8"));
|
||||
return injectCloudUiSnippet(applyUiBranding(fs.readFileSync(path.join(uiDist, "index.html"), "utf-8")));
|
||||
}
|
||||
Reference in new issue
Block a user