OGuardAI
Integrations

Build a ChatGPT app with OGuardAI

A guarded connector that brings your data into ChatGPT as tokens, with server-side reveal

OGuardAI ships a ChatGPT app template: a guarded connector that brings your organization's records into a ChatGPT conversation with every personal value already replaced by an OGuardAI token. It is built on the same MCP Streamable HTTP transport as the MCP server, and it calls your runtime's /v1/transform and /v1/rehydrate through the SDK. Detection, tokenization, and restore all happen in the runtime; the app never inspects or transforms raw values itself.

The template is in the repository at examples/chatgpt-app. It ships with fictional records so it runs on its own; you point its retrieval at your own system of record to make it real.

What it protects, and what it does not

Be precise about the reach.

The app protects the data it brings into a conversation. Records and search results enter ChatGPT as {{type:id:cap}} tokens with safe metadata, so the model, the transcript, and whatever OpenAI retains from that conversation hold tokens, not values.

It does not and cannot protect what you type into ChatGPT. Your prompt reaches OpenAI before any tool runs, so a tool cannot reach back and scrub it.

By default the human also sees tokens. Reveal is an explicit click that shows the original values only inside the app's own panel in your browser, fetched from your runtime through a single-use, short-lived ticket. The values never pass through the model, the tool result, or the conversation record. The panel is rendered by ChatGPT's sandbox, so treat anything you reveal as visible on that screen.

Detection is best-effort; a missed value is a leak, and the NER detector is recommended. This changes what OpenAI receives from your data. It does not change OpenAI's terms, retention, or training settings, and it is not a compliance certification.

How the reveal stays safe

  • The sealed session blob and the tokenized text stay in the app process. They never enter the tool result the model or the host can read.
  • The tool result carries only an opaque 128-bit reveal ticket in _meta, hidden from the model.
  • The widget spends the ticket at /reveal, which is same-origin with /mcp and inside your boundary. A ticket is single use with a short TTL; a replayed, unknown, or expired ticket is a 404.
  • /reveal checks the browser Origin against an allowlist before it calls the runtime, and it uses a server-held credential, so no runtime key is ever shipped to the browser.

Run it

Availability: The registry, image and package installs below are private today and provided under a written agreement with Oronts. Request access at /request.

# Start an OGuardAI runtime (dev auth is fine locally)
docker run -p 3000:3000 \
  -e GUARDAI_SESSION_SECRET=$(openssl rand -base64 32) \
  -e GUARDAI_HOST=0.0.0.0 \
  -e GUARDAI_ALLOW_DEV_NON_LOOPBACK=1 \
  ghcr.io/oronts/oguardai/server:latest

# Drive the end-to-end trace against that runtime
GUARDAI_URL=http://localhost:3000 pnpm --filter guardai-chatgpt-app-example demo

The demo prints tokens in the model plane and the original values only in the reveal step. Against a stopped runtime it fails closed and exits nonzero.

The example, file by file

The template lives in the repository at examples/chatgpt-app. This section embeds its real source so you can rebuild the connector without opening the repo. Scenario in one line: a guarded_search MCP tool retrieves fictional German support records, sends them through the runtime's /v1/transform, returns {{type:id:cap}} tokens to the model, and reveals the originals only in the widget panel through a single-use ticket at /reveal.

Prerequisites

  • A running OGuardAI runtime (see Run it above). The base runtime tokenizes structured PII: email, IBAN, phone, cards, IDs. That already covers the email and IBAN values in the fixtures.
  • needs-NER: to also tokenize the person, company, and location names in these German records, run the full stack with the NER detector sidecar. That stack generates its secrets first and uses api-key auth, so the demo then needs GUARDAI_API_KEY set to a provisioned key. See deploy/scripts/gen-secrets.sh and deploy/docker/docker-compose.yml. Without the NER detector, person names stay in the model plane as raw text, and the demo prints a note when no {{person:...}} token is present.

Commands

Run these from examples/chatgpt-app, with a runtime already running:

GUARDAI_URL=http://localhost:3000 pnpm demo   # drive the end-to-end trace

pnpm test        # acceptance and fail-closed checks against a mock runtime
pnpm typecheck   # tsc --noEmit
pnpm build       # tsc && cp src/widget.html dist/
pnpm start       # node dist/server.js  (serve /mcp and /reveal)
pnpm dev         # tsx src/server.ts    (same, without a build step)

This app reads its configuration from the environment; there is no autoloaded .env. Export the variables you need (see the .env.example below) or pass them inline.

What the demo prints

pnpm demo boots the app on an ephemeral port and drives the exact call sequence a ChatGPT host makes. It prints five labeled steps:

  1. initialize + tools/list: the tool list is exactly guarded_search.
  2. tools/call guarded_search (model plane sees tokens): the serialized result shows {{email:...}} (and {{iban:...}}) tokens, then OK: no structured raw value (email, IBAN) crossed into the model plane; they are tokens. If the NER detector is not running, it also prints a note that person, company, and location names need it.
  3. resources/read (widget resource): widget mimeType: text/html;profile=mcp-app.
  4. /reveal (human plane sees values, only in the app panel): the restored text with the original values, then OK: reveal restored N original value(s) in the app panel only.
  5. the same ticket is single-use: OK: replay of a spent ticket is 404.

It ends with All checks passed.. Against a stopped runtime the guarded_search call fails closed and the demo exits nonzero. Person names appear as tokens only when the NER detector is running.

Package manifest: package.json

{
  "name": "guardai-chatgpt-app-example",
  "version": "1.0.0",
  "private": true,
  "description": "A ChatGPT app (MCP Apps) that brings records into the chat as OGuardAI tokens, with server-side reveal",
  "type": "module",
  "scripts": {
    "build": "tsc && cp src/widget.html dist/",
    "typecheck": "tsc --noEmit",
    "test": "vitest run",
    "start": "node dist/server.js",
    "dev": "tsx src/server.ts",
    "demo": "tsx src/demo.ts"
  },
  "dependencies": {
    "@modelcontextprotocol/server": "^2.1.0",
    "@oguardai/mcp-server": "workspace:*",
    "@oguardai/sdk": "workspace:*"
  },
  "devDependencies": {
    "@types/node": "^20",
    "tsx": "^4.0",
    "typescript": "^5.5",
    "vitest": "^3.2.6"
  }
}

Environment reference: .env.example

The required variables:

VariableMeaning
GUARDAI_URLThe OGuardAI runtime base URL (required).
GUARDAI_API_KEY or GUARDAI_BEARER_TOKENRuntime credential, used only in this process.
APP_PUBLIC_ORIGINPublic origin of this app; the widget's only allowed CSP connect domain (required).
REVEAL_ALLOWED_ORIGINSBrowser origins allowed to call /reveal. Empty means every reveal is refused.
GUARDAI_MCP_HTTP_ALLOW_UNAUTHENTICATED or GUARDAI_MCP_HTTP_BEARER_TOKENAuth for /mcp; set exactly one.
GUARDAI_APP_DESTINATIONTransform destination class; empty string omits the context.

The full reference file:

# OGuardAI ChatGPT app example: a reference of the environment variables this app reads. There is
# no autoloaded .env file; export these in your shell or set them in your process manager. All
# placeholders below are fake.

# The OGuardAI runtime this app calls for transform and rehydrate. Loopback http is fine locally;
# a remote runtime must be https.
GUARDAI_URL=http://localhost:3000

# Runtime credential used only inside this process (never sent to the model or the widget). Under a
# production auth mode the key needs the transform and rehydrate scopes; if it sends a destination
# (below), its allowed_destinations must include external_llm. Leave empty for a dev-auth runtime.
GUARDAI_API_KEY=
# Or a bearer token instead of an API key (mutually exclusive with GUARDAI_API_KEY):
# GUARDAI_BEARER_TOKEN=

# Where the widget reaches this app's /reveal route. This is also the single CSP connect domain the
# widget is allowed to call. Use the public https origin of THIS app.
APP_PUBLIC_ORIGIN=https://your-app.example.com

# The browser origins allowed to call /reveal (comma separated). This is the ChatGPT widget sandbox
# origin, which you capture from the first 403 in this app's log during a live run. Empty means every
# reveal is refused (fail closed).
REVEAL_ALLOWED_ORIGINS=

# Auth for the /mcp endpoint. Set exactly one. The unauthenticated flag is the developer-mode pilot
# only; a shared bearer is the minimum for anything past a locked-down pilot.
GUARDAI_MCP_HTTP_ALLOW_UNAUTHENTICATED=true
# GUARDAI_MCP_HTTP_BEARER_TOKEN=replace-with-a-long-random-secret

# Bind address and port for this app.
APP_HOST=127.0.0.1
APP_PORT=8091

# Transform context. Set GUARDAI_APP_DESTINATION to an empty string to omit the context entirely
# (do that when the runtime credential does not allow the external_llm destination).
GUARDAI_APP_DESTINATION=external_llm
GUARDAI_APP_PROVIDER=openai

# Optional transport and MCP knobs. Defaults are safe; shown for completeness.
# Browser origins allowed to call the /mcp endpoint (comma separated); empty means same-origin only.
# GUARDAI_MCP_HTTP_ALLOWED_ORIGINS=
# The path the MCP endpoint is served on.
# APP_MCP_PATH=/mcp
# Per-line and concurrency bounds for the MCP HTTP transport.
# GUARDAI_MCP_MAX_LINE_BYTES=
# GUARDAI_MCP_MAX_CONCURRENCY=
# Allow an insecure (plain http, non-loopback) runtime URL; development only, a remote runtime must be https.
# GUARDAI_ALLOW_INSECURE_TRANSPORT=false

Runtime entitlements: the credential needs the transform and rehydrate scopes. If the app sends a destination (the default external_llm), the credential's allowed_destinations must include it, or set GUARDAI_APP_DESTINATION= to omit the context.

Fixture records: fixtures/records.de.json

The self-contained corpus of fictional German support records. canaries lists the raw values that must never survive tokenization; the tool asserts their absence.

{
  "records": [
    {
      "id": "c-8842",
      "title": "Beschwerde ueber verspaetete Lieferung",
      "channel": "email",
      "city": "Muenchen",
      "body": "Kundin Erika Mustermann aus Muenchen meldet, dass ihre Bestellung seit zwei Wochen nicht angekommen ist. Rueckmeldung erbeten an erika.mustermann@example.de oder telefonisch unter +49 89 1234567. Rechnungsbetrag wurde bereits abgebucht von IBAN DE97 9900 0000 0123 4567 89.",
      "tags": ["muenchen", "beschwerde", "lieferung"]
    },
    {
      "id": "c-9007",
      "title": "Rueckerstattung angefragt",
      "channel": "phone",
      "city": "Hamburg",
      "body": "Herr Max Mustermann bittet um Rueckerstattung fuer eine stornierte Bestellung. Der Betrag soll zurueck auf IBAN DE97 9900 0000 0123 4567 89. Kontakt: max.mustermann@example.de.",
      "tags": ["rueckerstattung", "abrechnung"]
    },
    {
      "id": "c-9153",
      "title": "Frage zur Rechnung",
      "channel": "chat",
      "city": "Berlin",
      "body": "Familie Mustermann hat eine Rueckfrage zur letzten Rechnung und moechte eine Kopie per Post. Erreichbar unter erika.mustermann@example.de und +49 30 9876543.",
      "tags": ["rechnung", "abrechnung"]
    },
    {
      "id": "c-8790",
      "title": "Adressaenderung",
      "channel": "email",
      "city": "Muenchen",
      "body": "Kunde Max Mustermann aus Muenchen bittet um Aktualisierung seiner Lieferadresse. Neue Kontaktadresse: max.mustermann@example.de.",
      "tags": ["muenchen", "adresse"]
    },
    {
      "id": "c-9330",
      "title": "Lob fuer den Support",
      "channel": "email",
      "city": "Koeln",
      "body": "Frau Erika Mustermann bedankt sich fuer die schnelle Hilfe des Support-Teams. Rueckfragen an erika.mustermann@example.de.",
      "tags": ["lob"]
    }
  ],
  "canaries": [
    "erika.mustermann@example.de",
    "max.mustermann@example.de",
    "erika.mustermann@example.de",
    "max.mustermann@example.de",
    "erika.mustermann@example.de",
    "DE97 9900 0000 0123 4567 89",
    "DE97 9900 0000 0123 4567 89"
  ]
}

The retrieval stub: src/retrieval.ts

The customer-boundary stub. Repoint retrieve at your own system of record to make it real; whatever it returns is raw data that must pass through the runtime before it reaches the model.

/**
 * The "customer boundary" stub: a tiny keyword search over fixture records.
 *
 * In a real connector this is where the organization's own system of record answers a query.
 * Here it is a fixed set of fictional German support records so the example is self-contained.
 * Whatever this returns is raw data that MUST pass through the runtime before it reaches the
 * model; retrieval never tokenizes anything itself.
 */

import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";

/** One fixture record. The body carries the fictional personal data the runtime protects. */
export interface CustomerRecord {
  readonly id: string;
  readonly title: string;
  readonly channel: string;
  readonly city: string;
  readonly body: string;
  readonly tags: readonly string[];
}

export interface RecordCorpus {
  readonly records: readonly CustomerRecord[];
  /**
   * Raw personal values that must never survive tokenization; the tool asserts their absence. This is
   * a fixture-scoped backstop, not the primary protection: it only catches the exact strings listed
   * here. If you repoint retrieval at a real system of record, keep this list in sync with that
   * source's known raw values, or the backstop passes silently. The runtime tokenization is the
   * primary guarantee.
   */
  readonly canaries: readonly string[];
}

const DEFAULT_TOP_K = 3;

/** Loads the fixture corpus shipped beside this module. */
export function loadCorpus(): RecordCorpus {
  const path = fileURLToPath(new URL("../fixtures/records.de.json", import.meta.url));
  const parsed = JSON.parse(readFileSync(path, "utf8")) as RecordCorpus;
  return parsed;
}

function scoreRecord(record: CustomerRecord, terms: readonly string[]): number {
  const haystack =
    `${record.title} ${record.city} ${record.body} ${record.tags.join(" ")}`.toLowerCase();
  let score = 0;
  for (const term of terms) {
    if (haystack.includes(term)) score += 1;
  }
  return score;
}

/**
 * Returns the top matches for a question. Words shorter than three characters are dropped so common
 * German fillers do not dominate the score. A question that matches nothing falls back to the first
 * records, so the tool always has something to protect and demonstrate rather than returning empty.
 */
export function retrieve(
  question: string,
  corpus: RecordCorpus,
  topK: number = DEFAULT_TOP_K,
): CustomerRecord[] {
  const terms = question
    .toLowerCase()
    .split(/[^\p{L}\p{N}]+/u)
    .filter((term) => term.length >= 3);

  const ranked = corpus.records
    .map((record) => ({ record, score: scoreRecord(record, terms) }))
    .sort((a, b) => b.score - a.score);

  const hits = ranked.filter((entry) => entry.score > 0).map((entry) => entry.record);
  const chosen = hits.length > 0 ? hits : [...corpus.records];
  return chosen.slice(0, topK);
}

Single-use reveal tickets: src/tickets.ts

The sealed session blob and the tokenized text never leave this process. The tool result carries only an opaque 128-bit ticket; the widget spends it once at /reveal.

/**
 * Single-use reveal tickets.
 *
 * The sealed session blob and the tokenized text never leave this process, and never ride in a tool
 * result the model or the host can read. Instead the tool result carries only an opaque ticket. The
 * widget hands that ticket to the app's own /reveal route, which consumes it once and calls the
 * runtime with the server-held credential. A ticket is a random 128-bit id, lives for a short TTL,
 * and is destroyed the first time it is consumed, so a captured ticket cannot be replayed and an
 * unclaimed ticket expires on its own.
 */

import { randomBytes } from "node:crypto";

/** What a consumed ticket yields: exactly the inputs /reveal needs to call rehydrate. */
export interface RevealPayload {
  /** Sealed session blob from the transform response. Never sent to the model or the host. */
  readonly sessionState: string;
  /** Tokenized text to rehydrate (the runtime's serialized safe output for the search result). */
  readonly revealText: string;
}

export interface MintedTicket {
  readonly ticket: string;
  /** Unix seconds at which the ticket stops resolving. */
  readonly expiresAt: number;
}

interface StoredTicket extends RevealPayload {
  readonly expiresEpochMs: number;
}

export interface TicketStoreOptions {
  /** Time to live per ticket, in milliseconds. */
  readonly ttlMs?: number;
  /** Sweep interval for expired tickets, in milliseconds. Set to 0 to disable the timer. */
  readonly sweepMs?: number;
  /** Injectable clock (milliseconds since epoch); defaults to Date.now. Tests pass a fake clock. */
  readonly now?: () => number;
}

const DEFAULT_TTL_MS = 300_000;
const DEFAULT_SWEEP_MS = 60_000;
const TICKET_BYTES = 16;

export class TicketStore {
  private readonly store = new Map<string, StoredTicket>();
  private readonly ttlMs: number;
  private readonly now: () => number;
  private readonly sweepTimer: NodeJS.Timeout | undefined;

  constructor(options: TicketStoreOptions = {}) {
    this.ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
    this.now = options.now ?? Date.now;
    const sweepMs = options.sweepMs ?? DEFAULT_SWEEP_MS;
    if (sweepMs > 0) {
      this.sweepTimer = setInterval(() => this.sweep(), sweepMs);
      // Do not keep the event loop alive for the sweep alone; the process exits on its own work.
      this.sweepTimer.unref?.();
    } else {
      this.sweepTimer = undefined;
    }
  }

  /** Stores a payload under a fresh random ticket and returns the ticket and its expiry. */
  mint(payload: RevealPayload): MintedTicket {
    const ticket = randomBytes(TICKET_BYTES).toString("hex");
    const expiresEpochMs = this.now() + this.ttlMs;
    this.store.set(ticket, { ...payload, expiresEpochMs });
    return { ticket, expiresAt: Math.floor(expiresEpochMs / 1000) };
  }

  /**
   * Resolves a ticket exactly once. Returns the payload and deletes it, or returns undefined when
   * the ticket is unknown, already consumed, or expired. An expired ticket is deleted on the way
   * out so a later probe with the same id is indistinguishable from an unknown one.
   */
  consume(ticket: string): RevealPayload | undefined {
    const entry = this.store.get(ticket);
    if (entry === undefined) return undefined;
    this.store.delete(ticket);
    if (entry.expiresEpochMs <= this.now()) return undefined;
    return { sessionState: entry.sessionState, revealText: entry.revealText };
  }

  private sweep(): void {
    const cutoff = this.now();
    for (const [ticket, entry] of this.store) {
      if (entry.expiresEpochMs <= cutoff) this.store.delete(ticket);
    }
  }

  /** Stops the sweep timer. Call on shutdown so a short-lived process can exit cleanly. */
  dispose(): void {
    if (this.sweepTimer !== undefined) clearInterval(this.sweepTimer);
    this.store.clear();
  }
}

The guarded connector: src/app.ts

Defines the one guarded_search tool and the widget resource. The tool result carries token chunks plus an opaque reveal ticket in _meta; a runtime failure or a canary leak returns a single content-free error so a caller cannot tell a detection outage from a raw-value regression.

/**
 * The guarded connector: one MCP tool and one widget resource.
 *
 * guarded_search brings the organization's records into the conversation with every personal value
 * already replaced by a {{type:id:cap}} token. Detection, tokenization, and restore all happen in
 * the OGuardAI runtime through the shipped SDK; this file never transforms raw values itself.
 * The tool result carries tokens and safe metadata for the model, plus an opaque reveal ticket in
 * _meta that only the widget can spend. The sealed session blob and the tokenized text stay in the
 * ticket store in this process; they never enter the tool result.
 *
 * Honest reach: this protects the data the tool brings in, not the prompt the user types. The
 * user's question reaches the model provider before this tool ever runs.
 */

import { randomUUID } from "node:crypto";
import {
  Server,
  type CallToolResult,
  type ListResourcesResult,
  type ListToolsResult,
  type ReadResourceResult,
} from "@modelcontextprotocol/server";
import type { JsonValue, OGuardAIClient, TransformResponse } from "@oguardai/sdk";
import type { RecordCorpus } from "./retrieval.js";
import { retrieve } from "./retrieval.js";
import type { TicketStore } from "./tickets.js";

export const SERVER_NAME = "guardai-chatgpt-app";
export const SERVER_VERSION = "0.1.0";
export const WIDGET_RESOURCE_URI = "ui://widget/guarded-context.html";
export const WIDGET_MIME_TYPE = "text/html;profile=mcp-app";
export const APP_ORIGIN_PLACEHOLDER = "__APP_PUBLIC_ORIGIN__";

/** Fixed, content-free failure text. Every runtime or guard failure returns exactly this, so a
 *  caller can never tell a detection outage from a raw-value regression, and no data ever leaks. */
const UNAVAILABLE_MESSAGE = "Protected search is unavailable; no records were returned.";

const RESOURCE_NOT_FOUND = -32002;
const INVALID_PARAMS = -32602;

export interface GuardedAppDeps {
  readonly client: OGuardAIClient;
  readonly corpus: RecordCorpus;
  readonly tickets: TicketStore;
  /** Where the widget's /reveal call goes; also the single allowed CSP connect domain. */
  readonly appPublicOrigin: string;
  /** The widget HTML template; APP_ORIGIN_PLACEHOLDER is substituted at read time. */
  readonly widgetHtml: string;
  /** Destination class for the transform context. Empty string omits the context entirely. */
  readonly destination: string;
  readonly provider: string;
}

const GUARDED_SEARCH_TOOL = {
  name: "guarded_search",
  title: "Guarded search",
  description:
    "Searches the organization's records and returns them with personal data replaced by " +
    "{{type:id:cap}} tokens. Tokens are opaque placeholders: never guess, expand, or " +
    "reformat them.",
  inputSchema: {
    type: "object",
    properties: { question: { type: "string" } },
    required: ["question"],
  },
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
  securitySchemes: [{ type: "noauth" }],
  _meta: {
    ui: { resourceUri: WIDGET_RESOURCE_URI },
    "openai/outputTemplate": WIDGET_RESOURCE_URI,
    "openai/toolInvocation/invoking": "Searching protected records",
    "openai/toolInvocation/invoked": "Protected results ready",
  },
} as const;

function widgetResourceMeta(appPublicOrigin: string): Record<string, unknown> {
  return {
    ui: {
      csp: { connectDomains: [appPublicOrigin], resourceDomains: [] },
      prefersBorder: true,
    },
    "openai/widgetCSP": { connect_domains: [appPublicOrigin], resource_domains: [] },
  };
}

function isError(): CallToolResult {
  return {
    isError: true,
    content: [{ type: "text", text: UNAVAILABLE_MESSAGE }],
  } as unknown as CallToolResult;
}

function jsonRpcError(message: string, code: number): Error & { code: number } {
  return Object.assign(new Error(message), { code });
}

/** Pulls the tokenized `chunks` array out of the runtime's safe_json without trusting its shape. */
function extractChunks(safeJson: JsonValue | undefined): JsonValue {
  if (
    safeJson !== undefined &&
    safeJson !== null &&
    typeof safeJson === "object" &&
    !Array.isArray(safeJson)
  ) {
    const record = safeJson as { [key: string]: JsonValue };
    return record.chunks ?? [];
  }
  return [];
}

/**
 * True when any canary raw value survived tokenization anywhere in the runtime's output. This is a
 * fixture-scoped backstop over the corpus canaries, not the primary protection; the runtime
 * tokenization is. Keep the canary list in sync with the retrieval source or it passes silently.
 */
function anyCanaryLeaked(response: TransformResponse, canaries: readonly string[]): boolean {
  const surface = `${JSON.stringify(response.safe_json ?? null)} ${response.safe_text ?? ""}`;
  return canaries.some((canary) => canary.length > 0 && surface.includes(canary));
}

async function runGuardedSearch(deps: GuardedAppDeps, question: string): Promise<CallToolResult> {
  const records = retrieve(question, deps.corpus);
  const chunks: JsonValue[] = records.map((record) => ({
    id: record.id,
    title: record.title,
    channel: record.channel,
    city: record.city,
    body: record.body,
    tags: [...record.tags],
  }));

  let response: TransformResponse;
  try {
    response = await deps.client.transform({
      input_type: "json",
      input_json: { chunks },
      trace_id: randomUUID(),
      ...(deps.destination.length > 0
        ? { context: { destination: deps.destination, provider: deps.provider } }
        : {}),
    });
  } catch {
    return isError();
  }

  if (anyCanaryLeaked(response, deps.corpus.canaries)) return isError();
  if (response.session_state === undefined || response.session_state.length === 0) return isError();

  const { ticket, expiresAt } = deps.tickets.mint({
    sessionState: response.session_state,
    revealText: response.safe_text,
  });

  const entities = response.entities.map((entity) => ({ type: entity.type, token: entity.token }));
  const summary =
    `${records.length} records found; ${entities.length} personal values are protected as ` +
    "tokens. Tokens are opaque placeholders; do not guess or expand them.";

  return {
    content: [{ type: "text", text: summary }],
    structuredContent: {
      chunks: extractChunks(response.safe_json),
      entities,
      entity_count: entities.length,
    },
    _meta: {
      "oguardai/reveal": { ticket, expires_at: expiresAt },
      "openai/toolInvocation/invoked": "Protected results ready",
    },
  } as unknown as CallToolResult;
}

/** Builds a fresh MCP Server whose handlers close over the shared deps (client, corpus). */
export function createGuardedAppServer(deps: GuardedAppDeps): Server {
  const server = new Server(
    { name: SERVER_NAME, version: SERVER_VERSION },
    { capabilities: { tools: {}, resources: {} } },
  );

  server.setRequestHandler("tools/list", async () => {
    return { tools: [GUARDED_SEARCH_TOOL] } as unknown as ListToolsResult;
  });

  server.setRequestHandler("tools/call", async (request) => {
    const params = (request.params ?? {}) as { name?: string; arguments?: Record<string, unknown> };
    if (params.name !== GUARDED_SEARCH_TOOL.name) {
      throw jsonRpcError(`unknown tool: ${String(params.name)}`, INVALID_PARAMS);
    }
    const question = params.arguments?.question;
    if (typeof question !== "string" || question.trim().length === 0) {
      throw jsonRpcError("question is required and must be a non-empty string", INVALID_PARAMS);
    }
    return runGuardedSearch(deps, question);
  });

  server.setRequestHandler("resources/list", async () => {
    return {
      resources: [
        {
          uri: WIDGET_RESOURCE_URI,
          name: "Guarded context",
          mimeType: WIDGET_MIME_TYPE,
          _meta: widgetResourceMeta(deps.appPublicOrigin),
        },
      ],
    } as unknown as ListResourcesResult;
  });

  server.setRequestHandler("resources/read", async (request) => {
    const uri = (request.params as { uri?: string } | undefined)?.uri;
    if (uri !== WIDGET_RESOURCE_URI) {
      throw jsonRpcError(`unknown resource: ${String(uri)}`, RESOURCE_NOT_FOUND);
    }
    const html = deps.widgetHtml.split(APP_ORIGIN_PLACEHOLDER).join(deps.appPublicOrigin);
    return {
      contents: [{ uri: WIDGET_RESOURCE_URI, mimeType: WIDGET_MIME_TYPE, text: html }],
      _meta: widgetResourceMeta(deps.appPublicOrigin),
    } as unknown as ReadResourceResult;
  });

  return server;
}

The app process: src/server.ts

Three load-bearing excerpts from src/server.ts. resolveAppConfig fails closed on missing config, handleReveal gates on the browser Origin, consumes the ticket once, and is the only place the server-held credential is used, and buildApp layers /reveal in front of the reused Streamable HTTP transport so /mcp keeps every gate serveStreamableHttp applies.

Config resolution (src/server.ts):

/** Resolves configuration fail-closed. A missing runtime URL, a missing widget origin, or an absent
 *  MCP auth decision throws before the port is bound. */
export function resolveAppConfig(env: Env): AppConfig {
  const guardaiUrl = env.GUARDAI_URL?.trim();
  if (guardaiUrl === undefined || guardaiUrl.length === 0) {
    throw new Error("GUARDAI_URL is required (the OGuardAI runtime base URL)");
  }
  const appPublicOrigin = env.APP_PUBLIC_ORIGIN?.trim();
  if (appPublicOrigin === undefined || appPublicOrigin.length === 0) {
    throw new Error("APP_PUBLIC_ORIGIN is required (where the widget reaches /reveal)");
  }

  const apiKey = env.GUARDAI_API_KEY?.trim() || undefined;
  const bearerToken = env.GUARDAI_BEARER_TOKEN?.trim() || undefined;
  if (apiKey !== undefined && bearerToken !== undefined) {
    throw new Error(
      "GUARDAI_API_KEY and GUARDAI_BEARER_TOKEN are mutually exclusive; set only one",
    );
  }

  const mcpBearer = env.GUARDAI_MCP_HTTP_BEARER_TOKEN?.trim() || undefined;
  const mcpAllowUnauthenticated = env.GUARDAI_MCP_HTTP_ALLOW_UNAUTHENTICATED === "true";
  if (mcpBearer !== undefined && mcpAllowUnauthenticated) {
    throw new Error(
      "set only GUARDAI_MCP_HTTP_BEARER_TOKEN or GUARDAI_MCP_HTTP_ALLOW_UNAUTHENTICATED, not both",
    );
  }
  if (mcpBearer === undefined && !mcpAllowUnauthenticated) {
    throw new Error(
      "set GUARDAI_MCP_HTTP_BEARER_TOKEN or GUARDAI_MCP_HTTP_ALLOW_UNAUTHENTICATED=true for /mcp",
    );
  }

  const mcpAllowedOrigins = env.GUARDAI_MCP_HTTP_ALLOWED_ORIGINS;
  return {
    guardaiUrl,
    apiKey,
    bearerToken,
    host: env.APP_HOST?.trim() || DEFAULT_HOST,
    port: parsePort(env.APP_PORT),
    mcpPath: env.APP_MCP_PATH?.trim() || DEFAULT_MCP_PATH,
    appPublicOrigin,
    revealAllowedOrigins: parseOriginList(env.REVEAL_ALLOWED_ORIGINS),
    destination: env.GUARDAI_APP_DESTINATION ?? DEFAULT_DESTINATION,
    provider: env.GUARDAI_APP_PROVIDER?.trim() || DEFAULT_PROVIDER,
    mcpBearer,
    mcpAllowUnauthenticated,
    mcpAllowedOrigins:
      mcpAllowedOrigins === undefined ? undefined : parseOriginList(mcpAllowedOrigins),
    maxBodyBytes: parsePositiveInt(
      env.GUARDAI_MCP_MAX_LINE_BYTES,
      DEFAULT_MAX_BODY_BYTES,
      "GUARDAI_MCP_MAX_LINE_BYTES",
    ),
    maxConcurrency: parsePositiveInt(
      env.GUARDAI_MCP_MAX_CONCURRENCY,
      DEFAULT_MAX_CONCURRENCY,
      "GUARDAI_MCP_MAX_CONCURRENCY",
    ),
    maxRequestLifetimeMs: DEFAULT_MAX_REQUEST_LIFETIME_MS,
    allowInsecureTransport: env.GUARDAI_ALLOW_INSECURE_TRANSPORT === "true",
  };
}

The reveal route (src/server.ts):

interface RevealContext {
  readonly client: OGuardAIClient;
  readonly tickets: TicketStore;
  readonly allowedOrigins: ReadonlySet<string>;
}

function corsHeaders(origin: string): Record<string, string> {
  return {
    "access-control-allow-origin": origin,
    "access-control-allow-methods": "POST, OPTIONS",
    "access-control-allow-headers": "content-type",
    "access-control-max-age": "600",
    vary: "Origin",
  };
}

async function handleReveal(
  req: IncomingMessage,
  res: ServerResponse,
  ctx: RevealContext,
): Promise<number> {
  const origin = firstHeader(req, "origin");
  const originAllowed = origin !== undefined && ctx.allowedOrigins.has(origin);

  if (req.method === "OPTIONS") {
    if (!originAllowed) {
      sendJson(res, 403, { error: "origin not allowed" });
      return 403;
    }
    res.writeHead(204, corsHeaders(origin));
    res.end();
    return 204;
  }
  if (req.method !== "POST") {
    sendJson(res, 405, { error: "method not allowed" }, { allow: "POST, OPTIONS" });
    return 405;
  }
  if (!originAllowed) {
    // Fail closed before any ticket lookup or runtime call: reveal is browser-only, so an absent or
    // disallowed Origin is always refused.
    sendJson(res, 403, { error: "origin not allowed" });
    return 403;
  }

  const body = await readRevealBody(req);
  const ticket = typeof body?.ticket === "string" ? body.ticket : undefined;
  const payload = ticket !== undefined ? ctx.tickets.consume(ticket) : undefined;
  if (payload === undefined) {
    // Unknown, expired, or already-used tickets are indistinguishable: one fixed 404.
    sendJson(res, 404, { error: "reveal ticket not found" }, corsHeaders(origin));
    return 404;
  }

  try {
    const restored = await ctx.client.rehydrate({
      output: payload.revealText,
      session_state: payload.sessionState,
      output_channel: "user_output",
    });
    // restored_text is never logged.
    sendJson(
      res,
      200,
      {
        restored_text: restored.restored_text,
        tokens_unresolved: restored.tokens_unresolved ?? [],
      },
      corsHeaders(origin),
    );
    return 200;
  } catch {
    sendJson(res, 502, { error: "reveal unavailable" }, corsHeaders(origin));
    return 502;
  }
}

Composing the server (src/server.ts):

export interface App {
  readonly server: HttpServer;
  readonly tickets: TicketStore;
  readonly close: () => Promise<void>;
}

export interface AppOverrides {
  readonly tickets?: TicketStore;
}

/** Builds the composed HTTP server (not yet listening). /reveal is handled here; every other
 *  path is delegated to the reused Streamable HTTP transport host. */
export function buildApp(config: AppConfig, overrides: AppOverrides = {}): App {
  const client = new OGuardAIClient({
    baseUrl: config.guardaiUrl,
    apiKey: config.apiKey,
    bearerToken: config.bearerToken,
    allowInsecureTransport: config.allowInsecureTransport,
  });
  const tickets = overrides.tickets ?? new TicketStore();
  const deps: GuardedAppDeps = {
    client,
    corpus: loadCorpus(),
    tickets,
    appPublicOrigin: config.appPublicOrigin,
    widgetHtml: loadWidgetHtml(),
    destination: config.destination,
    provider: config.provider,
  };

  const mcpHost = serveStreamableHttp({
    createServer: () => createGuardedAppServer(deps),
    path: config.mcpPath,
    bindHost: config.host,
    allowedOrigins: config.mcpAllowedOrigins,
    allowedHosts: undefined,
    bearer: config.mcpBearer,
    allowUnauthenticated: config.mcpAllowUnauthenticated,
    maxBodyBytes: config.maxBodyBytes,
    maxConcurrency: config.maxConcurrency,
    maxRequestLifetimeMs: config.maxRequestLifetimeMs,
  });
  // Reuse the transport host's single request handler and layer /reveal in front of it, so /mcp
  // keeps every gate serveStreamableHttp applies without re-owning them.
  const mcpListener = mcpHost.listeners("request")[0] as
    | ((req: IncomingMessage, res: ServerResponse) => void)
    | undefined;
  if (mcpListener === undefined) {
    throw new Error("serveStreamableHttp did not expose a request handler");
  }

  const revealCtx: RevealContext = {
    client,
    tickets,
    allowedOrigins: config.revealAllowedOrigins,
  };

  const server = createHttpServer((req, res) => {
    const pathname = (req.url ?? "/").split("?")[0];
    if (pathname === REVEAL_PATH) {
      const started = Date.now();
      void handleReveal(req, res, revealCtx)
        .then((status) =>
          log("reveal_request", { method: req.method, status, duration_ms: Date.now() - started }),
        )
        .catch((err: unknown) => {
          log("reveal_unhandled", { kind: err instanceof Error ? err.name : typeof err });
          if (!res.headersSent) sendJson(res, 500, { error: "internal error" });
          else res.end();
        });
      return;
    }
    mcpListener(req, res);
  });

  const close = async (): Promise<void> => {
    tickets.dispose();
    await new Promise<void>((resolve) => server.close(() => resolve()));
  };
  return { server, tickets, close };
}

The reveal widget: src/widget.html

Served by resources/read with __APP_PUBLIC_ORIGIN__ substituted at read time. It renders the token chunks and, on the Reveal click, spends the ticket at /reveal to show originals in the panel only. A spent ticket cannot be replayed, so the button falls back to a warning.

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Guarded context</title>
    <style>
      :root {
        color-scheme: light dark;
        font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
      }
      body { margin: 0; padding: 16px; line-height: 1.45; }
      h2 { font-size: 15px; margin: 0 0 8px; }
      .muted { color: #6b7280; font-size: 12px; }
      .record { border: 1px solid #d1d5db; border-radius: 8px; padding: 10px 12px; margin: 8px 0; }
      .record h3 { font-size: 13px; margin: 0 0 4px; }
      .body { font-size: 13px; white-space: pre-wrap; word-break: break-word; }
      .token { background: #ede9fe; color: #5b21b6; border-radius: 4px; padding: 0 3px; font-weight: 600; }
      .entities { font-size: 12px; margin-top: 10px; }
      .entities code { background: #f3f4f6; border-radius: 4px; padding: 0 3px; }
      button {
        margin-top: 12px; padding: 8px 14px; border: 0; border-radius: 8px;
        background: #7432ff; color: #fff; font-size: 13px; cursor: pointer;
      }
      button:disabled { opacity: 0.5; cursor: default; }
      .reveal { margin-top: 12px; }
      .reveal pre {
        background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 8px;
        padding: 10px 12px; font-size: 12px; white-space: pre-wrap; word-break: break-word;
      }
      .warn { color: #b45309; font-size: 12px; margin-top: 8px; }
    </style>
  </head>
  <body>
    <div id="root">
      <p class="muted">Waiting for protected results...</p>
    </div>

    <script>
      // Injected by the app server at resource-read time. The widget only ever talks to this origin,
      // which is also the single CSP connect domain. No secret is embedded here.
      const APP_ORIGIN = "__APP_PUBLIC_ORIGIN__";

      const root = document.getElementById("root");
      let currentTicket = null;

      // Announce the widget to the host per the MCP Apps handshake. Names verified against the live
      // spec before a real ChatGPT run; the handshake carries no secret.
      function announce() {
        const target = window.parent;
        if (!target) return;
        target.postMessage(
          {
            jsonrpc: "2.0",
            id: 1,
            method: "ui/initialize",
            params: {
              appInfo: { name: "guardai-guarded-context", version: "0.1.0" },
              appCapabilities: {},
              protocolVersion: "2026-01-26",
            },
          },
          "*",
        );
        target.postMessage({ jsonrpc: "2.0", method: "ui/notifications/initialized" }, "*");
      }

      function escapeHtml(value) {
        return String(value)
          .replace(/&/g, "&amp;")
          .replace(/</g, "&lt;")
          .replace(/>/g, "&gt;");
      }

      function highlightTokens(value) {
        return escapeHtml(value).replace(
          /\{\{[a-z0-9_]+:[^}]+\}\}/g,
          (match) => '<span class="token">' + match + "</span>",
        );
      }

      function renderRecord(chunk) {
        const title = chunk && typeof chunk === "object" ? chunk.title : undefined;
        const body = chunk && typeof chunk === "object" ? chunk.body : JSON.stringify(chunk);
        const div = document.createElement("div");
        div.className = "record";
        div.innerHTML =
          "<h3>" + highlightTokens(title || "Record") + "</h3>" +
          '<div class="body">' + highlightTokens(body || "") + "</div>";
        return div;
      }

      function render(structured, ticket) {
        currentTicket = ticket || null;
        root.innerHTML = "";
        const chunks = structured && Array.isArray(structured.chunks) ? structured.chunks : [];
        const entityCount = structured && typeof structured.entity_count === "number" ? structured.entity_count : 0;

        const heading = document.createElement("h2");
        heading.textContent = chunks.length + " protected records";
        root.appendChild(heading);

        const note = document.createElement("p");
        note.className = "muted";
        note.textContent =
          entityCount + " personal values are shown as tokens. Reveal shows the originals only here in this panel.";
        root.appendChild(note);

        for (const chunk of chunks) root.appendChild(renderRecord(chunk));

        const button = document.createElement("button");
        button.textContent = "Reveal original values";
        button.disabled = !currentTicket;
        button.addEventListener("click", () => reveal(button));
        root.appendChild(button);
      }

      async function reveal(button) {
        if (!currentTicket) return;
        button.disabled = true;
        button.textContent = "Revealing...";
        try {
          const res = await fetch(APP_ORIGIN + "/reveal", {
            method: "POST",
            headers: { "content-type": "application/json" },
            body: JSON.stringify({ ticket: currentTicket }),
          });
          if (!res.ok) throw new Error("reveal failed");
          const data = await res.json();
          // A ticket is single-use; once spent it cannot be replayed.
          currentTicket = null;
          const wrap = document.createElement("div");
          wrap.className = "reveal";
          let text = data.restored_text || "";
          try {
            text = JSON.stringify(JSON.parse(text), null, 2);
          } catch {
            // Not JSON; show as-is.
          }
          const pre = document.createElement("pre");
          pre.textContent = text;
          wrap.appendChild(pre);
          root.appendChild(wrap);
          button.remove();
        } catch {
          button.remove();
          const warn = document.createElement("p");
          warn.className = "warn";
          warn.textContent = "Reveal unavailable (ticket expired or already used). Run the search again.";
          root.appendChild(warn);
        }
      }

      window.addEventListener("message", (event) => {
        // Ignore a message from a different, known window. A spec-compliant MCP Apps host relays the
        // tool-result from a sandbox with a null source, so allow a null source, the parent, and the
        // top frame; the reveal ticket is validated single-use server-side, which is the real boundary.
        if (event.source && event.source !== window.parent && event.source !== window.top) return;
        const message = event.data;
        if (!message || typeof message !== "object") return;
        if (message.method === "ui/notifications/tool-result") {
          const params = message.params || {};
          const meta = params._meta || {};
          const reveal = meta["oguardai/reveal"] || {};
          render(params.structuredContent, reveal.ticket);
        }
      });

      // Fallback for hosts that expose the tool output on window.openai instead of the postMessage
      // notification.
      function tryOpenaiFallback() {
        const api = window.openai;
        if (api && api.toolOutput) {
          const meta = api.toolResponseMetadata || {};
          const reveal = meta["oguardai/reveal"] || {};
          render(api.toolOutput, reveal.ticket);
        }
      }

      announce();
      tryOpenaiFallback();
    </script>
  </body>
</html>

The end-to-end trace: src/demo.ts

The trace pnpm demo runs. It boots the app on an ephemeral port and drives initialize, tools/list, tools/call, resources/read, and the widget's /reveal, asserting the boundary at each step.

/**
 * End-to-end trace of the guarded ChatGPT app against a live OGuardAI runtime.
 *
 * Boots the app on an ephemeral port, then drives the exact call sequence a ChatGPT host would:
 * initialize, tools/list, tools/call guarded_search, resources/read, and finally the widget's
 * /reveal. It prints what the model plane sees (tokens) and what the reveal step returns (values),
 * and asserts the boundary: no raw personal value crosses in the tool result, and the reveal shows
 * the originals. Against a stopped runtime the guarded_search call fails closed and the demo exits
 * nonzero, matching the examples "broken lane" contract.
 *
 * Run:  GUARDAI_URL=http://localhost:3000 pnpm demo
 */

import type { AddressInfo } from "node:net";
import { buildApp, resolveAppConfig } from "./server.js";
import { loadCorpus } from "./retrieval.js";

const WIDGET_ORIGIN = "https://widget.demo.local";

function rule(label: string): void {
  console.log(`\n${"=".repeat(72)}\n${label}\n${"=".repeat(72)}`);
}

interface JsonRpcResponse {
  result?: Record<string, unknown>;
  error?: { code: number; message: string };
}

async function mcpCall(
  base: string,
  id: number,
  method: string,
  params: unknown,
): Promise<JsonRpcResponse> {
  const res = await fetch(`${base}/mcp`, {
    method: "POST",
    headers: { "content-type": "application/json", accept: "application/json, text/event-stream" },
    body: JSON.stringify({ jsonrpc: "2.0", id, method, params }),
  });
  return (await res.json()) as JsonRpcResponse;
}

function fail(message: string): never {
  console.error(`\nFAILED: ${message}`);
  process.exit(1);
}

async function run(): Promise<void> {
  const env: Record<string, string | undefined> = {
    ...process.env,
    APP_PUBLIC_ORIGIN: WIDGET_ORIGIN,
    REVEAL_ALLOWED_ORIGINS: WIDGET_ORIGIN,
    GUARDAI_MCP_HTTP_ALLOW_UNAUTHENTICATED: "true",
    APP_PORT: "0",
    GUARDAI_URL: process.env.GUARDAI_URL ?? process.env.GUARDAI_BASE_URL ?? "http://localhost:3000",
  };
  const config = resolveAppConfig(env);
  const app = buildApp(config);
  await new Promise<void>((resolve) => app.server.listen(0, config.host, () => resolve()));
  const port = (app.server.address() as AddressInfo).port;
  const base = `http://${config.host}:${port}`;
  const corpus = loadCorpus();

  try {
    rule("1. initialize + tools/list");
    const init = await mcpCall(base, 1, "initialize", {
      protocolVersion: "2025-06-18",
      capabilities: {},
      clientInfo: { name: "demo", version: "0.1.0" },
    });
    if (init.error) fail(`initialize errored: ${init.error.message}`);
    const list = await mcpCall(base, 2, "tools/list", {});
    const tools = (list.result?.tools as Array<{ name: string }> | undefined) ?? [];
    console.log("tools:", tools.map((tool) => tool.name).join(", "));
    if (tools.length !== 1 || tools[0]?.name !== "guarded_search") {
      fail("tools/list is not exactly [guarded_search]");
    }

    rule("2. tools/call guarded_search (model plane sees tokens)");
    const call = await mcpCall(base, 3, "tools/call", {
      name: "guarded_search",
      arguments: { question: "Beschwerde aus Muenchen" },
    });
    if (call.error) fail(`tools/call errored: ${call.error.message}`);
    const result = call.result ?? {};
    if (result.isError === true) fail("guarded_search returned isError (is the runtime running?)");
    const serialized = JSON.stringify(result);
    console.log(serialized.slice(0, 900));

    const leaked = corpus.canaries.filter((canary) => serialized.includes(canary));
    if (leaked.length > 0) fail(`raw values crossed into the tool result: ${leaked.join(", ")}`);
    if (!serialized.includes("{{email:")) {
      fail("no email token in the model plane; is the runtime detecting?");
    }
    console.log(
      "\nOK: no structured raw value (email, IBAN) crossed into the model plane; they are tokens.",
    );
    if (!serialized.includes("{{person:")) {
      console.log(
        "note: no person token present. Person, company, and location names need the NER detector; " +
          "the base runtime tokenizes structured PII only. Start the full stack to tokenize names too.",
      );
    }

    const meta = (result._meta as Record<string, unknown> | undefined) ?? {};
    const reveal = (meta["oguardai/reveal"] as { ticket?: string } | undefined) ?? {};
    const ticket = reveal.ticket;
    if (typeof ticket !== "string") fail("no reveal ticket in _meta");

    rule("3. resources/read (widget resource)");
    const read = await mcpCall(base, 4, "resources/read", {
      uri: "ui://widget/guarded-context.html",
    });
    const contents = (read.result?.contents as Array<{ mimeType?: string }> | undefined) ?? [];
    console.log("widget mimeType:", contents[0]?.mimeType);

    rule("4. /reveal (human plane sees values, only in the app panel)");
    const revealRes = await fetch(`${base}/reveal`, {
      method: "POST",
      headers: { "content-type": "application/json", origin: WIDGET_ORIGIN },
      body: JSON.stringify({ ticket }),
    });
    if (!revealRes.ok) fail(`/reveal returned ${revealRes.status}`);
    const revealed = (await revealRes.json()) as { restored_text: string };
    console.log(revealed.restored_text.slice(0, 900));
    const restoredCanaries = corpus.canaries.filter((canary) =>
      revealed.restored_text.includes(canary),
    );
    if (restoredCanaries.length === 0) fail("reveal did not restore any original value");
    console.log(
      `\nOK: reveal restored ${restoredCanaries.length} original value(s) in the app panel only.`,
    );

    rule("5. the same ticket is single-use");
    const replay = await fetch(`${base}/reveal`, {
      method: "POST",
      headers: { "content-type": "application/json", origin: WIDGET_ORIGIN },
      body: JSON.stringify({ ticket }),
    });
    if (replay.status !== 404) fail(`replayed ticket should be 404, got ${replay.status}`);
    console.log("OK: replay of a spent ticket is 404.");

    console.log("\nAll checks passed.");
  } finally {
    await app.close();
  }
}

run().catch((err: unknown) => {
  console.error(err instanceof Error ? err.message : String(err));
  process.exit(1);
});

Acceptance checks: src/server.test.ts

Excerpt from src/server.test.ts: the mock runtime constants plus the two load-bearing acceptance checks. A2 proves tokens cross but no canary value or the sealed blob does; A4 proves /reveal returns the values once, then answers 404 on replay.

const WIDGET_ORIGIN = "https://widget.example.test";
const CANARY_EMAIL = "erika.mustermann@example.de";
const CANARY_IBAN = "DE97 9900 0000 0123 4567 89";

const SAFE_JSON = {
  chunks: [
    {
      id: "c-8842",
      title: "Beschwerde",
      body:
        "Kundin {{person:p_001:aa}} meldet sich unter {{email:e_001:bb}}; " +
        "Abbuchung von {{iban:i_001:cc}}.",
    },
  ],
};

const TRANSFORM_OK = {
  safe_text: JSON.stringify(SAFE_JSON),
  session_id: "sess_1",
  session_state: "sealed-session-blob",
  entities: [
    { token: "{{person:p_001:aa}}", type: "person", protection_level: "2" },
    { token: "{{email:e_001:bb}}", type: "email", protection_level: "2" },
    { token: "{{iban:i_001:cc}}", type: "iban", protection_level: "3" },
  ],
  safe_json: SAFE_JSON,
};

const REHYDRATED = {
  restored_text: JSON.stringify({
    chunks: [
      { body: `Kundin Erika Mustermann unter ${CANARY_EMAIL}; IBAN ${CANARY_IBAN}.` },
    ],
  }),
  tokens_unresolved: [],
};

describe("A2 guarded_search returns tokens, never a raw value or the sealed blob", () => {
  it("returns token chunks, a reveal ticket, and no canary or session_state", async () => {
    const { base } = await bootApp(await runtimeMock());
    const result = await callSearch(base);
    const serialized = JSON.stringify(result);
    expect(serialized).toContain("{{person:");
    expect(serialized).toContain("{{email:");
    expect(serialized).toContain("{{iban:");
    expect(serialized).not.toContain(CANARY_EMAIL);
    expect(serialized).not.toContain(CANARY_IBAN);
    expect(serialized).not.toContain("sealed-session-blob");
    expect(ticketFrom(result)).toMatch(/^[0-9a-f]{32}$/);
    const structured = result.structuredContent as { entity_count: number };
    expect(structured.entity_count).toBe(3);
  });
});

describe("A4 /reveal is single-use, origin-gated, and fails closed", () => {
  it("returns the values once, then 404 on replay", async () => {
    const { base } = await bootApp(await runtimeMock());
    const ticket = ticketFrom(await callSearch(base));

    const first = await revealPost(base, ticket, WIDGET_ORIGIN);
    expect(first.status).toBe(200);
    const revealed = (await first.json()) as { restored_text: string };
    expect(revealed.restored_text).toContain(CANARY_EMAIL);
    expect(revealed.restored_text).toContain(CANARY_IBAN);

    const replay = await revealPost(base, ticket, WIDGET_ORIGIN);
    expect(replay.status).toBe(404);
  });
});

Connect it to ChatGPT (developer mode)

  1. Expose the app over HTTPS. A tunnel is fine for developer mode; it is not enough for submission.
  2. Set APP_PUBLIC_ORIGIN to that public https origin and start the app.
  3. In ChatGPT: Settings, Connectors, Developer mode, add the URL with "No authentication".
  4. Ask a question over your records, confirm the transcript shows tokens, then click Reveal.
  5. The first reveal is a 403 until you allow the widget's sandbox origin. Read that origin from the first 403 in the app log, set REVEAL_ALLOWED_ORIGINS to it, and restart.

Runtime entitlements

The app's runtime credential needs the transform and rehydrate scopes. If the app sends a destination (the default is external_llm), the credential's allowed_destinations must include it, or omit the context by setting GUARDAI_APP_DESTINATION to an empty string.

Limits

  • Publishing to the ChatGPT directory needs OAuth 2.1 on the transport and a non-tunnel https origin. The developer-mode pilot uses "No authentication", which is why the app exposes an explicit unauthenticated flag.
  • Token identity is per search in this version, so the same person may carry a different token in a later search.
  • Reveal uses one server-held runtime credential; per-user reveal needs OAuth subject binding.