Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
207 changes: 207 additions & 0 deletions packages/core/src/v3/chat-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
/**
* Chat shared types used by backend (ai.ts) and frontend (chat.ts)
* code paths — primarily {@link ChatStoreChunk} + {@link applyChatStorePatch}
* for the `chat.store` primitive. Pre-Session transport also exported
* `CHAT_STREAM_KEY` / `CHAT_MESSAGES_STREAM_ID` / `CHAT_STOP_STREAM_ID`
* from here; those are gone — chat output and input both live on the
* backing Session now (see `@trigger.dev/sdk/sessions`).
*/

// ─── chat.store chunk types ────────────────────────────────────────
//
// First-class chunk types for `chat.store` — bidirectional shared data
// between a chat.agent and its clients. Emitted on the same S2 output
// stream as UIMessageChunks but intercepted by the transport (not
// passed to the AI SDK).

/**
* An RFC 6902 JSON Patch operation used by `chat.store.patch()` and
* emitted inside {@link ChatStoreDeltaChunk}.
*
* @see https://tools.ietf.org/html/rfc6902
*/
export type ChatStorePatchOperation =
| { op: "add"; path: string; value: unknown }
| { op: "remove"; path: string }
| { op: "replace"; path: string; value: unknown }
| { op: "move"; path: string; from: string }
| { op: "copy"; path: string; from: string }
| { op: "test"; path: string; value: unknown };

/** Full-value snapshot — emitted by `chat.store.set(...)`. */
export type ChatStoreSnapshotChunk = {
type: "store-snapshot";
value: unknown;
};

/** Incremental update — emitted by `chat.store.patch([...])`. */
export type ChatStoreDeltaChunk = {
type: "store-delta";
operations: ChatStorePatchOperation[];
};

export type ChatStoreChunk = ChatStoreSnapshotChunk | ChatStoreDeltaChunk;

// ─── RFC 6902 JSON Patch applier ───────────────────────────────────
//
// Minimal in-process implementation so we don't pull a runtime dep
// into the SDK or webapp. Handles the six RFC 6902 ops with RFC 6901
// JSON Pointer paths. Used by `chat.store.patch()` on the agent and
// the matching client-side `applyStorePatch` on the transport.

// Reject these segments at the parser to prevent prototype pollution: a
// malicious patch like `{ op: "replace", path: "/__proto__/polluted", value: 1 }`
// would otherwise mutate Object.prototype. Patches with these keys aren't
// legitimate for chat.store, so reject the whole patch with a clear error.
const FORBIDDEN_POINTER_SEGMENTS = new Set(["__proto__", "constructor", "prototype"]);

function parseJsonPointer(path: string): string[] {
if (path === "") return [];
if (!path.startsWith("/")) {
throw new Error(`Invalid JSON Pointer (must start with "/"): ${path}`);
}
const tokens = path
.slice(1)
.split("/")
.map((segment) => segment.replace(/~1/g, "/").replace(/~0/g, "~"));
for (const token of tokens) {
if (FORBIDDEN_POINTER_SEGMENTS.has(token)) {
throw new Error(`Invalid JSON Pointer segment "${token}" in path "${path}"`);
}
}
return tokens;
}

function cloneValue<T>(value: T): T {
if (value === undefined || value === null) return value;
if (typeof structuredClone === "function") {
try {
return structuredClone(value);
} catch {
// Fall through for values that can't be structured-cloned
}
}
return JSON.parse(JSON.stringify(value));
}

function getParentAndKey(
doc: unknown,
tokens: string[]
): { parent: any; lastToken: string } {
if (tokens.length === 0) {
throw new Error("Cannot get parent of root");
}
let parent: any = doc;
for (let i = 0; i < tokens.length - 1; i++) {
if (parent == null || typeof parent !== "object") {
throw new Error(`Path traversal failed at segment "${tokens[i]}"`);
}
const key = Array.isArray(parent) ? Number(tokens[i]) : tokens[i];
parent = (parent as any)[key as any];
}
return { parent, lastToken: tokens[tokens.length - 1]! };
}

function readPointer(doc: unknown, tokens: string[]): unknown {
if (tokens.length === 0) return doc;
let cursor: any = doc;
for (const token of tokens) {
if (cursor == null) return undefined;
const key = Array.isArray(cursor) ? Number(token) : token;
cursor = cursor[key];
}
return cursor;
}

function removeAt(parent: any, lastToken: string): void {
if (Array.isArray(parent)) {
parent.splice(Number(lastToken), 1);
} else if (parent && typeof parent === "object") {
if (
lastToken === "__proto__" ||
lastToken === "constructor" ||
lastToken === "prototype"
) {
throw new Error(`Refusing to mutate forbidden key "${lastToken}"`);
}
delete parent[lastToken];
} else {
throw new Error("Cannot remove: parent is not a container");
}
}

function insertAt(parent: any, lastToken: string, value: unknown, op: "add" | "replace"): void {
if (Array.isArray(parent)) {
const idx = lastToken === "-" ? parent.length : Number(lastToken);
if (op === "add") parent.splice(idx, 0, value);
else parent[idx] = value;
} else if (parent && typeof parent === "object") {
if (
lastToken === "__proto__" ||
lastToken === "constructor" ||
lastToken === "prototype"
) {
throw new Error(`Refusing to mutate forbidden key "${lastToken}"`);
}
parent[lastToken] = value;
} else {
throw new Error("Cannot insert: parent is not a container");
}
}

/**
* Apply an RFC 6902 JSON Patch to a document and return the new value.
* Never mutates the input.
*/
export function applyChatStorePatch(
doc: unknown,
operations: readonly ChatStorePatchOperation[]
): unknown {
let result: any = doc === undefined ? undefined : cloneValue(doc);

for (const op of operations) {
const tokens = parseJsonPointer(op.path);

if (op.op === "test") {
const actual = readPointer(result, tokens);
if (JSON.stringify(actual) !== JSON.stringify(op.value)) {
throw new Error(`JSON Patch test failed at path "${op.path}"`);
}
continue;
}

if (op.op === "remove") {
if (tokens.length === 0) {
result = undefined;
continue;
}
const { parent, lastToken } = getParentAndKey(result, tokens);
removeAt(parent, lastToken);
continue;
}

// add / replace / move / copy all insert a value at `path`
let valueToInsert: unknown;
if (op.op === "add" || op.op === "replace") {
valueToInsert = cloneValue(op.value);
} else {
// move / copy — source must exist
const fromTokens = parseJsonPointer(op.from);
valueToInsert = cloneValue(readPointer(result, fromTokens));
if (op.op === "move" && fromTokens.length > 0) {
const { parent: fromParent, lastToken: fromLast } = getParentAndKey(result, fromTokens);
removeAt(fromParent, fromLast);
}
}

if (tokens.length === 0) {
result = valueToInsert;
continue;
}

const { parent, lastToken } = getParentAndKey(result, tokens);
insertAt(parent, lastToken, valueToInsert, op.op === "replace" ? "replace" : "add");
}

return result;
}
11 changes: 11 additions & 0 deletions packages/trigger-sdk/src/v3/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,17 @@ type PublicTokenPermissionProperties = {
* Grant access to send data to input streams on specific runs
*/
inputStreams?: string | string[];

/**
* Grant access to specific Sessions (the durable, typed I/O primitive that
* outlives a single run). Use the session's friendlyId (e.g. `session_abc`).
*
* `read:sessions:{id}` lets the bearer read both the `.out` and `.in`
* channels and list runs on the session. `write:sessions:{id}` lets the
* bearer append to the session's channels. `trigger:sessions:{id}` permits
* triggering new runs on the session.
*/
sessions?: string | string[];
};

export type PublicTokenPermissions = {
Expand Down
Loading
Loading