diff --git a/.env.example b/.env.example index c35dc51..f396a0b 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,7 @@ -# Oktasso SSO auth config for scripts/oktassoAuth.ts. -# Copy to .env (gitignored) and fill in the provider values. +# Local-only configuration template. +# Copy to .env (gitignored) and fill in the values for your deployment. -# Provider endpoints + SSO client for your deployment. +# OAuth/PKCE provider endpoints + client for scripts/oktassoAuth.ts. OKTASSO_OAUTH_ENDPOINT_BASE= OKTASSO_TOKEN_EXCHANGE_URL= OKTASSO_CLIENT_ID= @@ -13,5 +13,15 @@ OKTASSO_CALLBACK_PORT=3001 # Token cache lifetime in ms (55 minutes). OKTASSO_REFRESH_AFTER_MS=3300000 +# Server runtime config. Deployment systems should inject these as environment +# variables or secrets rather than baking private values into the image or repo. +TANGLE_API_URL=https://api.example.com +TANGLE_BASE_URL=https://api.example.com +TANGLE_TOKEN= +PI_PROXY_URL=https://proxy.example.com +PI_PROXY_API_KEY= +AUTH_JWT_TOKEN_COOKIE_NAME= +INSTANCE_PROXY_URL= + # Default bundle used by the Sessions page New session button. VITE_DEFAULT_SESSION_BUNDLE_ID=tangle diff --git a/README.md b/README.md index ee64c0c..2698b56 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,10 @@ pnpm dev # runs server + web via Turbo `pnpm dev` sets the session/bundle/memory paths to local gitignored folders (`.sessions`, `.agent-bundles`, `.memory`) so a checkout runs without extra setup. +Copy `.env.example` to `.env` for local-only provider values. Keep real deployment +URLs, cookies, proxy credentials, and auth endpoints out of committed files; inject them +through your process manager, container platform, or secret manager. + To load the example agent bundles into your local marketplace: ```bash @@ -115,17 +119,25 @@ pnpm seed # installs bundles from examples Key environment variables (see [`apps/server/src/config.ts`](apps/server/src/config.ts) for the full list and defaults): -| Variable | Purpose | -| ------------------------------------------ | ------------------------------------------------------ | -| `PORT` | HTTP server port | -| `SESSIONS_ROOT` | Root directory for per-session workspaces | -| `SESSIONS_DB` | SQLite metadata database file | -| `AGENT_BUNDLES_ROOT` | Bundle marketplace storage | -| `GLOBAL_MEMORY_DIR` | Global (cross-session) memory store | -| `PI_BIN` | Path to the `pi` agent executable | -| `PI_PROVIDER` / `PI_MODEL` / `PI_THINKING` | Default LLM provider, model, and thinking level | -| `PI_PROXY_URL` / `PI_PROXY_API_KEY` | LLM proxy endpoint and credential | -| `TANGLE_API_URL` / `TANGLE_TOKEN` | Tangle pipeline API for bundles that integrate with it | +| Variable | Purpose | +| ------------------------------------------------- | -------------------------------------------------------------- | +| `PORT` | HTTP server port | +| `SESSIONS_ROOT` | Root directory for per-session workspaces | +| `SESSIONS_DB` | SQLite metadata database file | +| `AGENT_BUNDLES_ROOT` | Bundle marketplace storage | +| `GLOBAL_MEMORY_DIR` | Global (cross-session) memory store | +| `PI_BIN` | Path to the `pi` agent executable | +| `PI_PROVIDER` / `PI_MODEL` / `PI_THINKING` | Default LLM provider, model, and thinking level | +| `PI_PROXY_URL` / `PI_PROXY_API_KEY` | LLM proxy endpoint and credential | +| `TANGLE_API_URL` | Tangle API base URL used by server-side egress targets | +| `TANGLE_TOKEN` | Optional cookie string injected server-side for egress | +| `AUTH_JWT_TOKEN_COOKIE_NAME` | Optional auth cookie name for `/api/me` | +| `TANGENT_INTERNAL_URL` / `TANGENT_INTERNAL_TOKEN` | Internal agent API URL and bearer token | +| `INSTANCE_PROXY_URL` | Optional deployment proxy URL consumed by `docker/instance.sh` | + +Bundle UI extensions should call logical egress targets such as +`{ target: "tangle", path: "/api/..." }`; the server resolves the actual base URL from +`TANGLE_API_URL` at runtime. ### Common commands diff --git a/apps/server/src/bundleUi/egressAllowlist.test.ts b/apps/server/src/bundleUi/egressAllowlist.test.ts new file mode 100644 index 0000000..5327d6e --- /dev/null +++ b/apps/server/src/bundleUi/egressAllowlist.test.ts @@ -0,0 +1,72 @@ +import assert from "node:assert/strict"; +import { afterEach, mock, test } from "node:test"; + +import { TANGLE_API_URL } from "../config.ts"; +import { + EgressDeniedError, + resolveEgress, + resolveTargetUrl, +} from "./egressAllowlist.ts"; + +afterEach(() => { + delete process.env.TANGLE_TOKEN; + mock.reset(); +}); + +test("resolves a logical Tangle target and injects server-side credentials", async () => { + process.env.TANGLE_TOKEN = "SESSION=abc"; + const expectedBase = new URL(TANGLE_API_URL); + const fetchMock = mock.method( + globalThis, + "fetch", + async (input: string | URL | Request, init?: RequestInit) => { + const url = new URL(String(input)); + assert.equal(url.origin, expectedBase.origin); + assert.equal(url.pathname, "/api/executions/run-1/state"); + assert.equal(url.searchParams.get("include"), "true"); + assert.equal( + (init?.headers as Record | undefined)?.cookie, + "SESSION=abc", + ); + + return new Response(JSON.stringify({ ok: true }), { + headers: { + "content-type": "application/json", + "x-internal": "hidden", + }, + }); + }, + ); + + const result = await resolveEgress( + { target: "tangle", path: "/api/executions/run-1/state" }, + { query: { include: true } }, + ); + + assert.equal(fetchMock.mock.calls.length, 1); + assert.equal(result.ok, true); + assert.equal(result.status, 200); + assert.deepEqual(result.headers, { "content-type": "application/json" }); + assert.deepEqual(result.json, { ok: true }); +}); + +test("rejects a logical target path that is not allowlisted", async () => { + await assert.rejects( + resolveEgress({ target: "tangle", path: "/api/private" }), + EgressDeniedError, + ); +}); + +test("rejects malformed logical target paths", async () => { + assert.equal( + resolveTargetUrl({ target: "tangle", path: "api/executions/run-1/state" }), + undefined, + ); + assert.equal( + resolveTargetUrl({ + target: "tangle", + path: "//evil.example/api/executions/run-1/state", + }), + undefined, + ); +}); diff --git a/apps/server/src/bundleUi/egressAllowlist.ts b/apps/server/src/bundleUi/egressAllowlist.ts index dbf274a..b1239de 100644 --- a/apps/server/src/bundleUi/egressAllowlist.ts +++ b/apps/server/src/bundleUi/egressAllowlist.ts @@ -2,17 +2,23 @@ * Server-side egress allowlist for the bundle-UI `host.fetch` bridge (Phase 5+7). * * A sandboxed component can only reach destinations registered here; anything - * else is denied before a network call is made. Unlike the earlier alias-based - * stub, components now name a **real, full URL** (e.g. the Tangle executions API) - * and the proxy validates it against the allowlist of host/path patterns, then - * performs the actual `fetch` server-side, injecting any credentials and - * stripping host internals out of the response. + * else is denied before a network call is made. Components name a logical target + * and path; the proxy resolves that target from server config, validates it + * against the allowlist, performs the actual `fetch` server-side, injects any + * credentials, and strips host internals out of the response. * * See `docs/bundle-ui/host-bridge.md` for the contract. */ import { TANGLE_API_URL } from "../config.ts"; +export interface EgressTargetRequest { + target: "tangle"; + path: string; +} + +export type EgressInput = string | EgressTargetRequest; + /** A tiny subset of `RequestInit` that crosses the bridge. */ export interface EgressRequestInit { method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; @@ -31,9 +37,9 @@ export interface EgressResponse { } /** - * One allowlisted destination. The component supplies the full URL; a request - * is permitted only when its method and parsed URL match a rule. `headers()` - * lets the server inject credentials the worker never sees. + * One allowlisted destination. A request is permitted only when its method and + * resolved URL match a rule. `headers()` lets the server inject credentials the + * worker never sees. */ interface EgressRule { method: NonNullable; @@ -95,18 +101,7 @@ const TANGLE_RULES: EgressRule[] = TANGLE_PATH_RULES.map((rule) => ({ })); /** Registered destinations the bridge may reach. */ -const EGRESS_RULES: EgressRule[] = [ - { - // Oasis execution state, e.g. - // https://oasis.shopify.io/api/executions/019ea56d72cd5f4d75f6/state - method: "GET", - matches: (url) => - url.origin === "https://oasis.shopify.io" && - /^\/api\/executions\/[^/]+\/state$/.test(url.pathname), - headers: tangleAuthHeaders, - }, - ...TANGLE_RULES, -]; +const EGRESS_RULES: EgressRule[] = [...TANGLE_RULES]; /** Response headers we are willing to surface back across the bridge. */ const ALLOWED_RESPONSE_HEADERS = new Set(["content-type"]); @@ -122,7 +117,7 @@ export class EgressDeniedError extends Error { } } -/** Parses `input` as an absolute http(s) URL, or returns `undefined`. */ +/** Parses a string as an absolute http(s) URL, or returns `undefined`. */ function parseHttpUrl(input: string): URL | undefined { let url: URL; try { @@ -134,6 +129,14 @@ function parseHttpUrl(input: string): URL | undefined { return url; } +function isEgressTargetRequest( + input: EgressInput, +): input is EgressTargetRequest { + return ( + typeof input === "object" && input !== null && input.target === "tangle" + ); +} + /** Picks the allowlisted subset of response headers as a plain object. */ function sanitizeResponseHeaders(headers: Headers): Record { const out: Record = {}; @@ -144,12 +147,17 @@ function sanitizeResponseHeaders(headers: Headers): Record { } /** - * Resolves an absolute http(s) URL with merged query params, or throws - * {@link EgressDeniedError} for anything that isn't an http(s) URL. + * Resolves a logical target or absolute http(s) URL with merged query params, or + * throws {@link EgressDeniedError} for anything that cannot be safely resolved. */ -function resolveUrl(input: string, query: EgressRequestInit["query"]): URL { - const url = parseHttpUrl(input); - if (!url) throw new EgressDeniedError(input); +function resolveUrl( + input: EgressInput, + query: EgressRequestInit["query"], +): URL { + const url = isEgressTargetRequest(input) + ? resolveTargetUrl(input) + : parseHttpUrl(input); + if (!url) throw new EgressDeniedError(formatDeniedInput(input)); if (query) { for (const [key, value] of Object.entries(query)) { url.searchParams.set(key, String(value)); @@ -158,6 +166,17 @@ function resolveUrl(input: string, query: EgressRequestInit["query"]): URL { return url; } +export function resolveTargetUrl(input: EgressTargetRequest): URL | undefined { + if (!input.path.startsWith("/") || input.path.startsWith("//")) { + return undefined; + } + return new URL(input.path, TANGLE_API_URL); +} + +function formatDeniedInput(input: EgressInput): string { + return typeof input === "string" ? input : `${input.target}:${input.path}`; +} + /** Whether this request carries a JSON body (anything but a bodyless GET). */ function isBodyRequest( method: NonNullable, @@ -240,12 +259,12 @@ async function toEgressResponse(response: Response): Promise { /** * Resolves an allowlisted egress request by performing the real network call - * server-side. Throws {@link EgressDeniedError} when `input` is not an absolute - * http(s) URL matching a registered destination. Network/transport failures - * propagate as generic errors (the route maps them to a 502). + * server-side. Throws {@link EgressDeniedError} when `input` cannot resolve to a + * registered destination. Network/transport failures propagate as generic + * errors (the route maps them to a 502). */ export async function resolveEgress( - input: string, + input: EgressInput, init: EgressRequestInit = {}, ): Promise { const method = init.method ?? "GET"; @@ -253,7 +272,7 @@ export async function resolveEgress( const rule = EGRESS_RULES.find( (entry) => entry.method === method && entry.matches(url), ); - if (!rule) throw new EgressDeniedError(input); + if (!rule) throw new EgressDeniedError(formatDeniedInput(input)); const response = await performFetch(url, method, init, rule); return toEgressResponse(response); diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 9ab0032..aebcc08 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -6,6 +6,16 @@ import { DEFAULT_THINKING_LEVEL, } from "@tangent/shared/contracts.ts"; +function readAbsoluteUrl(name: string, fallback: string): string { + const value = process.env[name] ?? fallback; + try { + new URL(value); + } catch { + throw new Error(`${name} must be an absolute URL`); + } + return value; +} + /** Port the dev server listens on. Vite proxies /api and /socket.io here. */ export const PORT = Number(process.env.PORT ?? 8787); @@ -81,12 +91,13 @@ export const PI_BIN = process.env.PI_BIN ?? "pi"; /** * Base URL of the LLM proxy the bundled proxy-provider extension points Pi at. - * Defaults to the Shopify proxy so local behavior matches the auto-discovered - * `~/.pi/agent` extension; override in deployments (e.g. Cloud Run) to target a - * different proxy. Mirrored here for logging; the extension reads it directly. + * Override in deployments to target the proxy available in that environment. + * Mirrored here for logging; the extension reads it directly. */ -export const PI_PROXY_URL = - process.env.PI_PROXY_URL ?? "https://proxy.shopify.ai"; +export const PI_PROXY_URL = readAbsoluteUrl( + "PI_PROXY_URL", + "https://proxy.example.com", +); /** * Provider/model Pi is pinned to when spawned. In the container there is no @@ -136,10 +147,11 @@ export const AUTH_JWT_TOKEN_COOKIE_NAME = process.env.AUTH_JWT_TOKEN_COOKIE_NAME ?? ""; /** - * Base URL of the Tangle (Cloud Pipelines) API reached by the bundle-UI/agent - * egress allowlist. The OpenAPI doc declares no `servers`, so this is supplied - * per environment: defaults to the local dev server and is overridden in - * production. Only the origin is used when matching egress destinations. + * Base URL of the Tangle API reached by the bundle-UI/agent egress allowlist. + * The OpenAPI doc declares no `servers`, so this is supplied per environment. + * Only the origin is used when matching egress destinations. */ -export const TANGLE_API_URL = - process.env.TANGLE_API_URL ?? "https://oasis.shopify.io"; +export const TANGLE_API_URL = readAbsoluteUrl( + "TANGLE_API_URL", + "https://api.example.com", +); diff --git a/apps/server/src/routes/agentBundles.ts b/apps/server/src/routes/agentBundles.ts index 268a7ec..0faeb99 100644 --- a/apps/server/src/routes/agentBundles.ts +++ b/apps/server/src/routes/agentBundles.ts @@ -4,8 +4,10 @@ import { z } from "zod"; import { EgressDeniedError, + type EgressInput, type EgressRequestInit, resolveEgress, + resolveTargetUrl, } from "../bundleUi/egressAllowlist.ts"; import { getValidated, validate } from "../middleware/validate.ts"; import { @@ -35,11 +37,25 @@ type UiComponentParams = z.infer; * {@link resolveEgress}, so we type it via `z.custom` rather than re-describing * its shape, keeping the validated value assignable without an `as` cast. */ +const uiEgressInputSchema = z.union([ + z.string().min(1), + z.object({ + target: z.literal("tangle"), + path: z.string().min(1), + }), +]); + +const uiTargetUrlBodySchema = z.object({ + target: z.literal("tangle"), + path: z.string().min(1), +}); + const uiEgressBodySchema = z.object({ - input: z.string().min(1), + input: uiEgressInputSchema, init: z.custom().optional(), }); type UiEgressInput = z.infer; +type UiTargetUrlInput = z.infer; /** Handles `GET /api/agent-bundles`: lists stored bundle metadata. */ async function handleList( @@ -128,7 +144,7 @@ async function handleUiComponent( * destination that isn't registered (see Phase 5 of the bundle-ui spec). */ async function handleUiEgress( - input: string, + input: EgressInput, init: EgressRequestInit | undefined, res: Response, ): Promise { @@ -144,6 +160,16 @@ async function handleUiEgress( } } +/** Handles `POST /api/agent-bundles/ui-target-url`: resolves openable target URLs. */ +function handleUiTargetUrl(input: UiTargetUrlInput, res: Response): void { + const url = resolveTargetUrl(input); + if (!url) { + res.status(400).json({ error: "Invalid target URL" }); + return; + } + res.json({ url: url.href }); +} + /** Handles `POST /api/agent-bundles`: validates and stores an uploaded bundle. */ async function handleUpload( store: AgentBundleStore, @@ -205,6 +231,15 @@ function registerBundleCollectionRoutes( return handleUiEgress(body.input, body.init, res); }, ); + + router.post( + "/ui-target-url", + validate({ body: uiTargetUrlBodySchema }), + (req: Request, res: Response) => { + const { body } = getValidated(req); + handleUiTargetUrl(body, res); + }, + ); } /** Registers the single-bundle item routes (read/icon/download/ui/delete). */ diff --git a/apps/server/src/routes/internalEgress.ts b/apps/server/src/routes/internalEgress.ts index 092c594..9cfa468 100644 --- a/apps/server/src/routes/internalEgress.ts +++ b/apps/server/src/routes/internalEgress.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { EgressDeniedError, + type EgressInput, type EgressRequestInit, resolveEgress, } from "../bundleUi/egressAllowlist.ts"; @@ -16,18 +17,26 @@ import { getValidated, validate } from "../middleware/validate.ts"; * so we type it via `z.custom` rather than re-describing its shape, keeping the * validated value assignable without an `as` cast. */ +const egressInputSchema = z.union([ + z.string().min(1), + z.object({ + target: z.literal("tangle"), + path: z.string().min(1), + }), +]); + const egressBodySchema = z.object({ - input: z.string().min(1), + input: egressInputSchema, init: z.custom().optional(), }); -type EgressInput = z.infer; +type EgressBody = z.infer; /** * Resolves the requested destination against the egress allowlist, mapping a * denied destination to `403` and any other failure to `502`. */ async function handleEgress( - input: string, + input: EgressInput, init: EgressRequestInit | undefined, res: Response, ): Promise { @@ -63,7 +72,7 @@ export function createInternalEgressRouter(): Router { "/", validate({ body: egressBodySchema }), (req: Request, res: Response) => { - const { input, init } = getValidated(req).body; + const { input, init } = getValidated(req).body; return handleEgress(input, init, res); }, ); diff --git a/apps/web/public/bundle-ui-harness/sample.js b/apps/web/public/bundle-ui-harness/sample.js index a10ee20..78d70d4 100644 --- a/apps/web/public/bundle-ui-harness/sample.js +++ b/apps/web/public/bundle-ui-harness/sample.js @@ -23,8 +23,6 @@ import { Text, } from "@tangent/bundle-ui"; -const OASIS_BASE = "https://oasis.shopify.io/api/executions"; - export default function PipelineProgress() { const [executionId, setExecutionId] = useState(null); const [summary, setSummary] = useState(null); @@ -50,7 +48,10 @@ export default function PipelineProgress() { let active = true; const tick = async () => { try { - const res = await host.fetch(`${OASIS_BASE}/${executionId}/state`); + const res = await host.fetch({ + target: "tangle", + path: `/api/executions/${executionId}/state`, + }); if (active && res.ok) { const s = res.json && res.json.child_execution_status_summary diff --git a/apps/web/src/features/bundle-ui/BundleUiHost.tsx b/apps/web/src/features/bundle-ui/BundleUiHost.tsx index 8d73473..605129a 100644 --- a/apps/web/src/features/bundle-ui/BundleUiHost.tsx +++ b/apps/web/src/features/bundle-ui/BundleUiHost.tsx @@ -20,11 +20,13 @@ import { Text } from "@tangent/ui-primitives/typography"; import { useEffect, useMemo, useRef, useState } from "react"; import { ErrorBoundary } from "react-error-boundary"; +import { apiUrl } from "@/shared/lib/basePath"; + import { BUNDLE_UI_ELEMENT_NAMES, hostAdapters, } from "./components/host-registry"; -import { createHostBridge } from "./hostBridge"; +import { createHostBridge, TARGET_URL_ENDPOINT } from "./hostBridge"; import type { BundleUiKind, HostBridge, UICommand, WorkerApi } from "./types"; /** Reads a JSON value persisted under `:`, or `null`. */ @@ -61,6 +63,19 @@ function openExternalUrl(url: unknown): void { window.open(url, "_blank", "noopener,noreferrer"); } +async function openTargetUrl( + command: Extract, +) { + const response = await fetch(apiUrl(TARGET_URL_ENDPOINT), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ target: command.target, path: command.path }), + }); + if (!response.ok) return; + const body = (await response.json()) as { url?: unknown }; + openExternalUrl(body.url); +} + const remoteComponents: RemoteComponentRendererMap = new Map([ ...BUNDLE_UI_ELEMENT_NAMES.map( (name) => @@ -145,9 +160,16 @@ export function BundleUiHost({ const namespace = stateNamespaceRef.current; if (namespace) writePersistedState(namespace, key, value); }, - onUICommand: (command: UICommand) => { - if (command.type === "collapse") collapseRef.current?.(); - if (command.type === "openUrl") openExternalUrl(command.url); + onUICommand: async (command: UICommand) => { + if (command.type === "collapse") { + collapseRef.current?.(); + return; + } + if (command.type === "openUrl") { + openExternalUrl(command.url); + return; + } + await openTargetUrl(command); }, }); diff --git a/apps/web/src/features/bundle-ui/hostBridge.ts b/apps/web/src/features/bundle-ui/hostBridge.ts index 32c897e..29c8ee7 100644 --- a/apps/web/src/features/bundle-ui/hostBridge.ts +++ b/apps/web/src/features/bundle-ui/hostBridge.ts @@ -11,6 +11,7 @@ import { apiUrl } from "@/shared/lib/basePath"; import type { HostBridge, + HostFetchInput, HostRequestInit, HostResponse, UICommand, @@ -18,6 +19,7 @@ import type { /** Default server route that proxies allowlisted egress. */ export const EGRESS_ENDPOINT = "/api/agent-bundles/ui-egress"; +export const TARGET_URL_ENDPOINT = "/api/agent-bundles/ui-target-url"; export interface HostBridgeOptions { /** Returns the current JSON props for a `message` component. */ @@ -29,13 +31,13 @@ export interface HostBridgeOptions { /** Persists `value` under `key`; omitted disables `setState`. */ saveState?: (key: string, value: unknown) => void; /** Handles a host UI command; omitted makes `execUICommand` a no-op. */ - onUICommand?: (command: UICommand) => void; + onUICommand?: (command: UICommand) => void | Promise; /** Override the egress endpoint (tests / harness). */ egressEndpoint?: string; } export function createHostBridge(options: HostBridgeOptions): HostBridge { - const endpoint = options.egressEndpoint ?? EGRESS_ENDPOINT; + const egressEndpoint = options.egressEndpoint ?? EGRESS_ENDPOINT; return { async getProps() { @@ -60,11 +62,14 @@ export function createHostBridge(options: HostBridgeOptions): HostBridge { async execUICommand(command: UICommand) { if (!command || typeof command !== "object") return; - options.onUICommand?.(command); + await options.onUICommand?.(command); }, - async fetch(input: string, init?: HostRequestInit): Promise { - const response = await fetch(apiUrl(endpoint), { + async fetch( + input: HostFetchInput, + init?: HostRequestInit, + ): Promise { + const response = await fetch(apiUrl(egressEndpoint), { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ input, init }), diff --git a/apps/web/src/features/bundle-ui/runtime/bridge.tsx b/apps/web/src/features/bundle-ui/runtime/bridge.tsx index bb4bf67..e2a58b0 100644 --- a/apps/web/src/features/bundle-ui/runtime/bridge.tsx +++ b/apps/web/src/features/bundle-ui/runtime/bridge.tsx @@ -15,6 +15,7 @@ import type { HostBridge, + HostFetchInput, HostRequestInit, HostResponse, UICommand, @@ -36,13 +37,14 @@ function bridge(): HostBridge { export const host: HostBridge = { getProps: () => bridge().getProps(), sendPrompt: (text: string) => bridge().sendPrompt(text), - fetch: (input: string, init?: HostRequestInit) => bridge().fetch(input, init), + fetch: (input: HostFetchInput, init?: HostRequestInit) => + bridge().fetch(input, init), getState: (key: string) => bridge().getState(key), setState: (key: string, value: unknown) => bridge().setState(key, value), execUICommand: (command: UICommand) => bridge().execUICommand(command), }; -export type { HostRequestInit, HostResponse, UICommand }; +export type { HostFetchInput, HostRequestInit, HostResponse, UICommand }; export { Badge } from "../components/badge/badge.remote"; export { BlockStack } from "../components/block-stack/block-stack.remote"; diff --git a/apps/web/src/features/bundle-ui/types.ts b/apps/web/src/features/bundle-ui/types.ts index cd98472..9f2fc69 100644 --- a/apps/web/src/features/bundle-ui/types.ts +++ b/apps/web/src/features/bundle-ui/types.ts @@ -22,6 +22,13 @@ export interface HostRequestInit { query?: Record; } +export interface HostTargetRequest { + target: "tangle"; + path: string; +} + +export type HostFetchInput = string | HostTargetRequest; + /** JSON-safe stand-in for a `Response` (a live `Response` can't cross a thread). */ export interface HostResponse { ok: boolean; @@ -38,11 +45,14 @@ export interface HostResponse { * * Modeled as a discriminated union (rather than bespoke methods) so new actions * can be added — each carrying its own payload — without growing the bridge - * surface. `collapse` collapses the chat message the component is rendered in - * (a no-op outside a message surface); `openUrl` opens an `https:` destination - * in a new browser tab. + * surface. `collapse` collapses the chat message the component is rendered in, + * and URL commands open either a supplied `https:` URL or a server-configured + * target URL in a new browser tab. */ -export type UICommand = { type: "collapse" } | { type: "openUrl"; url: string }; +export type UICommand = + | { type: "collapse" } + | { type: "openUrl"; url: string } + | { type: "openTargetUrl"; target: "tangle"; path: string }; /** * The only channel a sandboxed component has to the host. Exposed to the worker @@ -54,7 +64,7 @@ export interface HostBridge { /** Composes and sends a chat message to Prime. */ sendPrompt(text: string): Promise; /** Host-mediated, allowlist-proxied network egress. */ - fetch(input: string, init?: HostRequestInit): Promise; + fetch(input: HostFetchInput, init?: HostRequestInit): Promise; /** * Reads a previously persisted value for `key` from this instance's * key-value store, or `null` if absent. State survives page reloads and is diff --git a/docs/bundle-ui/authoring-guide.md b/docs/bundle-ui/authoring-guide.md index 8594251..e91eb0d 100644 --- a/docs/bundle-ui/authoring-guide.md +++ b/docs/bundle-ui/authoring-guide.md @@ -56,8 +56,6 @@ renders a progress chip: import { BlockStack, Card, host, Progress, Text } from "@tangent/bundle-ui"; import { useEffect, useState } from "react"; -const TANGLE_BASE = "https://tangle.example.com/api/executions"; - export default function PipelineProgress() { const [executionId, setExecutionId] = useState(null); const [summary, setSummary] = useState<{ @@ -78,8 +76,10 @@ export default function PipelineProgress() { let active = true; const tick = async () => { try { - // A real, allowlisted endpoint — the host proxy validates the URL. - const res = await host.fetch(`${TANGLE_BASE}/${executionId}/state`); + const res = await host.fetch({ + target: "tangle", + path: `/api/executions/${executionId}/state`, + }); if (active && res.ok) { const s = (res.json as { child_execution_status_summary?: unknown }) ?.child_execution_status_summary as diff --git a/docs/bundle-ui/host-bridge.md b/docs/bundle-ui/host-bridge.md index bce6b2d..57c7d25 100644 --- a/docs/bundle-ui/host-bridge.md +++ b/docs/bundle-ui/host-bridge.md @@ -91,12 +91,16 @@ without growing the bridge surface. the worker thread, the new tab opens outside the original click's user-activation window, so a popup blocker may suppress it — trigger it directly from a user gesture (e.g. a `Button` press) for the best result. +- `{ type: "openTargetUrl", target: "tangle", path }` — asks the host to resolve + a configured target URL and open it. Bundle code supplies only the logical + target and path; the deployment-specific origin stays in server config. ```ts await host.execUICommand({ type: "collapse" }); await host.execUICommand({ - type: "openUrl", - url: "https://tangle.example.com/runs/abc", + type: "openTargetUrl", + target: "tangle", + path: "/runs/abc", }); ``` @@ -119,6 +123,11 @@ interface HostRequestInit { query?: Record; } +interface HostTargetRequest { + target: "tangle"; + path: string; +} + interface HostResponse { ok: boolean; status: number; @@ -128,10 +137,11 @@ interface HostResponse { } ``` -- `input` is a **real, absolute `https://` URL** for the destination. The proxy - matches it against allowlisted host/path patterns; authors name the actual - endpoint (e.g. `https://tangle.example.com/api/executions//state`) rather - than a logical alias. +- `input` is normally a **logical target request** such as + `{ target: "tangle", path: "/api/executions//state" }`. The server resolves + the target from deployment configuration and then matches the resolved URL + against allowlisted path patterns. Absolute `http(s)` URLs are accepted only as + a compatibility path for trusted internal tooling. - The proxy **rejects any destination not on the allowlist** before making a network call, and strips/normalizes headers in both directions. Credentials the destination needs are injected server-side and never reach the worker. @@ -151,17 +161,17 @@ const EGRESS_RULES = [ { method: "GET", matches: (url) => - url.origin === "https://tangle.example.com" && + url.origin === TANGLE_API_ORIGIN && /^\/api\/executions\/[^/]+\/state$/.test(url.pathname), - // optional bearer token attached server-side; never exposed to the worker + // optional credential attached server-side; never exposed to the worker headers: tangleAuthHeaders, }, ]; ``` -A request whose method+URL does not match any rule is denied. New destinations -are enabled by adding rules server-side, never by the component naming an -arbitrary URL. (Per-bundle allowlists are a future refinement — see +A request whose method+resolved URL does not match any rule is denied. New +destinations are enabled by adding rules server-side, never by the component +naming an arbitrary URL. (Per-bundle allowlists are a future refinement — see [`security.md`](security.md).) ### Example @@ -170,9 +180,10 @@ Request from the component: ```ts const id = "019ea56d72cd5f4d75f6"; -const res = await host.fetch( - `https://tangle.example.com/api/executions/${id}/state`, -); +const res = await host.fetch({ + target: "tangle", + path: `/api/executions/${id}/state`, +}); if (!res.ok) throw new Error(`status ${res.status}`); const summary = (res.json as { child_execution_status_summary: unknown }) .child_execution_status_summary; diff --git a/docs/server/egress-and-security.md b/docs/server/egress-and-security.md index aa60b47..01d15a7 100644 --- a/docs/server/egress-and-security.md +++ b/docs/server/egress-and-security.md @@ -58,10 +58,10 @@ sequenceDiagram Agents and sandboxed bundle-UI components must never hold outbound credentials and must not reach arbitrary hosts. Both go through -[`resolveEgress`](../../server/src/bundleUi/egressAllowlist.ts), which validates -the requested URL against a fixed allowlist, performs the real `fetch` -server-side (injecting credentials), and returns a JSON-safe response with only -allowlisted headers. +[`resolveEgress`](../../server/src/bundleUi/egressAllowlist.ts), which resolves +logical targets from server configuration, validates the resulting URL against a +fixed allowlist, performs the real `fetch` server-side (injecting credentials), +and returns a JSON-safe response with only allowlisted headers. Two routes wrap the same resolver: @@ -78,11 +78,11 @@ sequenceDiagram participant Resolver as resolveEgress participant Upstream as Tangle - Caller->>Route: POST egress { input: URL, init } + Caller->>Route: POST egress { input: target/path, init } activate Route Route->>Resolver: resolveEgress(input, init) activate Resolver - Resolver->>Resolver: parse http(s) URL + merge query + Resolver->>Resolver: resolve target + merge query Resolver->>Resolver: find rule (method + matches(url)) alt no matching rule Resolver-->>Route: throw EgressDeniedError @@ -103,12 +103,12 @@ sequenceDiagram Allowlist properties: -- Only `http(s)` URLs whose method + parsed URL match a registered `EgressRule` - are permitted; everything else throws `EgressDeniedError` (403). +- Bundle UI callers should use logical target input such as + `{ target: "tangle", path: "/api/executions//state" }`; trusted internal + tools may still pass absolute `http(s)` URLs for compatibility. - Rules cover the configured Tangle API origin (`TANGLE_API_URL`) for specific - `pipeline_runs` / `executions` / `artifacts` paths, plus the Tangle execution - state endpoint. Credentials are injected by the rule's `headers()` so the - caller never sees them. + `pipeline_runs` / `executions` / `artifacts` paths. Credentials are injected + by the rule's `headers()` so the caller never sees them. - Responses surface only `content-type`; a 10s `AbortController` timeout bounds upstream calls; transport failures map to a 502. @@ -137,50 +137,22 @@ Allowlist properties: --- -## Security findings +## Security notes -### Finding 1 (critical): hardcoded personal `OKTASSO_TOKEN` in source +### Note 1: egress credentials are deployment secrets -[server/src/bundleUi/egressAllowlist.ts](../../server/src/bundleUi/egressAllowlist.ts) -currently hardcodes a personal Oktasso JWT directly in `tangleAuthHeaders()`: +`TANGLE_TOKEN` is read from the process environment and injected as a cookie +header only inside the server-side egress proxy. Do not commit this value, bake it +into Docker images, or expose it to bundle UI workers. -```ts -function tangleAuthHeaders(): Record { - return { - cookie: `OKTASSO_TOKEN=eyJ......`, - }; - // unreachable below: - const token = process.env.TANGLE_TOKEN; - return token ? { authorization: `Bearer ${token}` } : {}; -} -``` - -Problems: - -- A live, personal credential (decoded subject `user@example.com`) is - committed to the repository. It will be exposed to anyone with repo access and - in git history. -- The early `return` makes the intended `TANGLE_TOKEN` env path dead code, so the - hardcoded cookie is attached to **every** allowlisted Tangle egress - request from any session's agents and UI components. - -Recommended remediation: - -1. Revoke/rotate the leaked Oktasso token immediately. -2. Remove the hardcoded `cookie` block and restore the `TANGLE_TOKEN` (and - `TANGLE_AUTH`) env-sourced path so credentials are injected from the - environment, never from source. -3. Scrub the secret from git history (e.g. `git filter-repo`) since rotating - alone leaves the old token in history. - -### Finding 2 (informational): default-open internal token in misconfigured deploys +### Note 2: default-open internal token in misconfigured deploys `INTERNAL_TOKEN` is a per-start random UUID, which is sound. But because `INTERNAL_URL` defaults to loopback and the token guards real capabilities, deployments must ensure the internal routers are not exposed beyond loopback and that `TANGENT_INTERNAL_TOKEN`, if pinned via env, is treated as a secret. -### Finding 3 (informational): in-memory session store +### Note 3: in-memory session store Session records + chat history are in-memory ([sessions-and-storage.md](./sessions-and-storage.md)); a restart drops them diff --git a/docs/server/index.md b/docs/server/index.md index 714a048..61e8c1e 100644 --- a/docs/server/index.md +++ b/docs/server/index.md @@ -180,7 +180,7 @@ From [server/src/config.ts](../../server/src/config.ts): - `PI_BIN` (`pi`), `PI_PROVIDER` (`openai`), `PI_MODEL` (`gpt-5.5`), `PI_PROXY_URL` (`https://proxy.example.com`), `PI_DEBUG` (on by default). - `INTERNAL_URL` (loopback on `PORT`) + `INTERNAL_TOKEN` (per-start UUID). -- `TANGLE_API_URL` (`https://tangle.example.com`) — origin for the egress +- `TANGLE_API_URL` (`https://api.example.com`) — origin for the egress allowlist. ## Glossary diff --git a/examples/tangle-ml-pipeline-optimizer/tools/tangle-api.ts b/examples/tangle-ml-pipeline-optimizer/tools/tangle-api.ts index 7ef17bb..339f4fb 100644 --- a/examples/tangle-ml-pipeline-optimizer/tools/tangle-api.ts +++ b/examples/tangle-ml-pipeline-optimizer/tools/tangle-api.ts @@ -21,7 +21,7 @@ import { Type } from "typebox"; const INTERNAL_URL = process.env.TANGENT_INTERNAL_URL ?? ""; const INTERNAL_TOKEN = process.env.TANGENT_INTERNAL_TOKEN ?? ""; -const TANGLE_API_URL = process.env.TANGLE_API_URL ?? "https://oasis.shopify.io"; +const TANGLE_API_URL = process.env.TANGLE_API_URL ?? "https://api.example.com"; interface EgressInit { method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; diff --git a/examples/tangle-ml-pipeline-optimizer/ui/pipeline-progress.tsx b/examples/tangle-ml-pipeline-optimizer/ui/pipeline-progress.tsx index 41e57c3..f64e229 100644 --- a/examples/tangle-ml-pipeline-optimizer/ui/pipeline-progress.tsx +++ b/examples/tangle-ml-pipeline-optimizer/ui/pipeline-progress.tsx @@ -3,7 +3,7 @@ * * The agent emits a `tangent-ui:pipeline-progress` block carrying * `{ "executionId": "" }`; the host parses it and delivers it via - * `host.getProps()`. We then poll the real Oasis executions-state endpoint + * `host.getProps()`. We then poll the real Tangle executions-state endpoint * through the allowlisted `host.fetch` bridge and render a progress bar. * * The header shows the pipeline's real title (resolved live from the Tangle API @@ -28,10 +28,12 @@ import { } from "@tangent/bundle-ui"; import { useEffect, useState } from "react"; -const TANGLE_BASE = "https://oasis.shopify.io"; -const OASIS_BASE = `${TANGLE_BASE}/api/executions`; const POLL_INTERVAL_MS = 4000; +function tangleApi(path: string) { + return { target: "tangle" as const, path }; +} + /** Candidate keys for the pipeline's human title across API shapes. */ const TITLE_KEYS = ["pipeline_name", "display_name", "name", "title"]; @@ -43,10 +45,10 @@ interface ExecutionState { interface PipelineMeta { title: string | null; - url: string; + path: string; } -interface OasisStateResponse { +interface TangleStateResponse { child_execution_status_summary?: { total_executions?: number; ended_executions?: number; @@ -55,7 +57,7 @@ interface OasisStateResponse { } function toExecutionState(json: unknown): ExecutionState | null { - const summary = (json as OasisStateResponse | null) + const summary = (json as TangleStateResponse | null) ?.child_execution_status_summary; if (!summary) return null; return { @@ -142,7 +144,7 @@ function extractTitle(json: unknown): string | null { async function loadRunTitle(runId: string): Promise { try { const res = await host.fetch( - `${TANGLE_BASE}/api/pipeline_runs/${encodeURIComponent(runId)}`, + tangleApi(`/api/pipeline_runs/${encodeURIComponent(runId)}`), ); return res.ok ? extractTitle(res.json) : null; } catch { @@ -160,16 +162,16 @@ async function loadPipelineMeta( ): Promise { try { const res = await host.fetch( - `${OASIS_BASE}/${encodeURIComponent(executionId)}/details`, + tangleApi(`/api/executions/${encodeURIComponent(executionId)}/details`), ); if (!res.ok) return null; const runId = extractRunId(res.json); - const url = runId - ? `${TANGLE_BASE}/runs/${runId}` - : `${TANGLE_BASE}/executions/${executionId}`; + const path = runId + ? `/runs/${encodeURIComponent(runId)}` + : `/executions/${encodeURIComponent(executionId)}`; const title = extractTitle(res.json) ?? (runId ? await loadRunTitle(runId) : null); - return { title, url }; + return { title, path }; } catch { return null; } @@ -179,7 +181,7 @@ export default function PipelineProgress() { const [executionId, setExecutionId] = useState(null); const [state, setState] = useState(null); const [title, setTitle] = useState(null); - const [pipelineUrl, setPipelineUrl] = useState(null); + const [pipelinePath, setPipelinePath] = useState(null); useEffect(() => { host.getProps().then((props) => { @@ -196,7 +198,7 @@ export default function PipelineProgress() { loadPipelineMeta(executionId).then((meta) => { if (!active || !meta) return; setTitle(meta.title); - setPipelineUrl(meta.url); + setPipelinePath(meta.path); }); return () => { @@ -217,7 +219,7 @@ export default function PipelineProgress() { const tick = async () => { try { const res = await host.fetch( - `${OASIS_BASE}/${encodeURIComponent(executionId)}/state`, + tangleApi(`/api/executions/${encodeURIComponent(executionId)}/state`), ); if (!active || !res.ok) return; const next = toExecutionState(res.json); @@ -256,8 +258,12 @@ export default function PipelineProgress() { const displayTitle = title ?? "Pipeline"; const openPipeline = async () => { - if (!pipelineUrl) return; - await host.execUICommand({ type: "openUrl", url: pipelineUrl }); + if (!pipelinePath) return; + await host.execUICommand({ + type: "openTargetUrl", + target: "tangle", + path: pipelinePath, + }); }; return ( @@ -269,7 +275,7 @@ export default function PipelineProgress() { variant="link" size="sm" onPress={openPipeline} - disabled={!pipelineUrl} + disabled={!pipelinePath} > {displayTitle} diff --git a/examples/tangle-ml-pipeline-optimizer/ui/pipeline-url-input.tsx b/examples/tangle-ml-pipeline-optimizer/ui/pipeline-url-input.tsx index 6226a8d..796c892 100644 --- a/examples/tangle-ml-pipeline-optimizer/ui/pipeline-url-input.tsx +++ b/examples/tangle-ml-pipeline-optimizer/ui/pipeline-url-input.tsx @@ -26,10 +26,12 @@ import { } from "@tangent/bundle-ui"; import { useEffect, useState } from "react"; -/** Tangle API origin; only this host's pipeline-runs path is allowlisted. */ -const TANGLE_BASE = "https://oasis.shopify.io"; const RECENT_RUN_LIMIT = 5; +function tangleApi(path: string) { + return { target: "tangle" as const, path }; +} + /** * Key for the host-mediated KV store. Holds `{ url }` once a pipeline has been * submitted; its presence is what marks this instance as "sent" across reloads. @@ -165,7 +167,7 @@ function RecentRunItem({ @@ -217,7 +219,7 @@ export default function PipelineUrlInput() { let active = true; (async () => { try { - const res = await host.fetch(`${TANGLE_BASE}/api/pipeline_runs/`, { + const res = await host.fetch(tangleApi("/api/pipeline_runs/"), { query: { include_execution_stats: true, include_pipeline_names: true, diff --git a/examples/tangle/tools/tangle-api.ts b/examples/tangle/tools/tangle-api.ts index 7ef17bb..339f4fb 100644 --- a/examples/tangle/tools/tangle-api.ts +++ b/examples/tangle/tools/tangle-api.ts @@ -21,7 +21,7 @@ import { Type } from "typebox"; const INTERNAL_URL = process.env.TANGENT_INTERNAL_URL ?? ""; const INTERNAL_TOKEN = process.env.TANGENT_INTERNAL_TOKEN ?? ""; -const TANGLE_API_URL = process.env.TANGLE_API_URL ?? "https://oasis.shopify.io"; +const TANGLE_API_URL = process.env.TANGLE_API_URL ?? "https://api.example.com"; interface EgressInit { method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; diff --git a/examples/tangle/ui/pipeline-progress.tsx b/examples/tangle/ui/pipeline-progress.tsx index 65f7ef2..5e4d972 100644 --- a/examples/tangle/ui/pipeline-progress.tsx +++ b/examples/tangle/ui/pipeline-progress.tsx @@ -3,7 +3,7 @@ * * The agent emits a `tangent-ui:pipeline-progress` block carrying * `{ "executionId": "" }`; the host parses it and delivers it via - * `host.getProps()`. We then poll the real Oasis executions-state endpoint + * `host.getProps()`. We then poll the real Tangle executions-state endpoint * through the allowlisted `host.fetch` bridge and render a progress bar. * * The header shows the pipeline's real title (resolved live from the Tangle API @@ -29,10 +29,12 @@ import { } from "@tangent/bundle-ui"; import { useEffect, useState } from "react"; -const TANGLE_BASE = "https://oasis.shopify.io"; -const OASIS_BASE = `${TANGLE_BASE}/api/executions`; const POLL_INTERVAL_MS = 4000; +function tangleApi(path: string) { + return { target: "tangle" as const, path }; +} + /** Candidate keys for the pipeline's human title across API shapes. */ const TITLE_KEYS = ["pipeline_name", "display_name", "name", "title"]; @@ -49,10 +51,10 @@ interface ExecutionState { interface PipelineMeta { title: string | null; - url: string | null; + path: string | null; } -interface OasisStateResponse { +interface TangleStateResponse { child_execution_status_summary?: { total_executions?: number; ended_executions?: number; @@ -66,7 +68,7 @@ interface OasisStateResponse { /** Flatten the nested per-child status stats into a single status->count map. */ function flattenStatusStats( - childStats: OasisStateResponse["child_execution_status_stats"], + childStats: TangleStateResponse["child_execution_status_stats"], ): StatusStats { if (!childStats) return {}; const result: StatusStats = {}; @@ -82,7 +84,7 @@ function flattenStatusStats( } function toExecutionState(json: unknown): ExecutionState | null { - const response = json as OasisStateResponse | null; + const response = json as TangleStateResponse | null; const summary = response?.child_execution_status_summary; if (!summary) return null; return { @@ -170,7 +172,7 @@ function extractTitle(json: unknown): string | null { async function loadRunTitle(runId: string): Promise { try { const res = await host.fetch( - `${TANGLE_BASE}/api/pipeline_runs/${encodeURIComponent(runId)}`, + tangleApi(`/api/pipeline_runs/${encodeURIComponent(runId)}`), ); return res.ok ? extractTitle(res.json) : null; } catch { @@ -188,14 +190,14 @@ async function loadPipelineMeta( ): Promise { try { const res = await host.fetch( - `${OASIS_BASE}/${encodeURIComponent(executionId)}/details`, + tangleApi(`/api/executions/${encodeURIComponent(executionId)}/details`), ); if (!res.ok) return null; const runId = extractRunId(res.json); - const url = runId ? `${TANGLE_BASE}/runs/${runId}` : null; + const path = runId ? `/runs/${encodeURIComponent(runId)}` : null; const title = extractTitle(res.json) ?? (runId ? await loadRunTitle(runId) : null); - return { title, url }; + return { title, path }; } catch { return null; } @@ -205,7 +207,7 @@ export default function PipelineProgress() { const [executionId, setExecutionId] = useState(null); const [state, setState] = useState(null); const [title, setTitle] = useState(null); - const [pipelineUrl, setPipelineUrl] = useState(null); + const [pipelinePath, setPipelinePath] = useState(null); useEffect(() => { host.getProps().then((props) => { @@ -222,7 +224,7 @@ export default function PipelineProgress() { loadPipelineMeta(executionId).then((meta) => { if (!active || !meta) return; setTitle(meta.title); - setPipelineUrl(meta.url); + setPipelinePath(meta.path); }); return () => { @@ -243,7 +245,7 @@ export default function PipelineProgress() { const tick = async () => { try { const res = await host.fetch( - `${OASIS_BASE}/${encodeURIComponent(executionId)}/state`, + tangleApi(`/api/executions/${encodeURIComponent(executionId)}/state`), ); if (!active || !res.ok) return; const next = toExecutionState(res.json); @@ -283,8 +285,12 @@ export default function PipelineProgress() { const displayTitle = title ?? "Pipeline"; const openPipeline = async () => { - if (!pipelineUrl) return; - await host.execUICommand({ type: "openUrl", url: pipelineUrl }); + if (!pipelinePath) return; + await host.execUICommand({ + type: "openTargetUrl", + target: "tangle", + path: pipelinePath, + }); }; return ( @@ -296,7 +302,7 @@ export default function PipelineProgress() { variant="link" size="sm" onPress={openPipeline} - disabled={!pipelineUrl} + disabled={!pipelinePath} > {displayTitle} diff --git a/scripts/print-tangle-token.ts b/scripts/print-tangle-token.ts index d15bcba..86c8379 100644 --- a/scripts/print-tangle-token.ts +++ b/scripts/print-tangle-token.ts @@ -13,9 +13,7 @@ import { getOktassoHeaders } from "./oktassoAuth.ts"; const baseUrl = - process.argv[2] ?? - process.env.TANGLE_BASE_URL ?? - "https://tangle.example.com"; + process.argv[2] ?? process.env.TANGLE_BASE_URL ?? "https://api.example.com"; const forceRefresh = process.env.TANGLE_TOKEN_FORCE_REFRESH === "1"; try { diff --git a/turbo.json b/turbo.json index a83a142..df4dcf9 100644 --- a/turbo.json +++ b/turbo.json @@ -16,6 +16,7 @@ "PI_PROXY_API_KEY", "TANGENT_INTERNAL_URL", "TANGENT_INTERNAL_TOKEN", + "AUTH_JWT_TOKEN_COOKIE_NAME", "TANGLE_API_URL", "TANGLE_TOKEN" ],