1+ import { buildJwtAbility } from "@trigger.dev/plugins" ;
12import { describe , expect , it } from "vitest" ;
23import {
34 isSafeSessionExternalId ,
45 SESSION_CHANNEL_SCOPE_INFIX ,
56 sessionChannelResources ,
7+ sessionStreamResources ,
68} from "./sessionChannels.server" ;
79
810describe ( "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