88 ENVIRONMENT_MATCH_ID ,
99 ENVIRONMENT_PORTABLE_PAGES ,
1010 environmentPortablePage ,
11+ ORGANIZATION_ADDRESSED_PAGES ,
1112 ORGANIZATION_PORTABLE_PAGES ,
1213 ORGANIZATION_SPECIFIC_PAGES ,
1314 organizationPortablePage ,
@@ -56,17 +57,38 @@ const GATE_REJECTS_VIA_FLAG = new RegExp(
5657 String . raw `const canAccess = await ${ ORGANIZATION_GATE } ;\s*if \(!canAccess\) \{\s*throw`
5758) ;
5859
60+ // A role the caller holds in one organization but not the next: an `authorization` block on the
61+ // loader, or the denial thrown directly for a check the block cannot express.
62+ const GATE_REJECTS_ON_ROLE = / t h r o w P e r m i s s i o n D e n i e d \( | a u t h o r i z a t i o n : \{ / ;
63+
64+ /** The loader's half of a route module, so a gate on its action is not read as a gate on landing. */
65+ function loaderSource ( source : string ) : string {
66+ const start = source . search ( / ^ e x p o r t (?: c o n s t | a s y n c f u n c t i o n ) l o a d e r \b / m) ;
67+ if ( start < 0 ) return "" ;
68+
69+ const loaderOnwards = source . slice ( start ) ;
70+ const next = loaderOnwards . slice ( 1 ) . search ( / ^ e x p o r t (?: c o n s t | a s y n c f u n c t i o n | d e f a u l t ) / m) ;
71+
72+ return next < 0 ? loaderOnwards : loaderOnwards . slice ( 0 , next + 1 ) ;
73+ }
74+
5975/**
60- * The page a loader turns you away from when an organization-scoped check says no, whether it
61- * sends you home or 404s . A gate that only covers one value of a route param names that page; an
62- * unconditional gate on a route that takes a resource id names nothing, since a page with an id in
63- * it is never portable anyway.
76+ * The page a loader turns you away from when a check the organization answers says no, whether it
77+ * sends you home, 404s or renders the permission panel . A gate that only covers one value of a
78+ * route param names that page; a gate on a route that takes a resource id names nothing, since a
79+ * page with an id in it is never portable anyway.
6480 */
6581function organizationGatedPage ( suffix : string , file : string ) : string | undefined {
6682 const source = readFileSync ( join ( APP_DIR , file ) , "utf8" ) ;
6783 const guarded = GATE_REJECTS . exec ( source ) ;
6884
69- if ( guarded === null ) return GATE_REJECTS_VIA_FLAG . test ( source ) ? suffix : undefined ;
85+ if ( guarded === null ) {
86+ if ( GATE_REJECTS_VIA_FLAG . test ( source ) ) return suffix ;
87+
88+ return GATE_REJECTS_ON_ROLE . test ( loaderSource ( source ) ) && ! suffix . includes ( ":" )
89+ ? suffix
90+ : undefined ;
91+ }
7092
7193 const [ , param , key ] = guarded ;
7294 if ( param === undefined ) return suffix . includes ( ":" ) ? undefined : suffix ;
@@ -80,11 +102,19 @@ const belowEnvironment = Object.values(compiledRoutes)
80102 const suffix = compiledUrl ( route . id ) . slice ( ENVIRONMENT_URL . length ) . replace ( / ^ \/ / , "" ) ;
81103 return {
82104 suffix,
105+ file : route . file ,
83106 rendersAPage : rendersAPage ( route . file ) ,
84107 organizationGatedPage : organizationGatedPage ( suffix , route . file ) ,
85108 } ;
86109 } ) ;
87110
111+ function sourceOf ( suffix : string ) : string {
112+ const route = belowEnvironment . find ( ( route ) => route . suffix === suffix ) ;
113+ if ( ! route ) throw new Error ( `no route below the environment at ${ suffix } ` ) ;
114+
115+ return readFileSync ( join ( APP_DIR , route . file ) , "utf8" ) ;
116+ }
117+
88118const environmentRoutes = [ ...new Set ( belowEnvironment . map ( ( route ) => route . suffix ) ) ] ;
89119
90120// Streams and Slack callbacks sit below an environment without being pages a user lands on.
@@ -102,7 +132,11 @@ function listAbove(page: string): string {
102132}
103133
104134const slugAddressedProbes = probes . filter ( ( page ) => SLUG_ADDRESSED_PAGES . includes ( listAbove ( page ) ) ) ;
105- const idAddressedProbes = probes . filter ( ( page ) => ! SLUG_ADDRESSED_PAGES . includes ( listAbove ( page ) ) ) ;
135+ const organizationAddressedProbes = probes . filter ( ( page ) =>
136+ ORGANIZATION_ADDRESSED_PAGES . includes ( listAbove ( page ) )
137+ ) ;
138+ const environmentKeptProbes = [ ...slugAddressedProbes , ...organizationAddressedProbes ] ;
139+ const idAddressedProbes = probes . filter ( ( page ) => ! environmentKeptProbes . includes ( page ) ) ;
106140
107141function matchesARoute ( page : string ) : boolean {
108142 const wanted = page === "" ? [ ] : page . split ( "/" ) ;
@@ -257,16 +291,30 @@ describe("pages an organization switch cannot carry", () => {
257291 expect ( gated ) . toEqual ( [ ...ORGANIZATION_SPECIFIC_PAGES ] . sort ( ) ) ;
258292 } ) ;
259293
260- it ( "are read from both ways a loader turns you away, so neither stops being noticed" , ( ) => {
294+ it ( "are read from every way a loader turns you away, so none stops being noticed" , ( ) => {
261295 const gatedPage = ( suffix : string ) =>
262296 belowEnvironment . find ( ( route ) => route . suffix === suffix ) ?. organizationGatedPage ;
263297
264298 expect ( gatedPage ( "logs" ) ) . toBe ( "logs" ) ;
265299 expect ( gatedPage ( "dashboards/:dashboardKey" ) ) . toBe ( "dashboards/queues" ) ;
300+ expect ( gatedPage ( "settings/integrations" ) ) . toBe ( "settings/integrations" ) ;
301+ expect ( gatedPage ( "bulk-actions/:bulkActionParam" ) ) . toBeUndefined ( ) ;
266302 expect ( gatedPage ( "queues/:queueParam" ) ) . toBeUndefined ( ) ;
267303 expect ( gatedPage ( "apikeys" ) ) . toBeUndefined ( ) ;
268304 } ) ;
269305
306+ it ( "read a role gate off the loader, not off an action the page never runs on landing" , ( ) => {
307+ const gatedAction = [
308+ "export const loader = dashboardLoader({ params: Schema }, async () => {});" ,
309+ "" ,
310+ 'export const action = dashboardAction({ authorization: { action: "write" } }, async () => {});' ,
311+ ] . join ( "\n" ) ;
312+
313+ expect ( GATE_REJECTS_ON_ROLE . test ( gatedAction ) ) . toBe ( true ) ;
314+ expect ( GATE_REJECTS_ON_ROLE . test ( loaderSource ( gatedAction ) ) ) . toBe ( false ) ;
315+ expect ( GATE_REJECTS_ON_ROLE . test ( loaderSource ( sourceOf ( "settings/integrations" ) ) ) ) . toBe ( true ) ;
316+ } ) ;
317+
270318 it ( "still travel with an environment or project switch, which stay in the same organization" , ( ) => {
271319 for ( const page of ORGANIZATION_SPECIFIC_PAGES ) {
272320 expect ( ENVIRONMENT_PORTABLE_PAGES . has ( page ) ) . toBe ( true ) ;
@@ -277,7 +325,16 @@ describe("pages an organization switch cannot carry", () => {
277325 }
278326 } ) ;
279327
280- it ( "stay put when only the environment changes" , ( ) => {
328+ it ( "stay put when only the environment or project changes" , ( ) => {
329+ expect ( projectPortablePage ( "settings/integrations" ) ) . toBe ( "settings/integrations" ) ;
330+ expect (
331+ pathForEnvironmentSwitch ( {
332+ location : locationOn ( "settings/integrations" ) ,
333+ environmentPathname : environmentLocation . pathname ,
334+ environmentSlug : "prod" ,
335+ } )
336+ ) . toBe ( "/orgs/acme/projects/api/env/prod/settings/integrations" ) ;
337+
281338 expect (
282339 pathForEnvironmentSwitch ( {
283340 location : locationOn ( "dashboards/queues" , "?period=1d" ) ,
@@ -311,6 +368,7 @@ describe("pages an organization switch cannot carry", () => {
311368 expect ( read ( "?page=logs" ) ) . toBe ( "" ) ;
312369 expect ( read ( "?page=query" ) ) . toBe ( "" ) ;
313370 expect ( read ( "?page=dashboards/queues" ) ) . toBe ( "dashboards" ) ;
371+ expect ( read ( "?page=settings/integrations" ) ) . toBe ( "settings" ) ;
314372 expect ( read ( "?page=apikeys" ) ) . toBe ( "apikeys" ) ;
315373 } ) ;
316374} ) ;
@@ -422,6 +480,43 @@ describe("pages named after something the environment did not issue", () => {
422480 } ) ;
423481} ) ;
424482
483+ describe ( "pages named after an id the organization issued" , ( ) => {
484+ it ( "are the ones a route below them takes an id its organization, not its environment, holds" , ( ) => {
485+ expect ( organizationAddressedProbes . length ) . toBeGreaterThan ( 0 ) ;
486+ expect ( [ ...new Set ( organizationAddressedProbes . map ( listAbove ) ) ] . sort ( ) ) . toEqual (
487+ [ ...ORGANIZATION_ADDRESSED_PAGES ] . sort ( )
488+ ) ;
489+
490+ for ( const page of organizationAddressedProbes ) {
491+ expect ( environmentPortablePage ( page ) ) . toBe ( page ) ;
492+ }
493+ } ) ;
494+
495+ it ( "stay open when only the environment changes, since the same id opens them there" , ( ) => {
496+ expect (
497+ pathForEnvironmentSwitch ( {
498+ location : locationOn ( "dashboards/custom/dashboard_123" , "?period=1d" ) ,
499+ environmentPathname : environmentLocation . pathname ,
500+ environmentSlug : "prod" ,
501+ } )
502+ ) . toBe ( "/orgs/acme/projects/api/env/prod/dashboards/custom/dashboard_123?period=1d" ) ;
503+ } ) ;
504+
505+ it ( "fall back to the dashboard list when the project or organization changes" , ( ) => {
506+ expect ( projectPortablePage ( "dashboards/custom/dashboard_123" ) ) . toBe ( "dashboards" ) ;
507+ expect ( organizationPortablePage ( "dashboards/custom/dashboard_123" ) ) . toBe ( "dashboards" ) ;
508+ } ) ;
509+
510+ it ( "keep nothing but a single plain id in that last segment" , ( ) => {
511+ expect ( environmentPortablePage ( "dashboards/custom/..%2f..%2flogin" ) ) . toBe ( "dashboards" ) ;
512+ expect ( environmentPortablePage ( "dashboards/custom/../../login" ) ) . toBe ( "dashboards" ) ;
513+ expect ( environmentPortablePage ( "dashboards/custom/%2e%2e" ) ) . toBe ( "dashboards" ) ;
514+ expect ( environmentPortablePage ( "dashboards/custom/.." ) ) . toBe ( "dashboards" ) ;
515+ expect ( environmentPortablePage ( "dashboards/custom/" ) ) . toBe ( "dashboards" ) ;
516+ expect ( environmentPortablePage ( "dashboards/custom/dashboard_123/extra" ) ) . toBe ( "dashboards" ) ;
517+ } ) ;
518+ } ) ;
519+
425520describe ( "a page suffix that is not a plain relative page" , ( ) => {
426521 it ( "falls back to the environment root rather than being sanitised into one" , ( ) => {
427522 expect ( projectPortablePage ( "/apikeys" ) ) . toBe ( "" ) ;
@@ -458,6 +553,7 @@ describe("a page suffix that is not a plain relative page", () => {
458553 "tasks/standard/../../login" ,
459554 "agents/..%2f..%2flogin" ,
460555 "models/%2f%2fevil.example.com" ,
556+ "dashboards/custom/..%2f..%2flogin" ,
461557 ] ;
462558
463559 for ( const attempt of attempts ) {
0 commit comments