@@ -44,17 +44,44 @@ function rendersAPage(file: string): boolean {
4444 return / ^ e x p o r t d e f a u l t / m. test ( readFileSync ( join ( APP_DIR , file ) , "utf8" ) ) ;
4545}
4646
47- function sendsYouHome ( file : string ) : boolean {
48- return / r e d i r e c t \( " \/ " \) / . test ( readFileSync ( join ( APP_DIR , file ) , "utf8" ) ) ;
47+ const ORGANIZATION_GATE = String . raw `(?:can|has)[A-Z]\w*\([^)]*\borganizationSlug\b[^)]*\)` ;
48+
49+ const GATE_REJECTS = new RegExp (
50+ String . raw `if \(\s*(?:(\w+) === "([^"]+)" &&\s*)?!\(await ${ ORGANIZATION_GATE } \)\s*\)\s*\{\s*throw`
51+ ) ;
52+
53+ const GATE_REJECTS_VIA_FLAG = new RegExp (
54+ String . raw `const canAccess = await ${ ORGANIZATION_GATE } ;\s*if \(!canAccess\) \{\s*throw`
55+ ) ;
56+
57+ /**
58+ * The page a loader turns you away from when an organization-scoped check says no, whether it
59+ * sends you home or 404s. A gate that only covers one value of a route param names that page; an
60+ * unconditional gate on a route that takes a resource id names nothing, since a page with an id in
61+ * it is never portable anyway.
62+ */
63+ function organizationGatedPage ( suffix : string , file : string ) : string | undefined {
64+ const source = readFileSync ( join ( APP_DIR , file ) , "utf8" ) ;
65+ const guarded = GATE_REJECTS . exec ( source ) ;
66+
67+ if ( guarded === null ) return GATE_REJECTS_VIA_FLAG . test ( source ) ? suffix : undefined ;
68+
69+ const [ , param , key ] = guarded ;
70+ if ( param === undefined ) return suffix . includes ( ":" ) ? undefined : suffix ;
71+
72+ return suffix . includes ( `:${ param } ` ) ? suffix . replace ( `:${ param } ` , key ) : undefined ;
4973}
5074
5175const belowEnvironment = Object . values ( compiledRoutes )
5276 . filter ( ( route ) => compiledUrl ( route . id ) . startsWith ( ENVIRONMENT_URL ) )
53- . map ( ( route ) => ( {
54- suffix : compiledUrl ( route . id ) . slice ( ENVIRONMENT_URL . length ) . replace ( / ^ \/ / , "" ) ,
55- rendersAPage : rendersAPage ( route . file ) ,
56- sendsYouHome : sendsYouHome ( route . file ) ,
57- } ) ) ;
77+ . map ( ( route ) => {
78+ const suffix = compiledUrl ( route . id ) . slice ( ENVIRONMENT_URL . length ) . replace ( / ^ \/ / , "" ) ;
79+ return {
80+ suffix,
81+ rendersAPage : rendersAPage ( route . file ) ,
82+ organizationGatedPage : organizationGatedPage ( suffix , route . file ) ,
83+ } ;
84+ } ) ;
5885
5986const environmentRoutes = [ ...new Set ( belowEnvironment . map ( ( route ) => route . suffix ) ) ] ;
6087
@@ -197,14 +224,26 @@ describe("pages a project switch cannot carry", () => {
197224} ) ;
198225
199226describe ( "pages an organization switch cannot carry" , ( ) => {
200- it ( "are the ones whose loaders send you home when the organization is not allowed in" , ( ) => {
201- const sendHome = [
227+ it ( "are the ones whose loaders turn you away when the organization is not allowed in" , ( ) => {
228+ const gated = [
202229 ...new Set (
203- belowEnvironment . filter ( ( route ) => route . sendsYouHome ) . map ( ( route ) => route . suffix )
230+ belowEnvironment
231+ . map ( ( route ) => route . organizationGatedPage )
232+ . filter ( ( page ) : page is string => page !== undefined )
204233 ) ,
205234 ] . sort ( ) ;
206235
207- expect ( sendHome ) . toEqual ( [ ...ORGANIZATION_SPECIFIC_PAGES ] . sort ( ) ) ;
236+ expect ( gated ) . toEqual ( [ ...ORGANIZATION_SPECIFIC_PAGES ] . sort ( ) ) ;
237+ } ) ;
238+
239+ it ( "are read from both ways a loader turns you away, so neither stops being noticed" , ( ) => {
240+ const gatedPage = ( suffix : string ) =>
241+ belowEnvironment . find ( ( route ) => route . suffix === suffix ) ?. organizationGatedPage ;
242+
243+ expect ( gatedPage ( "logs" ) ) . toBe ( "logs" ) ;
244+ expect ( gatedPage ( "dashboards/:dashboardKey" ) ) . toBe ( "dashboards/queues" ) ;
245+ expect ( gatedPage ( "queues/:queueParam" ) ) . toBeUndefined ( ) ;
246+ expect ( gatedPage ( "apikeys" ) ) . toBeUndefined ( ) ;
208247 } ) ;
209248
210249 it ( "still travel with an environment or project switch, which stay in the same organization" , ( ) => {
@@ -214,10 +253,27 @@ describe("pages an organization switch cannot carry", () => {
214253 expect ( ORGANIZATION_PORTABLE_PAGES . has ( page ) ) . toBe ( false ) ;
215254 expect ( environmentPortablePage ( page ) ) . toBe ( page ) ;
216255 expect ( projectPortablePage ( page ) ) . toBe ( page ) ;
217- expect ( organizationPortablePage ( page ) ) . toBe ( "" ) ;
218256 }
219257 } ) ;
220258
259+ it ( "stay put when only the environment changes" , ( ) => {
260+ expect (
261+ pathForEnvironmentSwitch ( {
262+ location : locationOn ( "dashboards/queues" , "?period=1d" ) ,
263+ environmentPathname : environmentLocation . pathname ,
264+ environmentSlug : "preview" ,
265+ } )
266+ ) . toBe ( "/orgs/acme/projects/api/env/preview/dashboards/queues?period=1d" ) ;
267+
268+ expect (
269+ pathForEnvironmentSwitch ( {
270+ location : locationOn ( "logs" ) ,
271+ environmentPathname : environmentLocation . pathname ,
272+ environmentSlug : "prod" ,
273+ } )
274+ ) . toBe ( "/orgs/acme/projects/api/env/prod/logs" ) ;
275+ } ) ;
276+
221277 it ( "are otherwise the same list, so nothing else is quietly dropped" , ( ) => {
222278 const dropped = [ ...PROJECT_PORTABLE_PAGES ]
223279 . filter ( ( page ) => ! ORGANIZATION_PORTABLE_PAGES . has ( page ) )
@@ -226,13 +282,14 @@ describe("pages an organization switch cannot carry", () => {
226282 expect ( dropped ) . toEqual ( [ ...ORGANIZATION_SPECIFIC_PAGES ] . sort ( ) ) ;
227283 } ) ;
228284
229- it ( "fall back to the tasks page when the organization changes " , ( ) => {
285+ it ( "fall back to the nearest page the organization switched into can open " , ( ) => {
230286 const read = ( search : string ) =>
231287 requestedOrganizationPortablePage ( new Request ( `http://localhost/orgs/acme${ search } ` ) ) ;
232288
233289 expect ( portablePageSearch ( organizationPortablePage ( "logs" ) ) ) . toBe ( "" ) ;
234290 expect ( read ( "?page=logs" ) ) . toBe ( "" ) ;
235291 expect ( read ( "?page=query" ) ) . toBe ( "" ) ;
292+ expect ( read ( "?page=dashboards/queues" ) ) . toBe ( "dashboards" ) ;
236293 expect ( read ( "?page=apikeys" ) ) . toBe ( "apikeys" ) ;
237294 } ) ;
238295} ) ;
@@ -278,10 +335,10 @@ describe("pages named after a resource", () => {
278335 expect ( projectPortablePage ( "tasks/scheduled/my-task" ) ) . toBe ( "" ) ;
279336 } ) ;
280337
281- it ( "keep the built-in metric dashboards but not the one gated per organization " , ( ) => {
338+ it ( "keep the built-in metric dashboards, which are pages rather than saved dashboards " , ( ) => {
282339 expect ( projectPortablePage ( "dashboards/overview" ) ) . toBe ( "dashboards/overview" ) ;
283340 expect ( projectPortablePage ( "dashboards/llm" ) ) . toBe ( "dashboards/llm" ) ;
284- expect ( projectPortablePage ( "dashboards/queues" ) ) . toBe ( "dashboards" ) ;
341+ expect ( projectPortablePage ( "dashboards/queues" ) ) . toBe ( "dashboards/queues " ) ;
285342 } ) ;
286343} ) ;
287344
0 commit comments