Skip to content
16 changes: 16 additions & 0 deletions .changeset/calm-media-seam.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"@caplets/core": minor
"@caplets/pi": patch
---

Unify HTTP-like non-inline results behind explicit local-artifact and remote-reference variants, preserve mixed MCP content blocks, and prevent hosted Adapters from exposing managed filesystem paths.

GraphQL operation results now share the Media pipeline with a 1 MiB inline threshold and a 100 MiB artifact cap. Pi renders local artifact paths and remote artifact references according to the result variant.

Replace `handleServerTool`'s positional manager arguments with a named backend runtime. External `@caplets/core` callers now construct that runtime with `createBackendOperationRuntime`; common backend operations dispatch through its `operations` Interface, while MCP-only resource, prompt, and completion methods remain on `runtime.mcp`.

Make exposure projection the generation-bound callable-surface authority. MCP, Attach, and native adapters now render registration facts and Code Mode identities from the same projection, reject stale callbacks across reloads, discard out-of-order discovery, and keep hidden or unresolved Caplets out of declarations and execution allowlists.

Concentrate Current Host administration behind one typed operations Interface shared by dashboard and Operator bearer adapters. Operator activity now records the real acting Client, exact Access and Operator route roles are enforced, both Client roles can revoke only their own credential, and self-revocation or demotion ends the acting dashboard session. Raw Vault Reveal remains dashboard-only and expires from browser memory.

Give native Cloud and self-hosted Project Binding one lifecycle owner for accepted Caplet IDs, serialized updates, cleanup-last close, and atomic remote replacement while preserving their distinct failure policies. Self-hosted sockets now reauthorize durable Access Clients at execution time and serialize heartbeat, expiry, prune, end, and shutdown state so stale work cannot revive a terminal lease.
40 changes: 24 additions & 16 deletions apps/catalog/src/scripts/observability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@ import {
buildWebEvent,
bucketResultCount,
bucketSearchTerm,
capturePostHogEvent,
classifyRouteFamily,
createPostHogBeforeSend,
filterSentryBrowserEvent,
sanitizePostHogCapture,
type WebEventName,
type WebEventPropertySet,
type WebEventProperties,
Expand All @@ -21,17 +24,26 @@ const environment = import.meta.env.PUBLIC_CAPLETS_ENVIRONMENT ?? import.meta.en
let posthogEnabled = false;

if (posthogToken) {
posthog.init(posthogToken, {
api_host: posthogHost || "https://us.i.posthog.com",
autocapture: false,
capture_pageview: false,
disable_session_recording: true,
disable_surveys: true,
disable_web_experiments: true,
disable_persistence: true,
persistence: "memory",
});
posthogEnabled = true;
try {
posthog.init(posthogToken, {
api_host: posthogHost || "https://us.i.posthog.com",
advanced_disable_flags: true,
autocapture: false,
capture_pageview: false,
disable_session_recording: true,
disable_surveys: true,
disable_web_experiments: true,
disable_persistence: true,
persistence: "memory",
person_profiles: "never",
save_campaign_params: false,
save_referrer: false,
before_send: createPostHogBeforeSend(sanitizePostHogCapture),
});
posthogEnabled = true;
} catch {
// Analytics initialization must never interrupt catalog behavior.
}
}

if (sentryDsn) {
Expand Down Expand Up @@ -103,11 +115,7 @@ function captureCatalogEvent(name: WebEventName, properties: WebEventPropertySet
name,
properties: { surface, ...properties } as WebEventProperties<typeof name>,
});
posthog.capture(event.name, {
...event.properties,
$process_person_profile: false,
$geoip_disable: true,
});
capturePostHogEvent(posthog, event);
} catch {
// Analytics must never affect catalog behavior.
}
Expand Down
166 changes: 163 additions & 3 deletions apps/catalog/test/observability.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,36 @@
// @vitest-environment happy-dom

import type * as WebObservabilityModule from "@caplets/web-observability";
import { beforeEach, describe, expect, it, vi } from "vitest";

const posthogCapture = vi.hoisted(() => vi.fn());
const posthogInit = vi.hoisted(() => vi.fn());
const posthogSanitizer = vi.hoisted(() => vi.fn());

vi.mock("posthog-js", () => ({
default: { capture: posthogCapture, init: posthogInit },
}));

vi.mock("@sentry/browser", () => ({ init: vi.fn() }));

vi.mock("@caplets/web-observability", async (importOriginal) => {
const actual = await importOriginal<typeof WebObservabilityModule>();
return { ...actual, sanitizePostHogCapture: posthogSanitizer };
});

describe("catalog observability", () => {
beforeEach(() => {
beforeEach(async () => {
vi.resetModules();
vi.unstubAllEnvs();
vi.unstubAllGlobals();
vi.restoreAllMocks();
posthogCapture.mockReset();
posthogInit.mockReset();
posthogSanitizer.mockReset();
const actual = await vi.importActual<typeof WebObservabilityModule>(
"@caplets/web-observability",
);
posthogSanitizer.mockImplementation(actual.sanitizePostHogCapture);
document.body.innerHTML = "";
Object.defineProperty(navigator, "clipboard", {
configurable: true,
Expand All @@ -28,8 +53,143 @@ describe("catalog observability", () => {
);
});

it("loads browser observability without provider env", async () => {
await expect(import("../src/scripts/observability")).resolves.toBeDefined();
it("does not initialize or capture catalog analytics without provider env", async () => {
const { captureCatalogSearch } = await import("../src/scripts/observability");

captureCatalogSearch({ query: "raw private search", resultCount: 0 });

expect(posthogInit).not.toHaveBeenCalled();
expect(posthogCapture).not.toHaveBeenCalled();
});

it("installs privacy options and sanitizes the final augmented catalog envelope", async () => {
vi.stubEnv("PUBLIC_CAPLETS_POSTHOG_TOKEN", "phc_test");

await import("../src/scripts/observability");

expect(posthogInit).toHaveBeenCalledWith(
"phc_test",
expect.objectContaining({
api_host: "https://us.i.posthog.com",
advanced_disable_flags: true,
autocapture: false,
capture_pageview: false,
disable_persistence: true,
disable_session_recording: true,
disable_surveys: true,
disable_web_experiments: true,
person_profiles: "never",
persistence: "memory",
save_campaign_params: false,
save_referrer: false,
before_send: expect.any(Function),
}),
);
const options = posthogInit.mock.calls[0]?.[1] as {
before_send: (payload: unknown) => unknown;
};

expect(
options.before_send({
uuid: "0189d14f-4f1a-7000-8000-000000000003",
event: "caplets_catalog_search",
timestamp: new Date("2026-07-10T12:00:00.000Z"),
properties: {
token: "phc_test",
distinct_id: "anonymous-browser-identity",
$device_id: "anonymous-browser-identity",
$is_identified: false,
surface: "catalog",
route_family: "catalog",
page_family: "catalog",
section_category: "search",
search_length_bucket: "medium",
filter_category: "tag",
result_count_bucket: "few",
empty_state_category: "unknown",
$current_url: "https://catalog.caplets.dev/caplets?query=secret",
$unset: ["person_property"],
unknown_application_property: "not allowed",
},
}),
).toEqual({
uuid: "0189d14f-4f1a-7000-8000-000000000003",
event: "caplets_catalog_search",
timestamp: new Date("2026-07-10T12:00:00.000Z"),
properties: {
token: "phc_test",
distinct_id: "anonymous-browser-identity",
surface: "catalog",
route_family: "catalog",
page_family: "catalog",
section_category: "search",
search_length_bucket: "medium",
filter_category: "tag",
result_count_bucket: "few",
empty_state_category: "unknown",
$process_person_profile: false,
$geoip_disable: true,
},
});
});

it("keeps catalog entry points live when init throws", async () => {
vi.stubEnv("PUBLIC_CAPLETS_POSTHOG_TOKEN", "phc_test");
posthogInit.mockImplementationOnce(() => {
throw new Error("init failure");
});

const { captureCatalogSearch } = await import("../src/scripts/observability");

expect(() => captureCatalogSearch({ query: "search", resultCount: 1 })).not.toThrow();
expect(posthogCapture).not.toHaveBeenCalled();
});

it("returns null from its installed hook when the injected sanitizer throws", async () => {
vi.stubEnv("PUBLIC_CAPLETS_POSTHOG_TOKEN", "phc_test");
posthogSanitizer.mockImplementation(() => {
throw new Error("hook failure");
});

await import("../src/scripts/observability");

const options = posthogInit.mock.calls[0]?.[1] as {
before_send: (payload: unknown) => unknown;
};
expect(() => options.before_send({})).not.toThrow();
expect(options.before_send({})).toBeNull();
});

it("swallows capture failure while catalog search continues", async () => {
vi.stubEnv("PUBLIC_CAPLETS_POSTHOG_TOKEN", "phc_test");
posthogCapture.mockImplementation(() => {
throw new Error("capture failure");
});

const { captureCatalogSearch } = await import("../src/scripts/observability");

expect(() =>
captureCatalogSearch({ query: "raw private search", resultCount: 0, filterChanged: "tag" }),
).not.toThrow();
});

it("preserves categorical catalog search behavior", async () => {
vi.stubEnv("PUBLIC_CAPLETS_POSTHOG_TOKEN", "phc_test");

const { captureCatalogSearch } = await import("../src/scripts/observability");
posthogCapture.mockClear();
captureCatalogSearch({ query: "search", resultCount: 0, filterChanged: "tag" });

expect(posthogCapture).toHaveBeenCalledWith("caplets_catalog_search", {
surface: "catalog",
route_family: "catalog",
page_family: "catalog",
section_category: "search",
search_length_bucket: "medium",
result_count_bucket: "zero",
filter_category: "tag",
empty_state_category: "no_results",
});
});

it("sends catalog worker errors as sanitized Sentry envelopes", async () => {
Expand Down
Loading