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](04bb445151/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:
parent
4042eb1c48
commit
60ee13a0f7
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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")));
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue