Skip to content

Commit 33cf570

Browse files
carderneTrigger.dev RepoOps
authored andcommitted
fix(webapp,sdk): narrow session read tokens by direction and make .in reads secret-key only
Session public tokens can now be narrowed to one stream with `read:sessions:{id}:out`, and reading a session's `.in` channel now requires a secret key. The dashboard agent's browser token uses the narrowed scope. Mono-RevId: 1214e6dd60256c7f1323e891580c16d500c8fd9a
1 parent 9868110 commit 33cf570

23 files changed

Lines changed: 520 additions & 54 deletions

.changeset/session-output-scope.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@trigger.dev/sdk": patch
3+
---
4+
5+
Session public tokens can now be narrowed to one stream: `read: { sessions: "chat_123:out" }` grants read access to that session's `.out` channel only, without access to the session record or its other channels.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: breaking
4+
---
5+
6+
Reading a session's `.in` channel (`GET /realtime/v1/sessions/{id}/in` and `/in/records`) now requires a secret key. Public tokens, including `read:sessions:{id}`, get a 403; they can still read `.out` and append to `.in`.

apps/webapp/app/routes/api.v1.sessions.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,9 @@ const { action } = createActionApiRoute(
174174
try {
175175
if (body.externalId && !isSafeSessionExternalId(body.externalId)) {
176176
return json(
177-
{ error: `externalId cannot contain "${SESSION_CHANNEL_SCOPE_INFIX}"` },
177+
{
178+
error: `externalId cannot contain "${SESSION_CHANNEL_SCOPE_INFIX}" or end in ":out" or ":in"`,
179+
},
178180
{ status: 422 }
179181
);
180182
}

apps/webapp/app/routes/realtime.v1.sessions.$session.$io.records.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { json } from "@remix-run/server-runtime";
22
import { z } from "zod";
33
import { $replica } from "~/db.server";
44
import { S2RealtimeStreams } from "~/services/realtime/s2realtimeStreams.server";
5+
import { sessionStreamResources } from "~/services/realtime/sessionChannels.server";
56
import {
67
canonicalSessionAddressingKey,
78
isSessionFriendlyIdForm,
@@ -59,20 +60,32 @@ export const loader = createLoaderApiRoute(
5960
authorization: {
6061
action: "read",
6162
// Multi-key: the channel is addressable by the URL key, the row's
62-
// friendlyId, and (if set) externalId. Type-level `read:sessions`
63-
// matches any of them; `read:all` / `admin` bypass via the JWT
64-
// ability's wildcard branches.
65-
resource: ({ row, addressingKey }) => {
63+
// friendlyId, and (if set) externalId, each also in its direction-folded
64+
// form (`{key}:out`) so a token narrowed to one direction matches. Type-level
65+
// `read:sessions` matches any of them; `read:all` / `admin` bypass via the
66+
// JWT ability's wildcard branches.
67+
resource: ({ row, addressingKey }, params) => {
6668
const ids = new Set<string>([addressingKey]);
6769
if (row) {
6870
ids.add(row.friendlyId);
6971
if (row.externalId) ids.add(row.externalId);
7072
}
71-
return anyResource([...ids].map((id) => ({ type: "sessions", id })));
73+
return anyResource(sessionStreamResources(params.io, ids));
7274
},
7375
},
7476
},
7577
async ({ params, authentication, resource, searchParams }) => {
78+
// `.in` is the client→agent channel: the agent run reads it, clients only append. A
79+
// public token is a browser-held credential, and `.in` records can carry data meant
80+
// for the agent alone (a server-side proxy may inject per-turn credentials), so
81+
// reading `.in` requires the secret key. `.out` is the only public read.
82+
if (params.io === "in" && authentication.type !== "PRIVATE") {
83+
return json(
84+
{ ok: false, error: "Reading the in channel requires secret key authentication" },
85+
{ status: 403 }
86+
);
87+
}
88+
7689
const realtimeStream = getRealtimeStreamInstance(authentication.environment, "v2", {
7790
session: resource.row,
7891
organization: resource.row ? null : authentication.environment.organization,

apps/webapp/app/routes/realtime.v1.sessions.$session.$io.ts

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { STREAM_START_HEADER } from "@trigger.dev/core/v3";
33
import { z } from "zod";
44
import { getRequestAbortSignal } from "~/services/httpAsyncStorage.server";
55
import { S2RealtimeStreams } from "~/services/realtime/s2realtimeStreams.server";
6+
import { sessionStreamResources } from "~/services/realtime/sessionChannels.server";
67
import {
78
canonicalSessionAddressingKey,
89
isSessionFriendlyIdForm,
@@ -122,20 +123,31 @@ const loader = createLoaderApiRoute(
122123
authorization: {
123124
action: "read",
124125
// Multi-key: the channel is addressable by the URL key, the row's
125-
// friendlyId, and (if set) externalId. Type-level `read:sessions`
126-
// matches any of them; `read:all` / `admin` bypass via the JWT
127-
// ability's wildcard branches.
128-
resource: ({ row, addressingKey }) => {
126+
// friendlyId, and (if set) externalId, each also in its direction-folded
127+
// form (`{key}:out`) so a token narrowed to one direction matches. Type-level
128+
// `read:sessions` matches any of them; `read:all` / `admin` bypass via the
129+
// JWT ability's wildcard branches.
130+
resource: ({ row, addressingKey }, params) => {
129131
const ids = new Set<string>([addressingKey]);
130132
if (row) {
131133
ids.add(row.friendlyId);
132134
if (row.externalId) ids.add(row.externalId);
133135
}
134-
return anyResource([...ids].map((id) => ({ type: "sessions", id })));
136+
return anyResource(sessionStreamResources(params.io, ids));
135137
},
136138
},
137139
},
138140
async ({ params, request, authentication, resource }) => {
141+
// `.in` is the client→agent channel: the agent run reads it, clients only append. A
142+
// public token is a browser-held credential, and `.in` records can carry data meant
143+
// for the agent alone (a server-side proxy may inject per-turn credentials), so
144+
// reading `.in` requires the secret key. `.out` is the only public read.
145+
if (params.io === "in" && authentication.type !== "PRIVATE") {
146+
return new Response("Reading the in channel requires secret key authentication", {
147+
status: 403,
148+
});
149+
}
150+
139151
// Same no-row fallback as PUT above.
140152
const realtimeStream = getRealtimeStreamInstance(authentication.environment, "v2", {
141153
session: resource.row,

apps/webapp/app/routes/realtime.v1.sessions.$session.channels.$channel.$io.records.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ export const loader = createLoaderApiRoute(
4747
ids.add(row.friendlyId);
4848
if (row.externalId) ids.add(row.externalId);
4949
}
50-
return anyResource(sessionChannelResources(params.channel, ids));
50+
return anyResource(sessionChannelResources(params.channel, ids, params.io));
5151
},
5252
},
5353
},

apps/webapp/app/routes/realtime.v1.sessions.$session.channels.$channel.$io.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ const loader = createLoaderApiRoute(
104104
ids.add(row.friendlyId);
105105
if (row.externalId) ids.add(row.externalId);
106106
}
107-
return anyResource(sessionChannelResources(params.channel, ids));
107+
return anyResource(sessionChannelResources(params.channel, ids, params.io));
108108
},
109109
},
110110
},

apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,18 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
199199
// Null is not an empty transcript: the chat is deleted or another org's, and a 200 would
200200
// read as a real, empty chat.
201201
if (messages === null) return json({ error: "Chat not found" }, { status: 404 });
202-
return json({ messages, session });
202+
// The stored token is the agent run's own (bare `read:sessions` plus run scopes), kept for
203+
// the agent's replay. The browser gets a token minted for it, narrowed like every other
204+
// dashboard-agent issuance; a failed mint degrades to a non-streaming transcript.
205+
let browserSession: typeof session = null;
206+
if (session && isDashboardAgentConfigured()) {
207+
try {
208+
browserSession = { ...session, publicAccessToken: await mintDashboardAgentToken(chatId) };
209+
} catch (error) {
210+
logger.error("Dashboard agent chat read could not mint a browser token", { chatId, error });
211+
}
212+
}
213+
return json({ messages, session: browserSession });
203214
}
204215

205216
const chats = await listChats(dashboardAgentDb, {
@@ -638,7 +649,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
638649
// run's `basePayload.metadata` verbatim, so without the pick a client could inject
639650
// any server-owned field into the agent's first turn (a `repoSnapshot.tarballUrl`
640651
// is fetched and extracted on the worker).
641-
const { publicAccessToken } = await startDashboardAgentSession({
652+
await startDashboardAgentSession({
642653
chatId,
643654
clientData: {
644655
...pickAgentClientMetadata(clientData),
@@ -656,14 +667,24 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
656667
...dashboardAgentEnvironmentAddress(runtimeEnv),
657668
},
658669
});
659-
return json({ publicAccessToken });
660670
} catch (error) {
661671
logger.error("Failed to start dashboard agent session", { chatId, error });
662672
return json(
663673
{ error: "The dashboard agent couldn't start. Please try again in a moment." },
664674
{ status: 500 }
665675
);
666676
}
677+
678+
try {
679+
return json({ publicAccessToken: await mintDashboardAgentToken(chatId) });
680+
} catch (error) {
681+
// The session is live and idles out on its own if the client never comes back.
682+
logger.error("Dashboard agent chat resumed but its token mint failed", { chatId, error });
683+
return json(
684+
{ error: "The dashboard agent started but couldn't be opened. Try opening it again." },
685+
{ status: 500 }
686+
);
687+
}
667688
}
668689

669690
case "token": {

apps/webapp/app/services/dashboardAgent.server.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -111,25 +111,30 @@ export function dashboardAgentTriggerConfig(): {
111111
};
112112
}
113113

114+
// The SDK's start action also mints a `read:sessions` token; it is discarded here so every
115+
// browser token comes from `mintDashboardAgentToken`.
114116
export async function startDashboardAgentSession(params: {
115117
chatId: string;
116118
clientData?: Record<string, unknown>;
117-
}): Promise<{ publicAccessToken: string }> {
119+
}): Promise<void> {
118120
const config = dashboardAgentConfig();
119121
if (!config) throw new Error("DASHBOARD_AGENT_SECRET_KEY is not set");
120122
const startSession = chat.createStartSessionAction(TASK_ID, {
121123
apiClient: config,
122124
triggerConfig: dashboardAgentTriggerConfig(),
123125
});
124-
return startSession({ chatId: params.chatId, clientData: params.clientData });
126+
await startSession({ chatId: params.chatId, clientData: params.clientData });
125127
}
126128

129+
// Read is narrowed to the `.out` stream (`read:sessions:{chatId}:out`): `.in` records carry
130+
// the delegated user token the `in` proxy injects, and the session row carries the trigger
131+
// config, so the browser gets neither.
127132
export async function mintDashboardAgentToken(chatId: string): Promise<string> {
128133
const config = dashboardAgentConfig();
129134
if (!config) throw new Error("DASHBOARD_AGENT_SECRET_KEY is not set");
130135
const client = new TriggerClient(config);
131136
return client.auth.createPublicToken({
132-
scopes: { read: { sessions: chatId }, write: { sessions: chatId } },
137+
scopes: { read: { sessions: `${chatId}:out` }, write: { sessions: chatId } },
133138
expirationTime: "1h",
134139
});
135140
}
Lines changed: 89 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
import { buildJwtAbility } from "@trigger.dev/plugins";
12
import { describe, expect, it } from "vitest";
23
import {
34
isSafeSessionExternalId,
45
SESSION_CHANNEL_SCOPE_INFIX,
56
sessionChannelResources,
7+
sessionStreamResources,
68
} from "./sessionChannels.server";
79

810
describe("isSafeSessionExternalId", () => {
@@ -14,22 +16,105 @@ describe("isSafeSessionExternalId", () => {
1416
expect(isSafeSessionExternalId("a:channels:b:channels:c")).toBe(false);
1517
});
1618

19+
it("rejects an externalId that collides with the direction fold", () => {
20+
expect(isSafeSessionExternalId("session_abc:out")).toBe(false);
21+
expect(isSafeSessionExternalId("session_abc:in")).toBe(false);
22+
expect(isSafeSessionExternalId(":out")).toBe(false);
23+
});
24+
1725
it("allows normal externalIds, including single colons that are not the fold infix", () => {
1826
expect(isSafeSessionExternalId("chat-3c3a1756-a49a-4c78-891a-51f78596c984")).toBe(true);
1927
expect(isSafeSessionExternalId("user:123")).toBe(true);
2028
expect(isSafeSessionExternalId("org:abc:chat:1")).toBe(true);
2129
expect(isSafeSessionExternalId("channels")).toBe(true);
2230
expect(isSafeSessionExternalId("plain")).toBe(true);
31+
// Only the exact `:out` / `:in` suffix folds; these merely contain or end with the word.
32+
expect(isSafeSessionExternalId("checkout")).toBe(true);
33+
expect(isSafeSessionExternalId("login")).toBe(true);
34+
expect(isSafeSessionExternalId("chat:out:1")).toBe(true);
2335
});
2436

25-
it("keeps a channel-scoped token's folded id from equaling any allowed session's bare key", () => {
26-
const channel = "screencast";
27-
const foldedIds = sessionChannelResources(channel, ["session_abc"])
37+
it("keeps a narrowed token's folded id from equaling any allowed session's bare key", () => {
38+
const foldedIds = [
39+
...sessionChannelResources("screencast", ["session_abc"], "out"),
40+
...sessionStreamResources("out", ["session_abc"]),
41+
...sessionStreamResources("in", ["session_abc"]),
42+
]
2843
.map((r) => r.id)
29-
.filter((id) => id.includes(SESSION_CHANNEL_SCOPE_INFIX));
44+
.filter((id) => id !== "session_abc");
3045

46+
expect(foldedIds.length).toBe(4);
3147
for (const foldedId of foldedIds) {
3248
expect(isSafeSessionExternalId(foldedId)).toBe(false);
3349
}
3450
});
3551
});
52+
53+
describe("sessionStreamResources", () => {
54+
const keys = ["chat_abc", "session_123"];
55+
56+
it("lets read:sessions:{id}:out read .out but not .in", () => {
57+
const ability = buildJwtAbility(["read:sessions:chat_abc:out"]);
58+
expect(ability.can("read", sessionStreamResources("out", keys))).toBe(true);
59+
expect(ability.can("read", sessionStreamResources("in", keys))).toBe(false);
60+
});
61+
62+
it("keeps read:sessions:{id} matching both streams", () => {
63+
const ability = buildJwtAbility(["read:sessions:chat_abc"]);
64+
expect(ability.can("read", sessionStreamResources("out", keys))).toBe(true);
65+
expect(ability.can("read", sessionStreamResources("in", keys))).toBe(true);
66+
});
67+
68+
it("never authorizes a legacy externalId that equals another session's folded id", () => {
69+
// A row created before the direction fold existed may carry externalId `chat_abc:out`.
70+
// A token narrowed to session `chat_abc`'s .out stream must not read that other session.
71+
const narrowed = buildJwtAbility(["read:sessions:chat_abc:out"]);
72+
const legacyKeys = ["chat_abc:out", "session_legacy"];
73+
expect(narrowed.can("read", sessionStreamResources("out", legacyKeys))).toBe(false);
74+
expect(narrowed.can("read", sessionStreamResources("in", legacyKeys))).toBe(false);
75+
expect(narrowed.can("read", sessionChannelResources("tools", legacyKeys, "out"))).toBe(false);
76+
// Same for a pre-guard `:channels:` externalId against a channel-narrowed token.
77+
const channel = buildJwtAbility(["read:sessions:chat_abc:channels:tools"]);
78+
expect(channel.can("read", sessionStreamResources("out", ["chat_abc:channels:tools"]))).toBe(
79+
false
80+
);
81+
// The legacy row stays reachable by friendlyId and by a type-level scope.
82+
expect(
83+
buildJwtAbility(["read:sessions:session_legacy"]).can(
84+
"read",
85+
sessionStreamResources("out", legacyKeys)
86+
)
87+
).toBe(true);
88+
expect(
89+
buildJwtAbility(["read:sessions"]).can("read", sessionStreamResources("out", legacyKeys))
90+
).toBe(true);
91+
// An unsafe key contributes no resource at all.
92+
expect(sessionStreamResources("out", ["chat_abc:out"])).toEqual([]);
93+
});
94+
95+
it("does not let a direction-scoped token match the bare session", () => {
96+
const ability = buildJwtAbility(["read:sessions:chat_abc:out"]);
97+
expect(ability.can("read", { type: "sessions", id: "chat_abc" })).toBe(false);
98+
expect(ability.can("read", { type: "sessions", id: "session_123" })).toBe(false);
99+
});
100+
});
101+
102+
describe("sessionChannelResources", () => {
103+
const keys = ["chat_abc"];
104+
105+
it("lets a channel token read either direction and a channel:out token only .out", () => {
106+
const channelWide = buildJwtAbility(["read:sessions:chat_abc:channels:tools"]);
107+
expect(channelWide.can("read", sessionChannelResources("tools", keys, "out"))).toBe(true);
108+
expect(channelWide.can("read", sessionChannelResources("tools", keys, "in"))).toBe(true);
109+
110+
const outOnly = buildJwtAbility(["read:sessions:chat_abc:channels:tools:out"]);
111+
expect(outOnly.can("read", sessionChannelResources("tools", keys, "out"))).toBe(true);
112+
expect(outOnly.can("read", sessionChannelResources("tools", keys, "in"))).toBe(false);
113+
expect(outOnly.can("read", sessionChannelResources("other", keys, "out"))).toBe(false);
114+
});
115+
116+
it("does not let a default-stream :out token read a named channel", () => {
117+
const ability = buildJwtAbility(["read:sessions:chat_abc:out"]);
118+
expect(ability.can("read", sessionChannelResources("tools", keys, "out"))).toBe(false);
119+
});
120+
});

0 commit comments

Comments
 (0)